feat: add frontend and backend log export in options
This commit is contained in:
+98
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user