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({