project-nomad/admin/inertia/pages/settings/system.tsx
Martin Seener 134d1642af
Added initial i18n framework and most german translations
- Add i18next, react-i18next, i18next-browser-languagedetector packages
- Configure i18n initialization with language detector in lib/i18n.ts
- Created en/de translation files and moved most hard-coded strings into the files and translated them
- Uses locale-aware date formatting where applicable
- Added language-specific Wikipedia content files (wikipedia.en.json, wikipedia.de.json) and updated download URLs
- Added NOMAD_REPO_URL env variable for fork-friendly URL resolution (easier testing and rollout independent of Crosstalk repo)
2026-03-24 13:21:31 +01:00

340 lines
14 KiB
TypeScript

import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Head } from '@inertiajs/react'
import SettingsLayout from '~/layouts/SettingsLayout'
import { SystemInformationResponse } from '../../../types/system'
import { formatBytes } from '~/lib/util'
import { getAllDiskDisplayItems } from '~/hooks/useDiskDisplayData'
import CircularGauge from '~/components/systeminfo/CircularGauge'
import HorizontalBarChart from '~/components/HorizontalBarChart'
import InfoCard from '~/components/systeminfo/InfoCard'
import Alert from '~/components/Alert'
import StyledModal from '~/components/StyledModal'
import { useSystemInfo } from '~/hooks/useSystemInfo'
import { useNotifications } from '~/context/NotificationContext'
import { useModals } from '~/context/ModalContext'
import api from '~/lib/api'
import StatusCard from '~/components/systeminfo/StatusCard'
import { IconCpu, IconDatabase, IconServer, IconDeviceDesktop, IconComponents } from '@tabler/icons-react'
export default function SettingsPage(props: {
system: { info: SystemInformationResponse | undefined }
}) {
const { t } = useTranslation()
const { data: info } = useSystemInfo({
initialData: props.system.info,
})
const { addNotification } = useNotifications()
const { openModal, closeAllModals } = useModals()
const [gpuBannerDismissed, setGpuBannerDismissed] = useState(() => {
try {
return localStorage.getItem('nomad:gpu-banner-dismissed') === 'true'
} catch {
return false
}
})
const [reinstalling, setReinstalling] = useState(false)
const handleDismissGpuBanner = () => {
setGpuBannerDismissed(true)
try {
localStorage.setItem('nomad:gpu-banner-dismissed', 'true')
} catch {}
}
const handleForceReinstallOllama = () => {
openModal(
<StyledModal
title={t('system.reinstallAi')}
onConfirm={async () => {
closeAllModals()
setReinstalling(true)
try {
const response = await api.forceReinstallService('nomad_ollama')
if (!response || !response.success) {
throw new Error(response?.message || 'Force reinstall failed')
}
addNotification({
message: t('system.reinstallSuccess'),
type: 'success',
})
try { localStorage.removeItem('nomad:gpu-banner-dismissed') } catch {}
setTimeout(() => window.location.reload(), 5000)
} catch (error) {
addNotification({
message: t('system.reinstallFailed', { error: error instanceof Error ? error.message : 'Unknown error' }),
type: 'error',
})
setReinstalling(false)
}
}}
onCancel={closeAllModals}
open={true}
confirmText={t('system.reinstallConfirm')}
cancelText="Cancel"
>
<p className="text-text-primary">
{t('system.reinstallAiMessage')}
</p>
</StyledModal>,
'gpu-health-force-reinstall-modal'
)
}
// Use (total - available) to reflect actual memory pressure.
// mem.used includes reclaimable buff/cache on Linux, which inflates the number.
const memoryUsed = info?.mem.total && info?.mem.available != null
? info.mem.total - info.mem.available
: info?.mem.used || 0
const memoryUsagePercent = info?.mem.total
? ((memoryUsed / info.mem.total) * 100).toFixed(1)
: 0
const swapUsagePercent = info?.mem.swaptotal
? ((info.mem.swapused / info.mem.swaptotal) * 100).toFixed(1)
: 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
const storageItems = getAllDiskDisplayItems(info?.disk, info?.fsSize)
return (
<SettingsLayout>
<Head title={t('system.title')} />
<div className="xl:pl-72 w-full">
<main className="px-6 lg:px-12 py-6 lg:py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-desert-green mb-2">{t('system.heading')}</h1>
<p className="text-desert-stone-dark">
{t('system.subtitle', { time: new Date().toLocaleString() })}
</p>
</div>
{Number(memoryUsagePercent) > 90 && (
<div className="mb-6">
<Alert
type="error"
title={t('system.highMemory')}
message={t('system.highMemoryMessage')}
variant="bordered"
/>
</div>
)}
<section className="mb-12">
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
{t('system.resourceUsage')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
<CircularGauge
value={info?.currentLoad.currentLoad || 0}
label={t('system.cpuUsage')}
size="lg"
variant="cpu"
subtext={t('system.coresCount', { count: info?.cpu.cores || 0 })}
icon={<IconCpu className="w-8 h-8" />}
/>
</div>
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
<CircularGauge
value={Number(memoryUsagePercent)}
label={t('system.memoryUsage')}
size="lg"
variant="memory"
subtext={`${formatBytes(memoryUsed)} / ${formatBytes(info?.mem.total || 0)}`}
icon={<IconDatabase className="w-8 h-8" />}
/>
</div>
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
<CircularGauge
value={Number(swapUsagePercent)}
label={t('system.swapUsage')}
size="lg"
variant="disk"
subtext={`${formatBytes(info?.mem.swapused || 0)} / ${formatBytes(info?.mem.swaptotal || 0)}`}
icon={<IconServer className="w-8 h-8" />}
/>
</div>
</div>
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
{t('system.systemDetails')}
</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<InfoCard
title={t('system.operatingSystem')}
icon={<IconDeviceDesktop className="w-6 h-6" />}
variant="elevated"
data={[
{ label: t('system.distribution'), value: info?.os.distro },
{ label: t('system.kernelVersion'), value: info?.os.kernel },
{ label: t('system.architecture'), value: info?.os.arch },
{ label: t('system.hostname'), value: info?.os.hostname },
{ label: t('system.platform'), value: info?.os.platform },
]}
/>
<InfoCard
title={t('system.processor')}
icon={<IconCpu className="w-6 h-6" />}
variant="elevated"
data={[
{ label: t('system.manufacturer'), value: info?.cpu.manufacturer },
{ label: t('system.brand'), value: info?.cpu.brand },
{ label: t('system.cores'), value: info?.cpu.cores },
{ label: t('system.physicalCores'), value: info?.cpu.physicalCores },
{
label: t('system.virtualization'),
value: info?.cpu.virtualization ? t('system.enabled') : t('system.disabled'),
},
]}
/>
{info?.gpuHealth?.status === 'passthrough_failed' && !gpuBannerDismissed && (
<div className="lg:col-span-2">
<Alert
type="warning"
variant="bordered"
title={t('system.gpuNotAccessible')}
message={t('system.gpuNotAccessibleMessage')}
dismissible={true}
onDismiss={handleDismissGpuBanner}
buttonProps={{
children: t('system.fixReinstallAi'),
icon: 'IconRefresh',
variant: 'action',
size: 'sm',
onClick: handleForceReinstallOllama,
loading: reinstalling,
disabled: reinstalling,
}}
/>
</div>
)}
{info?.graphics?.controllers && info.graphics.controllers.length > 0 && (
<InfoCard
title={t('system.graphics')}
icon={<IconComponents className="w-6 h-6" />}
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` : t('system.na') },
]
}).flat()}
/>
)}
</div>
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
{t('system.memoryAllocation')}
</h2>
<div className="bg-desert-white rounded-lg p-8 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-8">
<div className="text-center">
<div className="text-3xl font-bold text-desert-green mb-1">
{formatBytes(info?.mem.total || 0)}
</div>
<div className="text-sm text-desert-stone-dark uppercase tracking-wide">
{t('system.totalRam')}
</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-desert-green mb-1">
{formatBytes(memoryUsed)}
</div>
<div className="text-sm text-desert-stone-dark uppercase tracking-wide">
{t('system.usedRam')}
</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-desert-green mb-1">
{formatBytes(info?.mem.available || 0)}
</div>
<div className="text-sm text-desert-stone-dark uppercase tracking-wide">
{t('system.availableRam')}
</div>
</div>
</div>
<div className="relative h-12 bg-desert-stone-lighter rounded-lg overflow-hidden border border-desert-stone-light">
<div
className="absolute left-0 top-0 h-full bg-desert-orange transition-all duration-1000"
style={{ width: `${memoryUsagePercent}%` }}
></div>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-bold text-white drop-shadow-md z-10">
{t('system.utilized', { percent: memoryUsagePercent })}
</span>
</div>
</div>
</div>
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
{t('system.storageDevices')}
</h2>
<div className="bg-desert-white rounded-lg p-8 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
{storageItems.length > 0 ? (
<HorizontalBarChart
items={storageItems}
progressiveBarColor={true}
statuses={[
{
label: t('system.normal'),
min_threshold: 0,
color_class: 'bg-desert-olive',
},
{
label: t('system.warningUsageHigh'),
min_threshold: 75,
color_class: 'bg-desert-orange',
},
{
label: t('system.criticalDiskFull'),
min_threshold: 90,
color_class: 'bg-desert-red',
},
]}
/>
) : (
<div className="text-center text-desert-stone-dark py-8">
{t('system.noStorageDevices')}
</div>
)}
</div>
</section>
<section>
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
{t('system.systemStatus')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatusCard title={t('system.systemUptime')} value={uptimeDisplay} />
<StatusCard title={t('system.cpuCores')} value={info?.cpu.cores || 0} />
<StatusCard title={t('system.storageDevices')} value={storageItems.length} />
</div>
</section>
</main>
</div>
</SettingsLayout>
)
}