feat: add frontend and backend log export in options

This commit is contained in:
Dariusz L
2026-06-30 23:14:37 +02:00
parent f1e863e495
commit 25e20eea8f
4 changed files with 215 additions and 6 deletions
+98
View File
@@ -3935,6 +3935,104 @@ class ModelResolverExtension:
# ==================== SETTINGS (server-side persistence) ====================
def _flush_backend_log_handlers():
for handler in getattr(backend_log_controller, "file_handlers", {}).values():
try:
handler.flush()
except Exception:
pass
def _backend_log_sort_key(path):
name = os.path.basename(path)
rotation = 0
if ".log." in name:
base_name, rotation_text = name.rsplit(".log.", 1)
try:
rotation = int(rotation_text)
except ValueError:
rotation = 999
elif name.endswith(".log"):
base_name = name[:-4]
else:
base_name = name
return (base_name.lower(), rotation)
def _collect_backend_log_files(log_dir):
if not log_dir or not os.path.isdir(log_dir):
return []
files = []
for entry in os.listdir(log_dir):
name = str(entry or "")
if not name.startswith("azlogs_"):
continue
if not (name.endswith(".log") or ".log." in name):
continue
path = os.path.abspath(os.path.join(log_dir, name))
try:
if os.path.commonpath([log_dir, path]) != log_dir:
continue
except ValueError:
continue
if os.path.isfile(path):
files.append(path)
return sorted(files, key=_backend_log_sort_key)
def _build_backend_log_export():
_flush_backend_log_handlers()
raw_log_dir = str(backend_log_controller.config.get("log_dir") or "")
log_dir = os.path.abspath(raw_log_dir) if raw_log_dir else ""
exported_at = time.strftime("%Y-%m-%d %H:%M:%S")
lines = [
"Model Resolver Backend Logs",
f"Exported: {exported_at}",
f"Log directory: {log_dir or 'not configured'}",
f"File logging: {bool(backend_log_controller.config.get('log_to_file'))}",
"",
]
log_files = _collect_backend_log_files(log_dir)
if not log_files:
lines.append("No backend log files found.")
lines.append("")
return "\n".join(lines)
for path in log_files:
name = os.path.basename(path)
try:
stat = os.stat(path)
modified_at = time.strftime(
"%Y-%m-%d %H:%M:%S",
time.localtime(stat.st_mtime),
)
lines.append(
f"===== {name} ({stat.st_size} bytes, modified {modified_at}) ====="
)
with open(path, "r", encoding="utf-8", errors="replace") as log_file:
lines.append(log_file.read().rstrip())
except OSError as exc:
lines.append(f"===== {name} =====")
lines.append(f"Could not read log file: {exc}")
lines.append("")
return "\n".join(lines)
@routes.get("/model_resolver/logs/backend/export")
@json_api_endpoint("backend log export")
async def export_backend_logs_route(request):
"""Download Model Resolver backend logs as a text file."""
content = await asyncio.to_thread(_build_backend_log_export)
filename = f"model_resolver_backend_logs_{time.strftime('%Y%m%d_%H%M%S')}.txt"
return web.Response(
text=content,
content_type="text/plain",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Cache-Control": "no-store",
},
)
def _log_level_setting(value, default: str = BACKEND_DEFAULT_LOG_LEVEL) -> LogLevel:
normalized = str(value or default or "INFO").strip().upper()
if hasattr(LogLevel, normalized):
+7
View File
@@ -6259,6 +6259,13 @@ a.mr-image-preview-resource-name:hover {
height: 14px;
}
.mr-options-export-status {
display: inline-flex;
align-items: center;
min-height: 32px;
margin-top: 0;
}
#model-resolver-modal .mr-options-shell .mr-btn.mr-btn-primary,
#model-resolver-modal .mr-options-shell .mr-btn.mr-btn-primary:hover:not(:disabled) {
color: #101010;
+7 -6
View File
@@ -221,11 +221,11 @@ class Logger {
callsite,
time: new Date()
};
this.logs.push(logData);
if (this.logs.length > this.config.maxStoredLogs) {
this.logs.shift();
}
if (this.config.saveToStorage) {
this.logs.push(logData);
if (this.logs.length > this.config.maxStoredLogs) {
this.logs.shift();
}
this.saveLogs();
}
this.printToConsole(logData);
@@ -662,13 +662,13 @@ class Logger {
exportLogs(format = 'json') {
if (this.logs.length === 0) {
console.warn('No logs to export');
return;
return false;
}
let content;
let mimeType;
let extension;
if (format === 'json') {
content = JSON.stringify(this.logs, null, 2);
content = JSON.stringify(this.logs.map((log) => this.serializeLogEntry(log)), null, 2);
mimeType = 'application/json';
extension = 'json';
}
@@ -690,6 +690,7 @@ class Logger {
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
return true;
}
/**
* Log at DEBUG level
+103
View File
@@ -638,6 +638,10 @@ export const optionsMethods = {
${renderLogLevelOptions(tokens.frontend_log_level)}
</select>
</div>
<div class="mr-options-db-actions mr-options-export-actions">
<button id="mr-options-export-frontend-logs" type="button" class="mr-btn mr-btn-secondary">${getSvgIcon('download')} Export Frontend Logs</button>
<span id="mr-options-export-frontend-logs-status" class="mr-options-db-message mr-options-export-status">Ready to export frontend logs.</span>
</div>
</div>
<label class="mr-options-toggle-row">
<div class="mr-options-toggle-copy">
@@ -657,6 +661,10 @@ export const optionsMethods = {
${renderLogLevelOptions(tokens.backend_log_level)}
</select>
</div>
<div class="mr-options-db-actions mr-options-export-actions">
<button id="mr-options-export-backend-logs" type="button" class="mr-btn mr-btn-secondary">${getSvgIcon('download')} Export Backend Logs</button>
<span id="mr-options-export-backend-logs-status" class="mr-options-db-message mr-options-export-status">Ready to export backend logs.</span>
</div>
</div>
</div>
</div>
@@ -722,6 +730,10 @@ export const optionsMethods = {
const backendLogsEnabledInput = this.contentElement.querySelector('#mr-options-backend-logs-enabled');
const frontendLogLevelInput = this.contentElement.querySelector('#mr-options-frontend-log-level');
const backendLogLevelInput = this.contentElement.querySelector('#mr-options-backend-log-level');
const exportFrontendLogsBtn = this.contentElement.querySelector('#mr-options-export-frontend-logs');
const exportBackendLogsBtn = this.contentElement.querySelector('#mr-options-export-backend-logs');
const exportFrontendLogsStatus = this.contentElement.querySelector('#mr-options-export-frontend-logs-status');
const exportBackendLogsStatus = this.contentElement.querySelector('#mr-options-export-backend-logs-status');
const hfTokenCheckBtn = this.contentElement.querySelector('#mr-options-hf-token-check');
const hfTokenCheckStatus = this.contentElement.querySelector('#mr-options-hf-token-check-status');
const braveKeyCheckBtn = this.contentElement.querySelector('#mr-options-brave-key-check');
@@ -785,6 +797,85 @@ export const optionsMethods = {
if (mode) status.classList.add(mode);
};
const setExportLogsStatus = (statusEl, text) => {
if (statusEl) statusEl.textContent = text;
};
const parseDownloadFilename = (header, fallback) => {
const value = String(header || '');
const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1].trim().replace(/^"|"$/g, ''));
} catch (error) {
return utf8Match[1].trim().replace(/^"|"$/g, '') || fallback;
}
}
const match = value.match(/filename="?([^";]+)"?/i);
return match?.[1]?.trim() || fallback;
};
const downloadBlob = (blob, filename) => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const exportFrontendLogs = () => {
try {
if (exportFrontendLogsBtn) exportFrontendLogsBtn.disabled = true;
const exported = frontendLogger.exportLogs('txt');
if (!exported) {
setExportLogsStatus(exportFrontendLogsStatus, 'No frontend logs captured yet.');
this.showNotification('No frontend logs to export', 'warning');
return;
}
setExportLogsStatus(exportFrontendLogsStatus, 'Frontend logs exported.');
this.showNotification('Frontend logs exported', 'success');
} catch (error) {
console.error('Model Resolver: frontend log export failed:', error);
setExportLogsStatus(exportFrontendLogsStatus, error.message || 'Frontend log export failed.');
this.showNotification('Frontend log export failed', 'error');
} finally {
if (exportFrontendLogsBtn) exportFrontendLogsBtn.disabled = false;
}
};
const exportBackendLogs = async () => {
try {
if (exportBackendLogsBtn) exportBackendLogsBtn.disabled = true;
setExportLogsStatus(exportBackendLogsStatus, 'Preparing backend logs...');
const response = await api.fetchApi('/model_resolver/logs/backend/export');
if (!response.ok) {
let errorMsg = `Server returned ${response.status}: ${response.statusText}`;
try {
const errData = await response.json();
if (errData?.error) errorMsg = errData.error;
} catch (_) { }
throw new Error(errorMsg);
}
const blob = await response.blob();
const filename = parseDownloadFilename(
response.headers.get('Content-Disposition'),
`model_resolver_backend_logs_${new Date().toISOString().replace(/[:.]/g, '-')}.txt`
);
downloadBlob(blob, filename);
setExportLogsStatus(exportBackendLogsStatus, 'Backend logs exported.');
this.showNotification('Backend logs exported', 'success');
} catch (error) {
console.error('Model Resolver: backend log export failed:', error);
setExportLogsStatus(exportBackendLogsStatus, error.message || 'Backend log export failed.');
this.showNotification('Backend log export failed', 'error');
} finally {
if (exportBackendLogsBtn) exportBackendLogsBtn.disabled = false;
}
};
const formatOptionDate = (value) => {
if (!value) return 'Bundled';
const date = new Date(value);
@@ -1823,6 +1914,18 @@ export const optionsMethods = {
});
}
if (exportFrontendLogsBtn) {
exportFrontendLogsBtn.addEventListener('click', () => {
exportFrontendLogs();
});
}
if (exportBackendLogsBtn) {
exportBackendLogsBtn.addEventListener('click', () => {
exportBackendLogs();
});
}
if (clearAllCacheBtn) {
clearAllCacheBtn.addEventListener('click', () => {
clearAllCachesFromOptions();