From 5c47fa39b70e00eca962b3a07ba6af3b074c4a5d Mon Sep 17 00:00:00 2001 From: Chris Sherwood Date: Thu, 12 Mar 2026 14:24:28 -0700 Subject: [PATCH] feat(UI): add Debug Info modal for bug reporting Add a "Debug Info" link to the footer and settings sidebar that opens a modal with non-sensitive system information (version, OS, hardware, GPU, installed services, internet status, update availability). Users can copy the formatted text and paste it into GitHub issues. Co-Authored-By: Claude Opus 4.6 --- admin/app/controllers/system_controller.ts | 5 + admin/app/services/system_service.ts | 111 ++++++++++++++++++++ admin/inertia/components/DebugInfoModal.tsx | 103 ++++++++++++++++++ admin/inertia/components/Footer.tsx | 14 +++ admin/inertia/components/StyledSidebar.tsx | 12 ++- admin/inertia/lib/api.ts | 7 ++ admin/start/routes.ts | 1 + 7 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 admin/inertia/components/DebugInfoModal.tsx diff --git a/admin/app/controllers/system_controller.ts b/admin/app/controllers/system_controller.ts index cdcde7f..0c3e1ad 100644 --- a/admin/app/controllers/system_controller.ts +++ b/admin/app/controllers/system_controller.ts @@ -113,6 +113,11 @@ export default class SystemController { return await this.systemService.subscribeToReleaseNotes(reqData.email); } + async getDebugInfo({}: HttpContext) { + const debugInfo = await this.systemService.getDebugInfo() + return { debugInfo } + } + async checkServiceUpdates({ response }: HttpContext) { await CheckServiceUpdatesJob.dispatch() response.send({ success: true, message: 'Service update check dispatched' }) diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index 396ff30..ab450b6 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -410,6 +410,117 @@ export class SystemService { } } + async getDebugInfo(): Promise { + const appVersion = SystemService.getAppVersion() + const environment = process.env.NODE_ENV || 'unknown' + + const [systemInfo, services, internetStatus, versionCheck] = await Promise.all([ + this.getSystemInfo(), + this.getServices({ installedOnly: false }), + this.getInternetStatus().catch(() => null), + this.checkLatestVersion().catch(() => null), + ]) + + const lines: string[] = [ + 'Project NOMAD Debug Info', + '========================', + `App Version: ${appVersion}`, + `Environment: ${environment}`, + ] + + if (systemInfo) { + const { cpu, mem, os, disk, fsSize, uptime, graphics } = systemInfo + + lines.push('') + lines.push('System:') + if (os.distro) lines.push(` OS: ${os.distro}`) + if (os.hostname) lines.push(` Hostname: ${os.hostname}`) + if (os.kernel) lines.push(` Kernel: ${os.kernel}`) + if (os.arch) lines.push(` Architecture: ${os.arch}`) + if (uptime?.uptime) lines.push(` Uptime: ${this._formatUptime(uptime.uptime)}`) + + lines.push('') + lines.push('Hardware:') + if (cpu.brand) { + lines.push(` CPU: ${cpu.brand} (${cpu.cores} cores)`) + } + if (mem.total) { + const total = this._formatBytes(mem.total) + const used = this._formatBytes(mem.total - (mem.available || 0)) + const available = this._formatBytes(mem.available || 0) + lines.push(` RAM: ${total} total, ${used} used, ${available} available`) + } + if (graphics.controllers && graphics.controllers.length > 0) { + for (const gpu of graphics.controllers) { + const vram = gpu.vram ? ` (${gpu.vram} MB VRAM)` : '' + lines.push(` GPU: ${gpu.model}${vram}`) + } + } else { + lines.push(' GPU: None detected') + } + + // Disk info — try disk array first, fall back to fsSize + const diskEntries = disk.filter((d) => d.totalSize > 0) + if (diskEntries.length > 0) { + for (const d of diskEntries) { + const size = this._formatBytes(d.totalSize) + const type = d.tran?.toUpperCase() || (d.rota ? 'HDD' : 'SSD') + lines.push(` Disk: ${size}, ${Math.round(d.percentUsed)}% used, ${type}`) + } + } else if (fsSize.length > 0) { + const realFs = fsSize.filter((f) => f.fs.startsWith('/dev/')) + const seen = new Set() + for (const f of realFs) { + if (seen.has(f.size)) continue + seen.add(f.size) + lines.push(` Disk: ${this._formatBytes(f.size)}, ${Math.round(f.use)}% used`) + } + } + } + + const installed = services.filter((s) => s.installed) + lines.push('') + if (installed.length > 0) { + lines.push('Installed Services:') + for (const svc of installed) { + lines.push(` ${svc.friendly_name} (${svc.service_name}): ${svc.status}`) + } + } else { + lines.push('Installed Services: None') + } + + if (internetStatus !== null) { + lines.push('') + lines.push(`Internet Status: ${internetStatus ? 'Online' : 'Offline'}`) + } + + if (versionCheck?.success) { + const updateMsg = versionCheck.updateAvailable + ? `Yes (${versionCheck.latestVersion} available)` + : `No (${versionCheck.currentVersion} is latest)` + lines.push(`Update Available: ${updateMsg}`) + } + + return lines.join('\n') + } + + private _formatUptime(seconds: number): string { + const days = Math.floor(seconds / 86400) + const hours = Math.floor((seconds % 86400) / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + if (days > 0) return `${days}d ${hours}h ${minutes}m` + if (hours > 0) return `${hours}h ${minutes}m` + return `${minutes}m` + } + + private _formatBytes(bytes: number, decimals = 1): string { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i] + } + async updateSetting(key: KVStoreKey, value: any): Promise { if ((value === '' || value === undefined || value === null) && KV_STORE_SCHEMA[key] === 'string') { await KVStore.clearValue(key) diff --git a/admin/inertia/components/DebugInfoModal.tsx b/admin/inertia/components/DebugInfoModal.tsx new file mode 100644 index 0000000..1714663 --- /dev/null +++ b/admin/inertia/components/DebugInfoModal.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from 'react' +import { IconBug, IconCopy, IconCheck } from '@tabler/icons-react' +import StyledModal from './StyledModal' +import api from '~/lib/api' + +interface DebugInfoModalProps { + open: boolean + onClose: () => void +} + +export default function DebugInfoModal({ open, onClose }: DebugInfoModalProps) { + const [debugText, setDebugText] = useState('') + const [loading, setLoading] = useState(false) + const [copied, setCopied] = useState(false) + + useEffect(() => { + if (!open) return + + setLoading(true) + setCopied(false) + + api.getDebugInfo().then((text) => { + if (text) { + const browserLine = `Browser: ${navigator.userAgent}` + setDebugText(text + '\n' + browserLine) + } else { + setDebugText('Failed to load debug info. Please try again.') + } + setLoading(false) + }).catch(() => { + setDebugText('Failed to load debug info. Please try again.') + setLoading(false) + }) + }, [open]) + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(debugText) + } catch { + // Fallback for older browsers + const textarea = document.querySelector('#debug-info-text') + if (textarea) { + textarea.select() + document.execCommand('copy') + } + } + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + } + cancelText="Close" + onCancel={onClose} + > +

+ This is non-sensitive system info you can share when reporting issues. + No passwords, IPs, or API keys are included. +

+ +