Skip to content

Commit e4fde22

Browse files
chriscrosstalkclaude
authored andcommitted
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 <noreply@anthropic.com>
1 parent 826c819 commit e4fde22

File tree

7 files changed

+252
-1
lines changed

7 files changed

+252
-1
lines changed

admin/app/controllers/system_controller.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,11 @@ export default class SystemController {
113113
return await this.systemService.subscribeToReleaseNotes(reqData.email);
114114
}
115115

116+
async getDebugInfo({}: HttpContext) {
117+
const debugInfo = await this.systemService.getDebugInfo()
118+
return { debugInfo }
119+
}
120+
116121
async checkServiceUpdates({ response }: HttpContext) {
117122
await CheckServiceUpdatesJob.dispatch()
118123
response.send({ success: true, message: 'Service update check dispatched' })

admin/app/services/system_service.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,117 @@ export class SystemService {
410410
}
411411
}
412412

413+
async getDebugInfo(): Promise<string> {
414+
const appVersion = SystemService.getAppVersion()
415+
const environment = process.env.NODE_ENV || 'unknown'
416+
417+
const [systemInfo, services, internetStatus, versionCheck] = await Promise.all([
418+
this.getSystemInfo(),
419+
this.getServices({ installedOnly: false }),
420+
this.getInternetStatus().catch(() => null),
421+
this.checkLatestVersion().catch(() => null),
422+
])
423+
424+
const lines: string[] = [
425+
'Project NOMAD Debug Info',
426+
'========================',
427+
`App Version: ${appVersion}`,
428+
`Environment: ${environment}`,
429+
]
430+
431+
if (systemInfo) {
432+
const { cpu, mem, os, disk, fsSize, uptime, graphics } = systemInfo
433+
434+
lines.push('')
435+
lines.push('System:')
436+
if (os.distro) lines.push(` OS: ${os.distro}`)
437+
if (os.hostname) lines.push(` Hostname: ${os.hostname}`)
438+
if (os.kernel) lines.push(` Kernel: ${os.kernel}`)
439+
if (os.arch) lines.push(` Architecture: ${os.arch}`)
440+
if (uptime?.uptime) lines.push(` Uptime: ${this._formatUptime(uptime.uptime)}`)
441+
442+
lines.push('')
443+
lines.push('Hardware:')
444+
if (cpu.brand) {
445+
lines.push(` CPU: ${cpu.brand} (${cpu.cores} cores)`)
446+
}
447+
if (mem.total) {
448+
const total = this._formatBytes(mem.total)
449+
const used = this._formatBytes(mem.total - (mem.available || 0))
450+
const available = this._formatBytes(mem.available || 0)
451+
lines.push(` RAM: ${total} total, ${used} used, ${available} available`)
452+
}
453+
if (graphics.controllers && graphics.controllers.length > 0) {
454+
for (const gpu of graphics.controllers) {
455+
const vram = gpu.vram ? ` (${gpu.vram} MB VRAM)` : ''
456+
lines.push(` GPU: ${gpu.model}${vram}`)
457+
}
458+
} else {
459+
lines.push(' GPU: None detected')
460+
}
461+
462+
// Disk info — try disk array first, fall back to fsSize
463+
const diskEntries = disk.filter((d) => d.totalSize > 0)
464+
if (diskEntries.length > 0) {
465+
for (const d of diskEntries) {
466+
const size = this._formatBytes(d.totalSize)
467+
const type = d.tran?.toUpperCase() || (d.rota ? 'HDD' : 'SSD')
468+
lines.push(` Disk: ${size}, ${Math.round(d.percentUsed)}% used, ${type}`)
469+
}
470+
} else if (fsSize.length > 0) {
471+
const realFs = fsSize.filter((f) => f.fs.startsWith('/dev/'))
472+
const seen = new Set<number>()
473+
for (const f of realFs) {
474+
if (seen.has(f.size)) continue
475+
seen.add(f.size)
476+
lines.push(` Disk: ${this._formatBytes(f.size)}, ${Math.round(f.use)}% used`)
477+
}
478+
}
479+
}
480+
481+
const installed = services.filter((s) => s.installed)
482+
lines.push('')
483+
if (installed.length > 0) {
484+
lines.push('Installed Services:')
485+
for (const svc of installed) {
486+
lines.push(` ${svc.friendly_name} (${svc.service_name}): ${svc.status}`)
487+
}
488+
} else {
489+
lines.push('Installed Services: None')
490+
}
491+
492+
if (internetStatus !== null) {
493+
lines.push('')
494+
lines.push(`Internet Status: ${internetStatus ? 'Online' : 'Offline'}`)
495+
}
496+
497+
if (versionCheck?.success) {
498+
const updateMsg = versionCheck.updateAvailable
499+
? `Yes (${versionCheck.latestVersion} available)`
500+
: `No (${versionCheck.currentVersion} is latest)`
501+
lines.push(`Update Available: ${updateMsg}`)
502+
}
503+
504+
return lines.join('\n')
505+
}
506+
507+
private _formatUptime(seconds: number): string {
508+
const days = Math.floor(seconds / 86400)
509+
const hours = Math.floor((seconds % 86400) / 3600)
510+
const minutes = Math.floor((seconds % 3600) / 60)
511+
if (days > 0) return `${days}d ${hours}h ${minutes}m`
512+
if (hours > 0) return `${hours}h ${minutes}m`
513+
return `${minutes}m`
514+
}
515+
516+
private _formatBytes(bytes: number, decimals = 1): string {
517+
if (bytes === 0) return '0 Bytes'
518+
const k = 1024
519+
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
520+
const i = Math.floor(Math.log(bytes) / Math.log(k))
521+
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i]
522+
}
523+
413524
async updateSetting(key: KVStoreKey, value: any): Promise<void> {
414525
if ((value === '' || value === undefined || value === null) && KV_STORE_SCHEMA[key] === 'string') {
415526
await KVStore.clearValue(key)
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { useEffect, useState } from 'react'
2+
import { IconBug, IconCopy, IconCheck } from '@tabler/icons-react'
3+
import StyledModal from './StyledModal'
4+
import api from '~/lib/api'
5+
6+
interface DebugInfoModalProps {
7+
open: boolean
8+
onClose: () => void
9+
}
10+
11+
export default function DebugInfoModal({ open, onClose }: DebugInfoModalProps) {
12+
const [debugText, setDebugText] = useState('')
13+
const [loading, setLoading] = useState(false)
14+
const [copied, setCopied] = useState(false)
15+
16+
useEffect(() => {
17+
if (!open) return
18+
19+
setLoading(true)
20+
setCopied(false)
21+
22+
api.getDebugInfo().then((text) => {
23+
if (text) {
24+
const browserLine = `Browser: ${navigator.userAgent}`
25+
setDebugText(text + '\n' + browserLine)
26+
} else {
27+
setDebugText('Failed to load debug info. Please try again.')
28+
}
29+
setLoading(false)
30+
}).catch(() => {
31+
setDebugText('Failed to load debug info. Please try again.')
32+
setLoading(false)
33+
})
34+
}, [open])
35+
36+
const handleCopy = async () => {
37+
try {
38+
await navigator.clipboard.writeText(debugText)
39+
} catch {
40+
// Fallback for older browsers
41+
const textarea = document.querySelector<HTMLTextAreaElement>('#debug-info-text')
42+
if (textarea) {
43+
textarea.select()
44+
document.execCommand('copy')
45+
}
46+
}
47+
setCopied(true)
48+
setTimeout(() => setCopied(false), 2000)
49+
}
50+
51+
return (
52+
<StyledModal
53+
open={open}
54+
onClose={onClose}
55+
title="Debug Info"
56+
icon={<IconBug className="size-8 text-desert-green" />}
57+
cancelText="Close"
58+
onCancel={onClose}
59+
>
60+
<p className="text-sm text-gray-500 mb-3 text-left">
61+
This is non-sensitive system info you can share when reporting issues.
62+
No passwords, IPs, or API keys are included.
63+
</p>
64+
65+
<textarea
66+
id="debug-info-text"
67+
readOnly
68+
value={loading ? 'Loading...' : debugText}
69+
rows={18}
70+
className="w-full font-mono text-xs bg-gray-50 border border-gray-200 rounded-md p-3 resize-none focus:outline-none text-left"
71+
/>
72+
73+
<div className="mt-3 flex items-center justify-between">
74+
<button
75+
onClick={handleCopy}
76+
disabled={loading}
77+
className="inline-flex items-center gap-1.5 rounded-md bg-desert-green px-3 py-1.5 text-sm font-semibold text-white hover:bg-desert-green-dark transition-colors disabled:opacity-50"
78+
>
79+
{copied ? (
80+
<>
81+
<IconCheck className="size-4" />
82+
Copied!
83+
</>
84+
) : (
85+
<>
86+
<IconCopy className="size-4" />
87+
Copy to Clipboard
88+
</>
89+
)}
90+
</button>
91+
92+
<a
93+
href="https://github.com/Crosstalk-Solutions/project-nomad/issues"
94+
target="_blank"
95+
rel="noopener noreferrer"
96+
className="text-sm text-desert-green hover:underline"
97+
>
98+
Open a GitHub Issue
99+
</a>
100+
</div>
101+
</StyledModal>
102+
)
103+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,31 @@
1+
import { useState } from 'react'
12
import { usePage } from '@inertiajs/react'
23
import { UsePageProps } from '../../types/system'
34
import ThemeToggle from '~/components/ThemeToggle'
5+
import { IconBug } from '@tabler/icons-react'
6+
import DebugInfoModal from './DebugInfoModal'
47

58
export default function Footer() {
69
const { appVersion } = usePage().props as unknown as UsePageProps
10+
const [debugModalOpen, setDebugModalOpen] = useState(false)
11+
712
return (
813
<footer>
914
<div className="flex items-center justify-center gap-3 border-t border-border-subtle py-4">
1015
<p className="text-sm/6 text-text-secondary">
1116
Project N.O.M.A.D. Command Center v{appVersion}
1217
</p>
18+
<span className="text-gray-300">|</span>
19+
<button
20+
onClick={() => setDebugModalOpen(true)}
21+
className="text-sm/6 text-gray-500 hover:text-desert-green flex items-center gap-1 cursor-pointer"
22+
>
23+
<IconBug className="size-3.5" />
24+
Debug Info
25+
</button>
1326
<ThemeToggle />
1427
</div>
28+
<DebugInfoModal open={debugModalOpen} onClose={() => setDebugModalOpen(false)} />
1529
</footer>
1630
)
1731
}

admin/inertia/components/StyledSidebar.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { useMemo, useState } from 'react'
22
import { Dialog, DialogBackdrop, DialogPanel, TransitionChild } from '@headlessui/react'
33
import classNames from '~/lib/classNames'
4-
import { IconArrowLeft } from '@tabler/icons-react'
4+
import { IconArrowLeft, IconBug } from '@tabler/icons-react'
55
import { usePage } from '@inertiajs/react'
66
import { UsePageProps } from '../../types/system'
77
import { IconMenu2, IconX } from '@tabler/icons-react'
88
import ThemeToggle from '~/components/ThemeToggle'
9+
import DebugInfoModal from './DebugInfoModal'
910

1011
type SidebarItem = {
1112
name: string
@@ -22,6 +23,7 @@ interface StyledSidebarProps {
2223

2324
const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
2425
const [sidebarOpen, setSidebarOpen] = useState(false)
26+
const [debugModalOpen, setDebugModalOpen] = useState(false)
2527
const { appVersion } = usePage().props as unknown as UsePageProps
2628

2729
const currentPath = useMemo(() => {
@@ -78,6 +80,13 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
7880
</nav>
7981
<div className="mb-4 flex flex-col items-center gap-1 text-sm text-text-secondary">
8082
<p>Project N.O.M.A.D. Command Center v{appVersion}</p>
83+
<button
84+
onClick={() => setDebugModalOpen(true)}
85+
className="mt-1 text-gray-500 hover:text-desert-green inline-flex items-center gap-1 cursor-pointer"
86+
>
87+
<IconBug className="size-3.5" />
88+
Debug Info
89+
</button>
8190
<ThemeToggle />
8291
</div>
8392
</div>
@@ -125,6 +134,7 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
125134
<div className="hidden xl:fixed xl:inset-y-0 xl:z-50 xl:flex xl:w-72 xl:flex-col">
126135
<Sidebar />
127136
</div>
137+
<DebugInfoModal open={debugModalOpen} onClose={() => setDebugModalOpen(false)} />
128138
</>
129139
)
130140
}

admin/inertia/lib/api.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,13 @@ class API {
211211
})()
212212
}
213213

214+
async getDebugInfo() {
215+
return catchInternal(async () => {
216+
const response = await this.client.get<{ debugInfo: string }>('/system/debug-info')
217+
return response.data.debugInfo
218+
})()
219+
}
220+
214221
async getInternetStatus() {
215222
return catchInternal(async () => {
216223
const response = await this.client.get<boolean>('/system/internet-status')

admin/start/routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ router
136136

137137
router
138138
.group(() => {
139+
router.get('/debug-info', [SystemController, 'getDebugInfo'])
139140
router.get('/info', [SystemController, 'getSystemInfo'])
140141
router.get('/internet-status', [SystemController, 'getInternetStatus'])
141142
router.get('/services', [SystemController, 'getServices'])

0 commit comments

Comments
 (0)