feat(system): adicionar notificações de espaço em disco

Adiciona monitoramento de disco com alertas globais quando o uso
ultrapassa thresholds configuráveis (85% warning, 95% critical).

- Novo endpoint GET /api/system/disk-status
- getDiskStatus() no SystemService com thresholds via KVStore
- Hook useDiskAlert com polling de 45s e dismiss via localStorage
- DiskAlertBanner no AppLayout visível em todas as páginas
- Banner reaparece se o nível de alerta piorar

Closes #485
This commit is contained in:
LuisMIguelFurlanettoSousa 2026-03-23 11:15:53 -03:00
parent f004c002a7
commit 67fc9290d8
9 changed files with 161 additions and 3 deletions

View File

@ -113,6 +113,10 @@ export default class SystemController {
return await this.systemService.subscribeToReleaseNotes(reqData.email);
}
async getDiskStatus({}: HttpContext) {
return await this.systemService.getDiskStatus();
}
async getDebugInfo({}: HttpContext) {
const debugInfo = await this.systemService.getDebugInfo()
return { debugInfo }

View File

@ -521,6 +521,59 @@ export class SystemService {
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i]
}
async getDiskStatus(): Promise<{
level: 'none' | 'warning' | 'critical'
threshold: number
highestUsage: number
diskName: string
}> {
try {
const diskInfoRawString = await getFile(
path.join(process.cwd(), SystemService.diskInfoFile),
'string'
)
const diskInfo = (
diskInfoRawString
? JSON.parse(diskInfoRawString.toString())
: { diskLayout: { blockdevices: [] }, fsSize: [] }
) as NomadDiskInfoRaw
const disks = this.calculateDiskUsage(diskInfo)
const warningStr = await KVStore.getValue('disk.warningThreshold')
const criticalStr = await KVStore.getValue('disk.criticalThreshold')
const warningThreshold = warningStr ? Number(warningStr) : 85
const criticalThreshold = criticalStr ? Number(criticalStr) : 95
let highestUsage = 0
let diskName = ''
for (const disk of disks) {
if (disk.percentUsed > highestUsage) {
highestUsage = disk.percentUsed
diskName = disk.name
}
}
let level: 'none' | 'warning' | 'critical' = 'none'
let threshold = 0
if (highestUsage >= criticalThreshold) {
level = 'critical'
threshold = criticalThreshold
} else if (highestUsage >= warningThreshold) {
level = 'warning'
threshold = warningThreshold
}
return { level, threshold, highestUsage, diskName }
} catch (error) {
logger.error('Error getting disk status:', error)
return { level: 'none', threshold: 0, highestUsage: 0, diskName: '' }
}
}
async updateSetting(key: KVStoreKey, value: any): Promise<void> {
if ((value === '' || value === undefined || value === null) && KV_STORE_SCHEMA[key] === 'string') {
await KVStore.clearValue(key)

View File

@ -1,3 +1,3 @@
import { KVStoreKey } from "../types/kv_store.js";
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'ai.assistantCustomName'];
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'ai.assistantCustomName', 'disk.warningThreshold', 'disk.criticalThreshold', 'ui.language'];

View File

@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next'
import Alert from './Alert'
import { useDiskAlert } from '~/hooks/useDiskAlert'
export default function DiskAlertBanner() {
const { t } = useTranslation('common')
const { diskStatus, shouldShow, dismiss } = useDiskAlert()
if (!shouldShow || !diskStatus) return null
const isWarning = diskStatus.level === 'warning'
return (
<div className="px-4 pt-4">
<Alert
type={isWarning ? 'warning' : 'error'}
variant="bordered"
title={isWarning ? t('alerts.diskWarningTitle') : t('alerts.diskCriticalTitle')}
message={t('alerts.diskMessage', {
diskName: diskStatus.diskName,
usage: diskStatus.highestUsage.toFixed(1),
})}
dismissible={true}
onDismiss={dismiss}
/>
</div>
)
}

View File

