diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index 1729f12..a6e0f14 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -5,6 +5,7 @@ import { ServiceSlim } from '../../types/services.js' import logger from '@adonisjs/core/services/logger' import si from 'systeminformation' import { NomadDiskInfo, NomadDiskInfoRaw, SystemInformationResponse } from '../../types/system.js' +import { SERVICE_NAMES } from '../../constants/service_names.js' import { readFileSync } from 'fs' import path, { join } from 'path' import { getAllFilesystems, getFile } from '../utils/fs.js' @@ -144,13 +145,14 @@ export class SystemService { async getSystemInfo(): Promise { try { - const [cpu, mem, os, currentLoad, fsSize, uptime] = await Promise.all([ + const [cpu, mem, os, currentLoad, fsSize, uptime, graphics] = await Promise.all([ si.cpu(), si.mem(), si.osInfo(), si.currentLoad(), si.fsSize(), si.time(), + si.graphics(), ]) let diskInfo: NomadDiskInfoRaw | undefined @@ -173,6 +175,75 @@ export class SystemService { logger.error('Error reading disk info file:', error) } + // Query Docker API for host-level info (hostname, OS, GPU runtime) + // si.osInfo() returns the container's info inside Docker, not the host's + try { + const dockerInfo = await this.dockerService.docker.info() + + if (dockerInfo.Name) { + os.hostname = dockerInfo.Name + } + if (dockerInfo.OperatingSystem) { + os.distro = dockerInfo.OperatingSystem + } + if (dockerInfo.KernelVersion) { + os.kernel = dockerInfo.KernelVersion + } + + // If si.graphics() returned no controllers (common inside Docker), + // fall back to nvidia runtime + nvidia-smi detection + if (!graphics.controllers || graphics.controllers.length === 0) { + const runtimes = dockerInfo.Runtimes || {} + if ('nvidia' in runtimes) { + let gpuName = 'NVIDIA GPU' + try { + const containers = await this.dockerService.docker.listContainers({ all: false }) + const ollamaContainer = containers.find((c) => + c.Names.includes(`/${SERVICE_NAMES.OLLAMA}`) + ) + if (ollamaContainer) { + const container = this.dockerService.docker.getContainer(ollamaContainer.Id) + const exec = await container.exec({ + Cmd: ['nvidia-smi', '--query-gpu=name,memory.total', '--format=csv,noheader,nounits'], + AttachStdout: true, + AttachStderr: true, + Tty: true, + }) + const stream = await exec.start({ Tty: true }) + const output = await new Promise((resolve) => { + let data = '' + const timeout = setTimeout(() => resolve(data), 5000) + stream.on('data', (chunk: Buffer) => { data += chunk.toString() }) + stream.on('end', () => { clearTimeout(timeout); resolve(data) }) + }) + const cleaned = output.replace(/[\x00-\x08]/g, '').trim() + if (cleaned && !cleaned.toLowerCase().includes('error')) { + const parts = cleaned.split(',').map((s) => s.trim()) + gpuName = parts[0] || gpuName + const vramMB = parts[1] ? parseInt(parts[1], 10) : 0 + graphics.controllers = [{ + vendor: 'NVIDIA', + model: gpuName, + vram: vramMB || null, + } as any] + } + } + } catch { + // nvidia-smi failed, use generic entry + } + if (graphics.controllers.length === 0) { + graphics.controllers = [{ + vendor: 'NVIDIA', + model: gpuName, + vram: null, + } as any] + } + } + } + } catch { + // Docker info query failed, skip host-level enrichment + } + return { cpu, mem, @@ -181,6 +252,7 @@ export class SystemService { currentLoad, fsSize, uptime, + graphics, } } catch (error) { logger.error('Error getting system info:', error) diff --git a/admin/inertia/pages/settings/system.tsx b/admin/inertia/pages/settings/system.tsx index 1a0b968..950f2fd 100644 --- a/admin/inertia/pages/settings/system.tsx +++ b/admin/inertia/pages/settings/system.tsx @@ -8,7 +8,7 @@ import InfoCard from '~/components/systeminfo/InfoCard' import Alert from '~/components/Alert' import { useSystemInfo } from '~/hooks/useSystemInfo' import StatusCard from '~/components/systeminfo/StatusCard' -import { IconCpu, IconDatabase, IconServer, IconDeviceDesktop } from '@tabler/icons-react' +import { IconCpu, IconDatabase, IconServer, IconDeviceDesktop, IconComponents } from '@tabler/icons-react' export default function SettingsPage(props: { system: { info: SystemInformationResponse | undefined } @@ -25,7 +25,15 @@ export default function SettingsPage(props: { ? ((info.mem.swapused / info.mem.swaptotal) * 100).toFixed(1) : 0 - const uptimeMinutes = info?.uptime.uptime ? Math.floor(info.uptime.uptime / 60) : 0 + const uptimeSeconds = info?.uptime.uptime || 0 + const uptimeDays = Math.floor(uptimeSeconds / 86400) + const uptimeHours = Math.floor((uptimeSeconds % 86400) / 3600) + const uptimeMinutes = Math.floor((uptimeSeconds % 3600) / 60) + const uptimeDisplay = uptimeDays > 0 + ? `${uptimeDays}d ${uptimeHours}h ${uptimeMinutes}m` + : uptimeHours > 0 + ? `${uptimeHours}h ${uptimeMinutes}m` + : `${uptimeMinutes}m` // Build storage display items - fall back to fsSize when disk array is empty // (Same approach as Easy Setup wizard fix from PR #90) @@ -160,6 +168,21 @@ export default function SettingsPage(props: { }, ]} /> + {info?.graphics?.controllers && info.graphics.controllers.length > 0 && ( + } + variant="elevated" + data={info.graphics.controllers.map((gpu, i) => { + const prefix = info.graphics.controllers.length > 1 ? `GPU ${i + 1} ` : '' + return [ + { label: `${prefix}Model`, value: gpu.model }, + { label: `${prefix}Vendor`, value: gpu.vendor }, + { label: `${prefix}VRAM`, value: gpu.vram ? `${gpu.vram} MB` : 'N/A' }, + ] + }).flat()} + /> + )}
@@ -249,7 +272,7 @@ export default function SettingsPage(props: { System Status
- +
diff --git a/admin/types/system.ts b/admin/types/system.ts index dd77a49..ceb7456 100644 --- a/admin/types/system.ts +++ b/admin/types/system.ts @@ -8,6 +8,7 @@ export type SystemInformationResponse = { currentLoad: Systeminformation.CurrentLoadData fsSize: Systeminformation.FsSizeData[] uptime: Systeminformation.TimeData + graphics: Systeminformation.GraphicsData } // Type inferrence is not working properly with usePage and shared props, so we define this type manually