mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 03:29:25 +01:00
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:
parent
f004c002a7
commit
67fc9290d8
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
28
admin/inertia/components/DiskAlertBanner.tsx
Normal file
28
admin/inertia/components/DiskAlertBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
admin/inertia/hooks/useDiskAlert.ts
Normal file
53
admin/inertia/hooks/useDiskAlert.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user