mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-23 02:40:11 +00:00
feat: add user-facing debug logs
This commit is contained in:
@@ -53,6 +53,7 @@ import ThemeCustomizerDialog from "./ThemeCustomizerDialog.vue";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { clearDebugLogs as clearStoredDebugLogs, downloadDebugLogs, getDebugLogBundleText } from "@/lib/debugLog";
|
||||
import {
|
||||
aiListModels,
|
||||
aiTestConnection,
|
||||
@@ -115,6 +116,9 @@ const editConfirmDangerousSqlExecution = ref(settingsStore.editorSettings.confir
|
||||
const editAppLayout = ref(settingsStore.editorSettings.appLayout);
|
||||
const editShowTrayIcon = ref(settingsStore.desktopSettings.show_tray_icon);
|
||||
const editIconTheme = ref<DesktopIconTheme>(settingsStore.desktopSettings.icon_theme);
|
||||
const editDebugLoggingEnabled = ref(settingsStore.desktopSettings.debug_logging_enabled);
|
||||
const debugLogCopied = ref(false);
|
||||
const debugLogDownloaded = ref(false);
|
||||
const editShowColumnCommentsInHeader = ref(settingsStore.editorSettings.showColumnCommentsInHeader);
|
||||
const editShowColumnTypesInHeader = ref(settingsStore.editorSettings.showColumnTypesInHeader);
|
||||
const editCompactColumnHeaderActions = ref(settingsStore.editorSettings.compactColumnHeaderActions);
|
||||
@@ -273,6 +277,7 @@ watch(
|
||||
editAppLayout.value = settingsStore.editorSettings.appLayout;
|
||||
editShowTrayIcon.value = settingsStore.desktopSettings.show_tray_icon;
|
||||
editIconTheme.value = settingsStore.desktopSettings.icon_theme;
|
||||
editDebugLoggingEnabled.value = settingsStore.desktopSettings.debug_logging_enabled;
|
||||
editShowColumnCommentsInHeader.value = settingsStore.editorSettings.showColumnCommentsInHeader;
|
||||
editShowColumnTypesInHeader.value = settingsStore.editorSettings.showColumnTypesInHeader;
|
||||
editCompactColumnHeaderActions.value = settingsStore.editorSettings.compactColumnHeaderActions;
|
||||
@@ -321,6 +326,7 @@ function hasChanges(): boolean {
|
||||
editAppLayout.value !== settingsStore.editorSettings.appLayout ||
|
||||
editShowTrayIcon.value !== settingsStore.desktopSettings.show_tray_icon ||
|
||||
editIconTheme.value !== settingsStore.desktopSettings.icon_theme ||
|
||||
editDebugLoggingEnabled.value !== settingsStore.desktopSettings.debug_logging_enabled ||
|
||||
editShowColumnCommentsInHeader.value !== settingsStore.editorSettings.showColumnCommentsInHeader ||
|
||||
editShowColumnTypesInHeader.value !== settingsStore.editorSettings.showColumnTypesInHeader ||
|
||||
editCompactColumnHeaderActions.value !== settingsStore.editorSettings.compactColumnHeaderActions ||
|
||||
@@ -376,6 +382,7 @@ async function persistSettings() {
|
||||
await settingsStore.updateDesktopSettings({
|
||||
show_tray_icon: editShowTrayIcon.value,
|
||||
icon_theme: editIconTheme.value,
|
||||
debug_logging_enabled: editDebugLoggingEnabled.value,
|
||||
});
|
||||
if (sidebarObjectDisplayChanged) {
|
||||
await connectionStore.refreshAllTree();
|
||||
@@ -404,6 +411,7 @@ function resetDefaults() {
|
||||
editAppLayout.value = DEFAULT_EDITOR_SETTINGS.appLayout;
|
||||
editShowTrayIcon.value = DEFAULT_DESKTOP_SETTINGS.show_tray_icon;
|
||||
editIconTheme.value = DEFAULT_DESKTOP_SETTINGS.icon_theme;
|
||||
editDebugLoggingEnabled.value = DEFAULT_DESKTOP_SETTINGS.debug_logging_enabled;
|
||||
editShowColumnCommentsInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader;
|
||||
editShowColumnTypesInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnTypesInHeader;
|
||||
editCompactColumnHeaderActions.value = DEFAULT_EDITOR_SETTINGS.compactColumnHeaderActions;
|
||||
@@ -629,6 +637,29 @@ function openExternalUrl(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyDebugLogs() {
|
||||
await copyToClipboard(await getDebugLogBundleText());
|
||||
debugLogCopied.value = true;
|
||||
window.setTimeout(() => {
|
||||
debugLogCopied.value = false;
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function clearDebugLogs() {
|
||||
clearStoredDebugLogs();
|
||||
debugLogCopied.value = false;
|
||||
debugLogDownloaded.value = false;
|
||||
}
|
||||
|
||||
async function exportDebugLogs() {
|
||||
const saved = await downloadDebugLogs();
|
||||
if (!saved) return;
|
||||
debugLogDownloaded.value = true;
|
||||
window.setTimeout(() => {
|
||||
debugLogDownloaded.value = false;
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// ---------- MCP Server ----------
|
||||
const mcpStatus = ref<McpServerStatus | null>(null);
|
||||
const mcpStatusLoading = ref(false);
|
||||
@@ -871,6 +902,7 @@ watch(
|
||||
await settingsStore.initDesktopSettings();
|
||||
editShowTrayIcon.value = settingsStore.desktopSettings.show_tray_icon;
|
||||
editIconTheme.value = settingsStore.desktopSettings.icon_theme;
|
||||
editDebugLoggingEnabled.value = settingsStore.desktopSettings.debug_logging_enabled;
|
||||
webdavPassword.value = "";
|
||||
await refreshWebDavPasswordStatus();
|
||||
syncAiEditState();
|
||||
@@ -1559,6 +1591,29 @@ watch(
|
||||
<Switch id="update-notifications-enabled" v-model="editUpdateNotificationsEnabled" />
|
||||
</div>
|
||||
|
||||
<div v-if="!isWeb" class="flex flex-col gap-3 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<Label for="debug-logging-enabled">{{ t("settings.debugLoggingEnabled") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t("settings.debugLoggingEnabledDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="debug-logging-enabled" v-model="editDebugLoggingEnabled" />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="sm" @click="clearDebugLogs">
|
||||
{{ t("settings.debugLogsClear") }}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" @click="copyDebugLogs">
|
||||
{{ debugLogCopied ? t("settings.debugLogsCopied") : t("settings.debugLogsCopy") }}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" @click="exportDebugLogs">
|
||||
{{ debugLogDownloaded ? t("settings.debugLogsDownloaded") : t("settings.debugLogsDownload") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-3">
|
||||
|
||||
@@ -1673,6 +1673,14 @@ export default {
|
||||
updateNotificationsEnabled: "Enable update reminders",
|
||||
updateNotificationsEnabledDescription:
|
||||
"When disabled, DBX will not automatically check app or driver updates or show update badges. Manual checks are still available.",
|
||||
debugLoggingEnabled: "Enable debug logs",
|
||||
debugLoggingEnabledDescription:
|
||||
"Record more detailed local user-side diagnostic logs while troubleshooting reports. Disabled by default.",
|
||||
debugLogsCopy: "Copy logs",
|
||||
debugLogsCopied: "Copied",
|
||||
debugLogsDownload: "Download logs",
|
||||
debugLogsDownloaded: "Downloaded",
|
||||
debugLogsClear: "Clear logs",
|
||||
dataGridDisplay: "Data grid display",
|
||||
showColumnCommentsInHeader: "Show column comments under names",
|
||||
showColumnCommentsInHeaderDescription: "Display table column comments directly below grid column names.",
|
||||
|
||||
@@ -1526,6 +1526,14 @@ export default {
|
||||
updateNotificationsEnabled: "Activar recordatorios de actualización",
|
||||
updateNotificationsEnabledDescription:
|
||||
"Al desactivarlo, DBX no comprobará automáticamente actualizaciones de la app ni de los controladores, ni mostrará indicadores. Las comprobaciones manuales siguen disponibles.",
|
||||
debugLoggingEnabled: "Activar logs de depuración",
|
||||
debugLoggingEnabledDescription:
|
||||
"Registra logs de diagnóstico locales del lado del usuario al investigar reportes. Desactivado por defecto.",
|
||||
debugLogsCopy: "Copiar logs",
|
||||
debugLogsCopied: "Copiado",
|
||||
debugLogsDownload: "Descargar logs",
|
||||
debugLogsDownloaded: "Descargado",
|
||||
debugLogsClear: "Borrar logs",
|
||||
dataGridDisplay: "Visualización de la tabla",
|
||||
showColumnCommentsInHeader: "Mostrar comentarios bajo los nombres",
|
||||
showColumnCommentsInHeaderDescription:
|
||||
|
||||
@@ -1669,6 +1669,14 @@ export default {
|
||||
updateNotificationsEnabled: "Abilita promemoria aggiornamenti",
|
||||
updateNotificationsEnabledDescription:
|
||||
"Se disattivato, DBX non controllerà automaticamente gli aggiornamenti dell'app o dei driver e non mostrerà badge. I controlli manuali restano disponibili.",
|
||||
debugLoggingEnabled: "Abilita log di debug",
|
||||
debugLoggingEnabledDescription:
|
||||
"Registra log diagnostici locali lato utente durante l'analisi delle segnalazioni. Disattivato per impostazione predefinita.",
|
||||
debugLogsCopy: "Copia log",
|
||||
debugLogsCopied: "Copiati",
|
||||
debugLogsDownload: "Scarica log",
|
||||
debugLogsDownloaded: "Scaricati",
|
||||
debugLogsClear: "Cancella log",
|
||||
dataGridDisplay: "Visualizzazione griglia dati",
|
||||
showColumnCommentsInHeader: "Mostra i commenti delle colonne sotto i nomi",
|
||||
showColumnCommentsInHeaderDescription:
|
||||
|
||||
@@ -1659,6 +1659,14 @@ export default {
|
||||
updateNotificationsEnabled: "Ativar lembretes de atualização",
|
||||
updateNotificationsEnabledDescription:
|
||||
"Quando desativado, o DBX não verificará automaticamente atualizações do app ou dos drivers nem mostrará indicadores. Verificações manuais continuam disponíveis.",
|
||||
debugLoggingEnabled: "Ativar logs de depuração",
|
||||
debugLoggingEnabledDescription:
|
||||
"Registra logs locais de diagnóstico do lado do usuário ao investigar relatos. Desativado por padrão.",
|
||||
debugLogsCopy: "Copiar logs",
|
||||
debugLogsCopied: "Copiado",
|
||||
debugLogsDownload: "Baixar logs",
|
||||
debugLogsDownloaded: "Baixado",
|
||||
debugLogsClear: "Limpar logs",
|
||||
dataGridDisplay: "Exibição da grade de dados",
|
||||
showColumnCommentsInHeader: "Mostrar comentários de coluna sob os nomes",
|
||||
showColumnCommentsInHeaderDescription:
|
||||
|
||||
@@ -1642,6 +1642,13 @@ export default {
|
||||
updateNotificationsEnabled: "启用更新提醒",
|
||||
updateNotificationsEnabledDescription:
|
||||
"关闭后,DBX 不会自动检查应用和驱动更新,也不会显示更新红点;仍可手动检查更新。",
|
||||
debugLoggingEnabled: "启用调试日志",
|
||||
debugLoggingEnabledDescription: "开启后在本机记录更详细的用户侧诊断日志,反馈问题时可临时开启;默认关闭。",
|
||||
debugLogsCopy: "复制日志",
|
||||
debugLogsCopied: "已复制",
|
||||
debugLogsDownload: "下载日志",
|
||||
debugLogsDownloaded: "已下载",
|
||||
debugLogsClear: "清空日志",
|
||||
dataGridDisplay: "数据表格显示",
|
||||
showColumnCommentsInHeader: "在字段名下方显示注释",
|
||||
showColumnCommentsInHeaderDescription: "把表字段注释直接显示在结果表头字段名下方。",
|
||||
|
||||
@@ -1583,6 +1583,13 @@ export default {
|
||||
updateNotificationsEnabled: "啟用更新提醒",
|
||||
updateNotificationsEnabledDescription:
|
||||
"關閉後,DBX 不會自動檢查應用程式和驅動程式更新,也不會顯示更新紅點;仍可手動檢查更新。",
|
||||
debugLoggingEnabled: "啟用偵錯日誌",
|
||||
debugLoggingEnabledDescription: "開啟後在本機記錄更詳細的使用者側診斷日誌,回報問題時可暫時開啟;預設關閉。",
|
||||
debugLogsCopy: "複製日誌",
|
||||
debugLogsCopied: "已複製",
|
||||
debugLogsDownload: "下載日誌",
|
||||
debugLogsDownloaded: "已下載",
|
||||
debugLogsClear: "清空日誌",
|
||||
dataGridDisplay: "資料表格顯示",
|
||||
showColumnCommentsInHeader: "在欄位名稱下方顯示註解",
|
||||
showColumnCommentsInHeaderDescription: "直接在資料表格欄位名稱下方顯示資料表欄位註解。",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isTauriRuntime } from "./tauriRuntime";
|
||||
import type * as TauriModule from "./tauri";
|
||||
import { appendDebugLog } from "./debugLog";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy backend resolution (avoids top-level await)
|
||||
@@ -21,8 +22,25 @@ async function getBackend(): Promise<Backend> {
|
||||
|
||||
function forward<K extends keyof Backend>(name: K): Backend[K] {
|
||||
return (async (...args: unknown[]) => {
|
||||
const startedAt = performance.now();
|
||||
const operation = String(name);
|
||||
appendDebugLog("debug", "[DBX][api:start]", operation);
|
||||
const b = await getBackend();
|
||||
return (b[name] as (...a: unknown[]) => unknown)(...args);
|
||||
try {
|
||||
const result = await (b[name] as (...a: unknown[]) => unknown)(...args);
|
||||
appendDebugLog("debug", "[DBX][api:success]", {
|
||||
operation,
|
||||
elapsedMs: Math.round(performance.now() - startedAt),
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
appendDebugLog("error", "[DBX][api:error]", {
|
||||
operation,
|
||||
elapsedMs: Math.round(performance.now() - startedAt),
|
||||
error,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}) as unknown as Backend[K];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
const DEBUG_LOG_ENABLED_KEY = "dbx-debug-logging-enabled";
|
||||
const DEBUG_LOG_ENTRIES_KEY = "dbx-debug-log-entries";
|
||||
const MAX_DEBUG_LOG_ENTRIES = 1500;
|
||||
const MAX_TEXT_LENGTH = 4000;
|
||||
const MAX_LABEL_LENGTH = 120;
|
||||
|
||||
type DebugLogLevel = "debug" | "info" | "log" | "warn" | "error";
|
||||
|
||||
interface DebugLogEntry {
|
||||
timestamp: string;
|
||||
level: DebugLogLevel;
|
||||
message: string;
|
||||
}
|
||||
|
||||
let installed = false;
|
||||
let originalConsole: Partial<Record<DebugLogLevel, (...args: unknown[]) => void>> = {};
|
||||
|
||||
function safeLocalStorageGet(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function safeLocalStorageSet(key: string, value: string) {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Ignore quota or unavailable storage errors. The app should keep running.
|
||||
}
|
||||
}
|
||||
|
||||
function safeLocalStorageRemove(key: string) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function isDebugLoggingEnabled(): boolean {
|
||||
return safeLocalStorageGet(DEBUG_LOG_ENABLED_KEY) === "1";
|
||||
}
|
||||
|
||||
function readEntries(): DebugLogEntry[] {
|
||||
const raw = safeLocalStorageGet(DEBUG_LOG_ENTRIES_KEY);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.slice(-MAX_DEBUG_LOG_ENTRIES) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function redactSensitiveText(value: string): string {
|
||||
return value
|
||||
.replace(
|
||||
/("(?:password|passphrase|apiKey|api_key|token|secret|connectionString|connection_string)"\s*:\s*")([^"]*)(")/gi,
|
||||
"$1[redacted]$3",
|
||||
)
|
||||
.replace(/\b(password|passphrase|apiKey|api_key|token|secret)=([^&\s]+)/gi, "$1=[redacted]");
|
||||
}
|
||||
|
||||
function formatValue(value: unknown, seen = new WeakSet<object>()): string {
|
||||
if (value instanceof Error) {
|
||||
return redactSensitiveText([value.name, value.message, value.stack].filter(Boolean).join(": "));
|
||||
}
|
||||
if (typeof value === "string") return redactSensitiveText(value);
|
||||
if (typeof value !== "object" || value === null) return String(value);
|
||||
if (seen.has(value)) return "[Circular]";
|
||||
seen.add(value);
|
||||
try {
|
||||
return redactSensitiveText(
|
||||
JSON.stringify(value, (_key, item) => {
|
||||
if (typeof item === "function") return `[Function ${item.name || "anonymous"}]`;
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return redactSensitiveText(String(value));
|
||||
}
|
||||
}
|
||||
|
||||
function formatArgs(args: unknown[]): string {
|
||||
const message = args.map((arg) => formatValue(arg)).join(" ");
|
||||
return message.length > MAX_TEXT_LENGTH ? `${message.slice(0, MAX_TEXT_LENGTH)}...` : message;
|
||||
}
|
||||
|
||||
export function appendDebugLog(level: DebugLogLevel, ...args: unknown[]) {
|
||||
if (!isDebugLoggingEnabled()) return;
|
||||
const entries = readEntries();
|
||||
entries.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message: formatArgs(args),
|
||||
});
|
||||
safeLocalStorageSet(DEBUG_LOG_ENTRIES_KEY, JSON.stringify(entries.slice(-MAX_DEBUG_LOG_ENTRIES)));
|
||||
}
|
||||
|
||||
export function setDebugLoggingEnabled(enabled: boolean) {
|
||||
safeLocalStorageSet(DEBUG_LOG_ENABLED_KEY, enabled ? "1" : "0");
|
||||
if (enabled) {
|
||||
appendDebugLog("info", "[DBX][debug-log] enabled", {
|
||||
url: location.href,
|
||||
viewport: `${window.innerWidth}x${window.innerHeight}`,
|
||||
devicePixelRatio: window.devicePixelRatio,
|
||||
language: navigator.language,
|
||||
online: navigator.onLine,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDebugLogs() {
|
||||
safeLocalStorageRemove(DEBUG_LOG_ENTRIES_KEY);
|
||||
}
|
||||
|
||||
export function getDebugLogText(): string {
|
||||
const entries = readEntries();
|
||||
const header = [
|
||||
`DBX debug log`,
|
||||
`Exported: ${new Date().toISOString()}`,
|
||||
`User agent: ${navigator.userAgent}`,
|
||||
`Platform: ${navigator.platform}`,
|
||||
`Timezone: ${Intl.DateTimeFormat().resolvedOptions().timeZone || "unknown"}`,
|
||||
"",
|
||||
];
|
||||
const body = entries.map((entry) => `[${entry.timestamp}] [${entry.level.toUpperCase()}] ${entry.message}`);
|
||||
return [...header, ...body].join("\n");
|
||||
}
|
||||
|
||||
export async function downloadDebugLogs() {
|
||||
const text = await getDebugLogBundleText();
|
||||
const filename = `dbx-debug-log-${new Date().toISOString().replace(/[:.]/g, "-")}.txt`;
|
||||
if (typeof window !== "undefined" && isTauriRuntimeLike()) {
|
||||
const [{ save }, { writeTextFile }] = await Promise.all([
|
||||
import("@tauri-apps/plugin-dialog"),
|
||||
import("@tauri-apps/plugin-fs"),
|
||||
]);
|
||||
const path = await save({
|
||||
defaultPath: filename,
|
||||
filters: [{ name: "Text", extensions: ["txt", "log"] }],
|
||||
});
|
||||
if (!path) return false;
|
||||
await writeTextFile(path, text);
|
||||
return true;
|
||||
}
|
||||
|
||||
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getDebugLogBundleText(): Promise<string> {
|
||||
const sections = [getDebugLogText()];
|
||||
const nativeLogs = await loadNativeDebugLogs().catch((error) => `Failed to load native logs: ${formatValue(error)}`);
|
||||
if (nativeLogs) {
|
||||
sections.push(["", "===== Native / Tauri logs =====", nativeLogs].join("\n"));
|
||||
}
|
||||
return sections.join("\n");
|
||||
}
|
||||
|
||||
async function loadNativeDebugLogs(): Promise<string> {
|
||||
if (!isTauriRuntimeLike()) return "";
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
return invoke<string>("load_native_debug_logs");
|
||||
}
|
||||
|
||||
function isTauriRuntimeLike(): boolean {
|
||||
return "__TAURI_INTERNALS__" in globalThis || "__TAURI__" in globalThis;
|
||||
}
|
||||
|
||||
export function installDebugLogCapture() {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
originalConsole = {
|
||||
debug: console.debug.bind(console),
|
||||
info: console.info.bind(console),
|
||||
log: console.log.bind(console),
|
||||
warn: console.warn.bind(console),
|
||||
error: console.error.bind(console),
|
||||
};
|
||||
(Object.keys(originalConsole) as DebugLogLevel[]).forEach((level) => {
|
||||
console[level] = (...args: unknown[]) => {
|
||||
appendDebugLog(level, ...args);
|
||||
originalConsole[level]?.(...args);
|
||||
};
|
||||
});
|
||||
window.addEventListener("error", (event) => {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLElement) {
|
||||
appendDebugLog("error", "[window:resource-error]", describeElement(target));
|
||||
return;
|
||||
}
|
||||
appendDebugLog("error", "[window:error]", event.error ?? event.message);
|
||||
});
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
appendDebugLog("error", "[window:unhandledrejection]", event.reason);
|
||||
});
|
||||
window.addEventListener("online", () => appendDebugLog("info", "[window:online]"));
|
||||
window.addEventListener("offline", () => appendDebugLog("warn", "[window:offline]"));
|
||||
window.addEventListener("hashchange", () => appendDebugLog("info", "[window:hashchange]", location.hash));
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
appendDebugLog("info", "[document:visibilitychange]", document.visibilityState);
|
||||
});
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const target = event.target instanceof HTMLElement ? interactiveElement(event.target) : null;
|
||||
if (!target) return;
|
||||
appendDebugLog("debug", "[ui:click]", describeElement(target));
|
||||
},
|
||||
true,
|
||||
);
|
||||
document.addEventListener(
|
||||
"change",
|
||||
(event) => {
|
||||
const target = event.target instanceof HTMLElement ? event.target : null;
|
||||
if (!target || !isFormControl(target)) return;
|
||||
appendDebugLog("debug", "[ui:change]", describeElement(target));
|
||||
},
|
||||
true,
|
||||
);
|
||||
installPerformanceCapture();
|
||||
}
|
||||
|
||||
function installPerformanceCapture() {
|
||||
if (typeof PerformanceObserver === "undefined") return;
|
||||
try {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.duration >= 250) {
|
||||
appendDebugLog("warn", "[performance:long-task]", {
|
||||
name: entry.name,
|
||||
durationMs: Math.round(entry.duration),
|
||||
startTimeMs: Math.round(entry.startTime),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe({ entryTypes: ["longtask"] });
|
||||
} catch {
|
||||
// Long task entries are not available in every WebView.
|
||||
}
|
||||
}
|
||||
|
||||
function interactiveElement(target: HTMLElement): HTMLElement | null {
|
||||
return target.closest(
|
||||
[
|
||||
"button",
|
||||
"a[href]",
|
||||
"input",
|
||||
"select",
|
||||
"textarea",
|
||||
"[role='button']",
|
||||
"[role='menuitem']",
|
||||
"[role='tab']",
|
||||
"[data-debug-id]",
|
||||
].join(","),
|
||||
);
|
||||
}
|
||||
|
||||
function isFormControl(target: HTMLElement): boolean {
|
||||
return (
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target.getAttribute("role") === "switch"
|
||||
);
|
||||
}
|
||||
|
||||
function describeElement(element: HTMLElement): Record<string, string> {
|
||||
const label =
|
||||
element.getAttribute("data-debug-id") ||
|
||||
element.getAttribute("aria-label") ||
|
||||
element.getAttribute("title") ||
|
||||
element.textContent ||
|
||||
"";
|
||||
const attrs: Record<string, string> = {
|
||||
tag: element.tagName.toLowerCase(),
|
||||
};
|
||||
if (element.id) attrs.id = element.id;
|
||||
const role = element.getAttribute("role");
|
||||
if (role) attrs.role = role;
|
||||
const type = element.getAttribute("type");
|
||||
if (type) attrs.type = type;
|
||||
const safeLabel = redactSensitiveText(label.trim().replace(/\s+/g, " "));
|
||||
if (safeLabel) attrs.label = safeLabel.slice(0, MAX_LABEL_LENGTH);
|
||||
const testId = element.getAttribute("data-testid");
|
||||
if (testId) attrs.testId = testId;
|
||||
return attrs;
|
||||
}
|
||||
@@ -874,7 +874,7 @@ export async function loadAiConfig(): Promise<AiConfig | null> {
|
||||
}
|
||||
|
||||
export async function loadDesktopSettings(): Promise<DesktopSettings> {
|
||||
return { show_tray_icon: true, icon_theme: "default" };
|
||||
return { show_tray_icon: true, icon_theme: "default", debug_logging_enabled: false };
|
||||
}
|
||||
|
||||
export async function saveDesktopSettings(_settings: DesktopSettings): Promise<void> {
|
||||
|
||||
@@ -145,6 +145,7 @@ export interface DriverRuntimeSummary {
|
||||
export interface DesktopSettings {
|
||||
show_tray_icon: boolean;
|
||||
icon_theme: "default" | "black";
|
||||
debug_logging_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface WebDavConfig {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPinia } from "pinia";
|
||||
import VueVirtualScroller from "vue-virtual-scroller";
|
||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||
import "./styles/globals.css";
|
||||
import { installDebugLogCapture } from "@/lib/debugLog";
|
||||
|
||||
function startupErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
@@ -89,5 +90,6 @@ async function bootstrap() {
|
||||
console.log("[STARTUP] vue mounted");
|
||||
}
|
||||
|
||||
installDebugLogCapture();
|
||||
installStartupErrorHandlers();
|
||||
void bootstrap().catch(renderStartupError);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebarTableNameDispl
|
||||
import type { SidebarActivation } from "@/lib/treeNodeClick";
|
||||
import type { SqlSnippet } from "@/types/database";
|
||||
import { DEFAULT_SQL_SNIPPETS } from "@/lib/sqlCompletion";
|
||||
import { setDebugLoggingEnabled } from "@/lib/debugLog";
|
||||
|
||||
export type AiProvider =
|
||||
| "claude"
|
||||
@@ -39,6 +40,7 @@ export interface AiConfig {
|
||||
export interface DesktopSettings {
|
||||
show_tray_icon: boolean;
|
||||
icon_theme: DesktopIconTheme;
|
||||
debug_logging_enabled: boolean;
|
||||
}
|
||||
|
||||
export type DesktopIconTheme = "default" | "black";
|
||||
@@ -46,6 +48,7 @@ export type DesktopIconTheme = "default" | "black";
|
||||
export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||
show_tray_icon: true,
|
||||
icon_theme: "default",
|
||||
debug_logging_enabled: false,
|
||||
};
|
||||
|
||||
function normalizeDesktopSettings(settings: Partial<DesktopSettings> | null | undefined): DesktopSettings {
|
||||
@@ -53,6 +56,7 @@ function normalizeDesktopSettings(settings: Partial<DesktopSettings> | null | un
|
||||
return {
|
||||
show_tray_icon: settings?.show_tray_icon ?? DEFAULT_DESKTOP_SETTINGS.show_tray_icon,
|
||||
icon_theme: iconTheme,
|
||||
debug_logging_enabled: settings?.debug_logging_enabled ?? DEFAULT_DESKTOP_SETTINGS.debug_logging_enabled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -567,6 +571,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
async function initDesktopSettings() {
|
||||
if (isDesktopSettingsLoaded.value) return;
|
||||
desktopSettings.value = normalizeDesktopSettings(await api.loadDesktopSettings().catch(() => null));
|
||||
setDebugLoggingEnabled(desktopSettings.value.debug_logging_enabled);
|
||||
isDesktopSettingsLoaded.value = true;
|
||||
}
|
||||
|
||||
@@ -577,10 +582,12 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
...partial,
|
||||
};
|
||||
desktopSettings.value = normalizeDesktopSettings(next);
|
||||
setDebugLoggingEnabled(desktopSettings.value.debug_logging_enabled);
|
||||
try {
|
||||
await api.saveDesktopSettings(desktopSettings.value);
|
||||
} catch (error) {
|
||||
desktopSettings.value = previous;
|
||||
setDebugLoggingEnabled(previous.debug_logging_enabled);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,11 +32,13 @@ pub struct TabRuntimeCacheEntry {
|
||||
pub struct DesktopSettings {
|
||||
pub show_tray_icon: bool,
|
||||
pub icon_theme: DesktopIconTheme,
|
||||
#[serde(default)]
|
||||
pub debug_logging_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for DesktopSettings {
|
||||
fn default() -> Self {
|
||||
Self { show_tray_icon: true, icon_theme: DesktopIconTheme::Default }
|
||||
Self { show_tray_icon: true, icon_theme: DesktopIconTheme::Default, debug_logging_enabled: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,6 +432,10 @@ impl Storage {
|
||||
"icon_theme".to_string(),
|
||||
serde_json::to_value(desktop_settings.icon_theme).map_err(|e| e.to_string())?,
|
||||
);
|
||||
settings.insert(
|
||||
"debug_logging_enabled".to_string(),
|
||||
serde_json::Value::Bool(desktop_settings.debug_logging_enabled),
|
||||
);
|
||||
self.save_app_settings_json(&settings).await
|
||||
}
|
||||
|
||||
@@ -442,6 +448,10 @@ impl Storage {
|
||||
.or_else(|| settings.get("run_in_background").and_then(|value| value.as_bool()))
|
||||
.unwrap_or_else(|| DesktopSettings::default().show_tray_icon),
|
||||
icon_theme: DesktopIconTheme::from_settings_value(settings.get("icon_theme")),
|
||||
debug_logging_enabled: settings
|
||||
.get("debug_logging_enabled")
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or_else(|| DesktopSettings::default().debug_logging_enabled),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1334,14 +1344,18 @@ mod tests {
|
||||
|
||||
storage.save_password_hash("hash-1").await.unwrap();
|
||||
storage
|
||||
.save_desktop_settings(&DesktopSettings { show_tray_icon: false, icon_theme: DesktopIconTheme::Black })
|
||||
.save_desktop_settings(&DesktopSettings {
|
||||
show_tray_icon: false,
|
||||
icon_theme: DesktopIconTheme::Black,
|
||||
debug_logging_enabled: true,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(storage.load_password_hash().await.unwrap(), Some("hash-1".to_string()));
|
||||
assert_eq!(
|
||||
storage.load_desktop_settings().await.unwrap(),
|
||||
DesktopSettings { show_tray_icon: false, icon_theme: DesktopIconTheme::Black }
|
||||
DesktopSettings { show_tray_icon: false, icon_theme: DesktopIconTheme::Black, debug_logging_enabled: true }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1365,6 +1379,7 @@ mod tests {
|
||||
assert_eq!(settings.get("run_in_background"), None);
|
||||
assert_eq!(settings.get("show_tray_icon").and_then(|value| value.as_bool()), Some(true));
|
||||
assert_eq!(settings.get("icon_theme").and_then(|value| value.as_str()), Some("black"));
|
||||
assert_eq!(settings.get("debug_logging_enabled").and_then(|value| value.as_bool()), Some(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1373,7 +1388,11 @@ mod tests {
|
||||
let storage = Storage::open(&path).await.unwrap();
|
||||
|
||||
storage
|
||||
.save_desktop_settings(&DesktopSettings { show_tray_icon: false, icon_theme: DesktopIconTheme::Black })
|
||||
.save_desktop_settings(&DesktopSettings {
|
||||
show_tray_icon: false,
|
||||
icon_theme: DesktopIconTheme::Black,
|
||||
..DesktopSettings::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
storage.save_password_hash("hash-2").await.unwrap();
|
||||
@@ -1381,7 +1400,11 @@ mod tests {
|
||||
assert_eq!(storage.load_password_hash().await.unwrap(), Some("hash-2".to_string()));
|
||||
assert_eq!(
|
||||
storage.load_desktop_settings().await.unwrap(),
|
||||
DesktopSettings { show_tray_icon: false, icon_theme: DesktopIconTheme::Black }
|
||||
DesktopSettings {
|
||||
show_tray_icon: false,
|
||||
icon_theme: DesktopIconTheme::Black,
|
||||
..DesktopSettings::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use dbx_core::storage::DesktopSettings;
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
use super::connection::AppState;
|
||||
use crate::apply_desktop_settings;
|
||||
use crate::{apply_debug_log_level, apply_desktop_settings};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_desktop_settings(state: State<'_, Arc<AppState>>) -> Result<DesktopSettings, String> {
|
||||
@@ -18,6 +18,7 @@ pub async fn save_desktop_settings(
|
||||
settings: DesktopSettings,
|
||||
) -> Result<(), String> {
|
||||
state.storage.save_desktop_settings(&settings).await?;
|
||||
apply_debug_log_level(settings.debug_logging_enabled);
|
||||
if let Err(err) = apply_desktop_settings(&app, &settings) {
|
||||
eprintln!("Failed to apply desktop settings: {err}");
|
||||
}
|
||||
@@ -33,3 +34,43 @@ pub async fn load_pinned_tree_node_ids(state: State<'_, Arc<AppState>>) -> Resul
|
||||
pub async fn save_pinned_tree_node_ids(state: State<'_, Arc<AppState>>, ids: Vec<String>) -> Result<(), String> {
|
||||
state.storage.save_pinned_tree_node_ids(&ids).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_native_debug_logs(app: AppHandle) -> Result<String, String> {
|
||||
const MAX_FILES: usize = 6;
|
||||
const MAX_FILE_BYTES: u64 = 512 * 1024;
|
||||
let log_dir = app.path().app_log_dir().map_err(|e| e.to_string())?;
|
||||
if !log_dir.exists() {
|
||||
return Ok(format!("Native log dir does not exist yet: {}", log_dir.display()));
|
||||
}
|
||||
let mut files = std::fs::read_dir(&log_dir)
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter_map(|entry| {
|
||||
let path = entry.path();
|
||||
let metadata = entry.metadata().ok()?;
|
||||
if !metadata.is_file() {
|
||||
return None;
|
||||
}
|
||||
let modified = metadata.modified().ok()?;
|
||||
Some((path, modified, metadata.len()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
files.sort_by_key(|(_, modified, _)| *modified);
|
||||
files.reverse();
|
||||
|
||||
let mut output = String::new();
|
||||
output.push_str(&format!("Native log dir: {}\n", log_dir.display()));
|
||||
for (path, _, len) in files.into_iter().take(MAX_FILES) {
|
||||
let name = path.file_name().and_then(|name| name.to_str()).unwrap_or("unknown");
|
||||
output.push_str(&format!("\n===== {name} =====\n"));
|
||||
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
|
||||
let start = if len > MAX_FILE_BYTES { bytes.len().saturating_sub(MAX_FILE_BYTES as usize) } else { 0 };
|
||||
if start > 0 {
|
||||
output.push_str("[truncated to last 512 KiB]\n");
|
||||
}
|
||||
output.push_str(&String::from_utf8_lossy(&bytes[start..]));
|
||||
output.push('\n');
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ const DESKTOP_TRAY_ID: &str = "main-tray";
|
||||
const MACOS_TRAY_ICON: tauri::image::Image<'_> = tauri::include_image!("icons/tray-macos-template.png");
|
||||
const BLACK_APP_ICON: tauri::image::Image<'_> = tauri::include_image!("icons/icon-black.png");
|
||||
|
||||
pub(crate) fn apply_debug_log_level(debug_logging_enabled: bool) {
|
||||
log::set_max_level(if debug_logging_enabled { log::LevelFilter::Debug } else { log::LevelFilter::Off });
|
||||
}
|
||||
|
||||
fn should_hide_window_on_close(target_os: &str) -> bool {
|
||||
matches!(target_os, "macos" | "windows")
|
||||
}
|
||||
@@ -153,6 +157,7 @@ fn apply_desktop_tray_icon_theme(app: &tauri::AppHandle, icon_theme: DesktopIcon
|
||||
}
|
||||
|
||||
pub(crate) fn apply_desktop_settings(app: &tauri::AppHandle, desktop_settings: &DesktopSettings) -> tauri::Result<()> {
|
||||
apply_debug_log_level(desktop_settings.debug_logging_enabled);
|
||||
apply_desktop_icon_theme(app, desktop_settings.icon_theme)?;
|
||||
if matches!(std::env::consts::OS, "macos" | "windows") {
|
||||
if let Some(tray) = app.tray_by_id(DESKTOP_TRAY_ID) {
|
||||
@@ -266,10 +271,6 @@ pub fn run() {
|
||||
let setup_start = Instant::now();
|
||||
eprintln!("[STARTUP] plugins registered in {:?}", startup_begin.elapsed());
|
||||
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(tauri_plugin_log::Builder::default().level(log::LevelFilter::Info).build())?;
|
||||
}
|
||||
|
||||
let default_data_dir =
|
||||
app.path().app_data_dir().map_err(|e| e.to_string()).expect("Failed to resolve app data dir");
|
||||
let data_dir = data_dir::resolve_data_dir(default_data_dir);
|
||||
@@ -286,6 +287,8 @@ pub fn run() {
|
||||
s
|
||||
});
|
||||
let desktop_settings = tauri::async_runtime::block_on(storage.load_desktop_settings()).unwrap_or_default();
|
||||
app.handle().plugin(tauri_plugin_log::Builder::default().level(log::LevelFilter::Debug).build())?;
|
||||
apply_debug_log_level(desktop_settings.debug_logging_enabled);
|
||||
eprintln!("[STARTUP] storage ready in {:?}", t.elapsed());
|
||||
|
||||
let state = if data_dir::uses_custom_data_dir() {
|
||||
@@ -355,6 +358,7 @@ pub fn run() {
|
||||
commands::app_settings::save_desktop_settings,
|
||||
commands::app_settings::load_pinned_tree_node_ids,
|
||||
commands::app_settings::save_pinned_tree_node_ids,
|
||||
commands::app_settings::load_native_debug_logs,
|
||||
commands::cloud_sync::webdav_sync_test,
|
||||
commands::cloud_sync::webdav_password_status,
|
||||
commands::cloud_sync::save_webdav_saved_password,
|
||||
|
||||
Reference in New Issue
Block a user