@ -0,0 +1,53 @@
import { useQuery } from '@tanstack/react-query'
import { useState, useMemo } from 'react'
import api from '~/lib/api'
type DiskAlertLevel = 'none' | 'warning' | 'critical'
interface DiskStatusResponse {
level: DiskAlertLevel
threshold: number
highestUsage: number
diskName: string
}
const DISMISSED_KEY = 'nomad:disk-alert-dismissed-level'
export function useDiskAlert() {
const [dismissedLevel, setDismissedLevel] = useState<DiskAlertLevel | null>(() => {
try {
return localStorage.getItem(DISMISSED_KEY) as DiskAlertLevel | null
} catch {
return null
}
})
const { data: diskStatus } = useQuery<DiskStatusResponse | undefined>({
queryKey: ['disk-status'],
queryFn: async () => await api.getDiskStatus(),
refetchInterval: 45000,
})
const shouldShow = useMemo(() => {
if (!diskStatus || diskStatus.level === 'none') return false
if (!dismissedLevel) return true
// Exibe novamente se o nível piorou
if (dismissedLevel === 'warning' && diskStatus.level === 'critical') return true
return false
}, [diskStatus, dismissedLevel])
const dismiss = () => {
if (diskStatus) {
setDismissedLevel(diskStatus.level)
try {
localStorage.setItem(DISMISSED_KEY, diskStatus.level)
} catch {}
}
}
return {
diskStatus,
shouldShow,
dismiss,
}
}

View File

@ -1,4 +1,5 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Footer from '~/components/Footer'
import ChatButton from '~/components/chat/ChatButton'
import ChatModal from '~/components/chat/ChatModal'
@ -7,8 +8,10 @@ import { SERVICE_NAMES } from '../../constants/service_names'
import { Link } from '@inertiajs/react'
import { IconArrowLeft } from '@tabler/icons-react'
import classNames from 'classnames'
import DiskAlertBanner from '~/components/DiskAlertBanner'
export default function AppLayout({ children }: { children: React.ReactNode }) {
const { t } = useTranslation('layout')
const [isChatOpen, setIsChatOpen] = useState(false)
const aiAssistantInstalled = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)
@ -18,7 +21,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
window.location.pathname !== '/home' && (
<Link href="/home" className="absolute top-60 md:top-48 left-4 flex items-center">
<IconArrowLeft className="mr-2" size={24} />
<p className="text-lg text-text-secondary">Back to Home</p>
<p className="text-lg text-text-secondary">{t('nav.backToHome')}</p>
</Link>
)}
<div
@ -26,13 +29,14 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
onClick={() => (window.location.href = '/home')}
>
<img src="/project_nomad_logo.png" alt="Project Nomad Logo" className="h-40 w-40" />
<h1 className="text-5xl font-bold text-desert-green">Command Center</h1>
<h1 className="text-5xl font-bold text-desert-green">{t('header.commandCenter')}</h1>
</div>
<hr className={
classNames(
"text-desert-green font-semibold h-[1.5px] bg-desert-green border-none",
window.location.pathname !== '/home' ? "mt-12 md:mt-0" : "mt-0"
)} />
<DiskAlertBanner />
<div className="flex-1 w-full bg-desert">{children}</div>
<Footer />

View File

@ -211,6 +211,18 @@ class API {
})()
}
async getDiskStatus() {
return catchInternal(async () => {
const response = await this.client.get<{
level: 'none' | 'warning' | 'critical'
threshold: number
highestUsage: number
diskName: string
}>('/system/disk-status')
return response.data
})()
}
async getDebugInfo() {
return catchInternal(async () => {
const response = await this.client.get<{ debugInfo: string }>('/system/debug-info')

View File

@ -137,6 +137,7 @@ router
router
.group(() => {
router.get('/disk-status', [SystemController, 'getDiskStatus'])
router.get('/debug-info', [SystemController, 'getDebugInfo'])
router.get('/info', [SystemController, 'getSystemInfo'])
router.get('/internet-status', [SystemController, 'getInternetStatus'])

View File

@ -10,6 +10,9 @@ export const KV_STORE_SCHEMA = {
'ui.theme': 'string',
'ai.assistantCustomName': 'string',
'gpu.type': 'string',
'disk.warningThreshold': 'string',
'disk.criticalThreshold': 'string',
'ui.language': 'string',
} as const
type KVTagToType<T extends string> = T extends 'boolean' ? boolean : string