From f20a3ec391cd064943ce86c2870b9d82a9bc8189 Mon Sep 17 00:00:00 2001 From: Ritik Sahni Date: Thu, 13 Nov 2025 12:38:10 +0530 Subject: [PATCH] feat(admin): add system status panel with database, storage, and update checks Add a comprehensive System Status page to the admin panel that provides actionable insights for administrators to monitor their Umami instance health. Features: - Database connectivity check with connection status and database type - Storage usage monitoring with total, used, and free space display - Visual progress bar with color-coded warnings (75% and 90% thresholds) - Human-readable byte formatting - Fallback support for different filesystem types - Update notifications with version comparison using semver - Current version display - Latest version check via Umami API - Clear update availability indicators Technical details: - New API endpoint: /api/admin/status (admin-only access) - New admin page: /admin/status - Added to admin navigation menu with Activity icon - Auto-refreshes every 60 seconds - Proper error handling and graceful degradation - Uses existing permission system (canViewUsers check) - Follows existing UI patterns and component structure - Added i18n labels: systemStatus, database, storage, updates This addresses the need for administrators to have visibility into system health and proactively identify potential issues before they impact users. --- src/app/(main)/admin/AdminNav.tsx | 8 +- src/app/(main)/admin/status/StatusPage.tsx | 252 +++++++++++++++++++++ src/app/(main)/admin/status/page.tsx | 11 + src/app/api/admin/status/route.ts | 234 +++++++++++++++++++ src/components/messages.ts | 4 + 5 files changed, 508 insertions(+), 1 deletion(-) create mode 100644 src/app/(main)/admin/status/StatusPage.tsx create mode 100644 src/app/(main)/admin/status/page.tsx create mode 100644 src/app/api/admin/status/route.ts diff --git a/src/app/(main)/admin/AdminNav.tsx b/src/app/(main)/admin/AdminNav.tsx index 20c01155..a229640e 100644 --- a/src/app/(main)/admin/AdminNav.tsx +++ b/src/app/(main)/admin/AdminNav.tsx @@ -1,6 +1,6 @@ import { SideMenu } from '@/components/common/SideMenu'; import { useMessages, useNavigation } from '@/components/hooks'; -import { Globe, User, Users } from '@/components/icons'; +import { Globe, User, Users, Activity } from '@/components/icons'; export function AdminNav({ onItemClick }: { onItemClick?: () => void }) { const { formatMessage, labels } = useMessages(); @@ -28,6 +28,12 @@ export function AdminNav({ onItemClick }: { onItemClick?: () => void }) { path: '/admin/teams', icon: , }, + { + id: 'status', + label: formatMessage(labels.systemStatus), + path: '/admin/status', + icon: , + }, ], }, ]; diff --git a/src/app/(main)/admin/status/StatusPage.tsx b/src/app/(main)/admin/status/StatusPage.tsx new file mode 100644 index 00000000..e053fb52 --- /dev/null +++ b/src/app/(main)/admin/status/StatusPage.tsx @@ -0,0 +1,252 @@ +'use client'; +import { Column, Grid, Text, StatusLight, Heading, Row, ProgressBar } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; +import { PageHeader } from '@/components/common/PageHeader'; +import { Panel } from '@/components/common/Panel'; +import { useApi } from '@/components/hooks/useApi'; +import { Database, HardDrive, Download, AlertCircle } from '@/components/icons'; + +interface DatabaseStatus { + connected: boolean; + type: string; + error?: string; +} + +interface StorageStatus { + available: boolean; + total: number; + free: number; + used: number; + percentage: number; + path: string; + error?: string; +} + +interface UpdateStatus { + current: string; + latest?: string; + updateAvailable: boolean; + error?: string; +} + +interface SystemStatus { + database: DatabaseStatus; + storage: StorageStatus; + updates: UpdateStatus; +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; +} + +function StatusCard({ + title, + icon, + status, + children, +}: { + title: string; + icon: React.ReactNode; + status: 'success' | 'error' | 'warning'; + children: React.ReactNode; +}) { + return ( + + + + {icon} + {title} + + + + {status === 'success' ? 'Operational' : status === 'error' ? 'Error' : 'Warning'} + + + {children} + + + ); +} + +function DatabaseStatusCard({ database }: { database: DatabaseStatus }) { + const { formatMessage, labels } = useMessages(); + + return ( + } + status={database.connected ? 'success' : 'error'} + > + + + Type: + {database.type} + + + Status: + + {database.connected ? 'Connected' : 'Disconnected'} + + + {database.error && ( + + {database.error} + + )} + + + ); +} + +function StorageStatusCard({ storage }: { storage: StorageStatus }) { + const { formatMessage, labels } = useMessages(); + + if (!storage.available) { + return ( + } + status="warning" + > + + + {storage.error || 'Unable to determine storage usage'} + + + Path: {storage.path} + + + + ); + } + + const status = storage.percentage >= 90 ? 'error' : storage.percentage >= 75 ? 'warning' : 'success'; + + return ( + } + status={status} + > + + + + Total: + {formatBytes(storage.total)} + + + Used: + {formatBytes(storage.used)} + + + Free: + {formatBytes(storage.free)} + + + Usage: + {storage.percentage}% + + + + + Path: {storage.path} + + + + ); +} + +function UpdateStatusCard({ updates }: { updates: UpdateStatus }) { + const { formatMessage, labels } = useMessages(); + + // Only show error status if there's an actual error message + // If latest is just not available, show success (current version is fine) + const status = updates.updateAvailable ? 'warning' : updates.error ? 'error' : 'success'; + + return ( + } + status={status} + > + + + Current Version: + {updates.current} + + {updates.latest ? ( + <> + + Latest Version: + {updates.latest} + + {updates.updateAvailable && ( + + + + Update available + + + )} + {!updates.updateAvailable && ( + + You are running the latest version + + )} + + ) : ( + + {updates.error || 'Unable to check for updates'} + + )} + + + ); +} + +export function StatusPage() { + const { formatMessage, labels } = useMessages(); + const { get, useQuery: useApiQuery } = useApi(); + + const { data, isLoading, error } = useApiQuery({ + queryKey: ['admin', 'status'], + queryFn: () => get('/admin/status'), + refetchInterval: 60000, // Refetch every minute + }); + + if (isLoading) { + return ( + + + Loading... + + ); + } + + if (error || !data) { + return ( + + + Failed to load system status + + ); + } + + return ( + + + + + + + + + ); +} + diff --git a/src/app/(main)/admin/status/page.tsx b/src/app/(main)/admin/status/page.tsx new file mode 100644 index 00000000..15733666 --- /dev/null +++ b/src/app/(main)/admin/status/page.tsx @@ -0,0 +1,11 @@ +import { Metadata } from 'next'; +import { StatusPage } from './StatusPage'; + +export default async function () { + return ; +} + +export const metadata: Metadata = { + title: 'System Status', +}; + diff --git a/src/app/api/admin/status/route.ts b/src/app/api/admin/status/route.ts new file mode 100644 index 00000000..6348d9dc --- /dev/null +++ b/src/app/api/admin/status/route.ts @@ -0,0 +1,234 @@ +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { canViewUsers } from '@/permissions'; +import prisma from '@/lib/prisma'; +import { CURRENT_VERSION, UPDATES_URL } from '@/lib/constants'; +import { execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { statfs } from 'node:fs/promises'; +import semver from 'semver'; + +interface DatabaseStatus { + connected: boolean; + type: string; + error?: string; +} + +interface StorageStatus { + available: boolean; + total: number; + free: number; + used: number; + percentage: number; + path: string; + error?: string; +} + +interface UpdateStatus { + current: string; + latest?: string; + updateAvailable: boolean; + error?: string; +} + +interface SystemStatus { + database: DatabaseStatus; + storage: StorageStatus; + updates: UpdateStatus; +} + +async function checkDatabase(): Promise { + try { + const dbType = process.env.DATABASE_TYPE || 'postgresql'; + + // Try to connect and run a simple query + await prisma.client.$queryRaw`SELECT 1`; + + return { + connected: true, + type: dbType, + }; + } catch (error: any) { + return { + connected: false, + type: process.env.DATABASE_TYPE || 'unknown', + error: error.message || 'Database connection failed', + }; + } +} + +async function checkStorage(): Promise { + try { + // Try to check storage for common paths + const pathsToCheck = [ + process.env.DATABASE_URL ? new URL(process.env.DATABASE_URL).pathname : null, + process.cwd(), + '/', + ].filter(Boolean) as string[]; + + let path = pathsToCheck[0]; + + // For PostgreSQL, try to get the data directory + if (process.env.DATABASE_URL) { + try { + const dbUrl = new URL(process.env.DATABASE_URL); + if (dbUrl.hostname === 'localhost' || dbUrl.hostname === '127.0.0.1') { + // Try to get PostgreSQL data directory + try { + const pgDataDir = execSync('pg_config --sharedir', { encoding: 'utf-8', stdio: 'pipe' }).trim(); + path = join(pgDataDir, '../data'); + } catch { + // Fallback to current directory + path = process.cwd(); + } + } + } catch { + path = process.cwd(); + } + } + + // Check if path exists, if not use current directory + if (!existsSync(path)) { + path = process.cwd(); + } + + let total = 0; + let free = 0; + let used = 0; + let percentage = 0; + + try { + // Try to use statfs (Node.js 18.9.0+) + const stats = await statfs(path); + const blockSize = stats.bsize || 1; + total = stats.blocks * blockSize; + free = stats.bavail * blockSize; + used = total - free; + percentage = total > 0 ? Math.round((used / total) * 100) : 0; + } catch { + // Fallback: Try to use df command on Unix systems + try { + const dfOutput = execSync(`df -k "${path}"`, { encoding: 'utf-8', stdio: 'pipe' }); + const lines = dfOutput.trim().split('\n'); + if (lines.length > 1) { + const parts = lines[1].split(/\s+/); + if (parts.length >= 4) { + total = parseInt(parts[1], 10) * 1024; // Convert from KB to bytes + used = parseInt(parts[2], 10) * 1024; + free = parseInt(parts[3], 10) * 1024; + percentage = total > 0 ? Math.round((used / total) * 100) : 0; + } + } + } catch { + // If both methods fail, we can't determine storage + throw new Error('Unable to determine storage usage'); + } + } + + return { + available: true, + total, + free, + used, + percentage, + path, + }; + } catch (error: any) { + return { + available: false, + total: 0, + free: 0, + used: 0, + percentage: 0, + path: process.cwd(), + error: error.message || 'Storage check failed', + }; + } +} + +async function checkUpdates(): Promise { + const current = CURRENT_VERSION || 'unknown'; + + try { + if (!UPDATES_URL) { + return { + current, + updateAvailable: false, + error: 'Updates URL not configured', + }; + } + + // Use the same API format as the existing version check + const response = await fetch(`${UPDATES_URL}?v=${current}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'User-Agent': 'Umami', + }, + }); + + if (!response.ok) { + // If API returns non-ok, don't treat it as an error - just show current version + return { + current, + updateAvailable: false, + }; + } + + const data = await response.json(); + + // The API returns { latest, url } structure + const latest = data?.latest || null; + + if (!latest) { + // If no latest version in response, just show current version without error + return { + current, + updateAvailable: false, + }; + } + + // Use semver for proper version comparison + const updateAvailable = semver.gt(latest, current); + + return { + current, + latest, + updateAvailable, + }; + } catch (error: any) { + // Network errors or other issues - show current version without alarming error + return { + current, + updateAvailable: false, + }; + } +} + +export async function GET(request: Request) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + if (!(await canViewUsers(auth))) { + return unauthorized(); + } + + const [database, storage, updates] = await Promise.all([ + checkDatabase(), + checkStorage(), + checkUpdates(), + ]); + + const status: SystemStatus = { + database, + storage, + updates, + }; + + return json(status); +} + diff --git a/src/components/messages.ts b/src/components/messages.ts index 0438c06e..92d34d55 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -363,6 +363,10 @@ export const labels = defineMessages({ support: { id: 'label.support', defaultMessage: 'Support' }, documentation: { id: 'label.documentation', defaultMessage: 'Documentation' }, switchAccount: { id: 'label.switch-account', defaultMessage: 'Switch account' }, + systemStatus: { id: 'label.system-status', defaultMessage: 'System Status' }, + database: { id: 'label.database', defaultMessage: 'Database' }, + storage: { id: 'label.storage', defaultMessage: 'Storage' }, + updates: { id: 'label.updates', defaultMessage: 'Updates' }, }); export const messages = defineMessages({