mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 04:37:11 +01:00
Merge 0c424649a3 into 860e6390f1
This commit is contained in:
commit
66ce7a46f8
5 changed files with 508 additions and 1 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
import { SideMenu } from '@/components/common/SideMenu';
|
import { SideMenu } from '@/components/common/SideMenu';
|
||||||
import { useMessages, useNavigation } from '@/components/hooks';
|
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 }) {
|
export function AdminNav({ onItemClick }: { onItemClick?: () => void }) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
@ -28,6 +28,12 @@ export function AdminNav({ onItemClick }: { onItemClick?: () => void }) {
|
||||||
path: '/admin/teams',
|
path: '/admin/teams',
|
||||||
icon: <Users />,
|
icon: <Users />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
label: formatMessage(labels.systemStatus),
|
||||||
|
path: '/admin/status',
|
||||||
|
icon: <Activity />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
252
src/app/(main)/admin/status/StatusPage.tsx
Normal file
252
src/app/(main)/admin/status/StatusPage.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Panel>
|
||||||
|
<Column gap="4">
|
||||||
|
<Row alignItems="center" gap="3">
|
||||||
|
{icon}
|
||||||
|
<Heading size="3">{title}</Heading>
|
||||||
|
</Row>
|
||||||
|
<StatusLight variant={status === 'success' ? 'success' : status === 'error' ? 'error' : 'warning'}>
|
||||||
|
<Text size="2" weight="medium">
|
||||||
|
{status === 'success' ? 'Operational' : status === 'error' ? 'Error' : 'Warning'}
|
||||||
|
</Text>
|
||||||
|
</StatusLight>
|
||||||
|
{children}
|
||||||
|
</Column>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DatabaseStatusCard({ database }: { database: DatabaseStatus }) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatusCard
|
||||||
|
title={formatMessage(labels.database) || 'Database'}
|
||||||
|
icon={<Database size={24} />}
|
||||||
|
status={database.connected ? 'success' : 'error'}
|
||||||
|
>
|
||||||
|
<Column gap="2">
|
||||||
|
<Row justifyContent="space-between">
|
||||||
|
<Text color="muted">Type:</Text>
|
||||||
|
<Text weight="medium">{database.type}</Text>
|
||||||
|
</Row>
|
||||||
|
<Row justifyContent="space-between">
|
||||||
|
<Text color="muted">Status:</Text>
|
||||||
|
<Text weight="medium" color={database.connected ? 'success' : 'error'}>
|
||||||
|
{database.connected ? 'Connected' : 'Disconnected'}
|
||||||
|
</Text>
|
||||||
|
</Row>
|
||||||
|
{database.error && (
|
||||||
|
<Text size="2" color="error">
|
||||||
|
{database.error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Column>
|
||||||
|
</StatusCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StorageStatusCard({ storage }: { storage: StorageStatus }) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
|
if (!storage.available) {
|
||||||
|
return (
|
||||||
|
<StatusCard
|
||||||
|
title={formatMessage(labels.storage) || 'Storage'}
|
||||||
|
icon={<HardDrive size={24} />}
|
||||||
|
status="warning"
|
||||||
|
>
|
||||||
|
<Column gap="2">
|
||||||
|
<Text size="2" color="error">
|
||||||
|
{storage.error || 'Unable to determine storage usage'}
|
||||||
|
</Text>
|
||||||
|
<Text size="2" color="muted">
|
||||||
|
Path: {storage.path}
|
||||||
|
</Text>
|
||||||
|
</Column>
|
||||||
|
</StatusCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = storage.percentage >= 90 ? 'error' : storage.percentage >= 75 ? 'warning' : 'success';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatusCard
|
||||||
|
title={formatMessage(labels.storage) || 'Storage'}
|
||||||
|
icon={<HardDrive size={24} />}
|
||||||
|
status={status}
|
||||||
|
>
|
||||||
|
<Column gap="3">
|
||||||
|
<Column gap="2">
|
||||||
|
<Row justifyContent="space-between">
|
||||||
|
<Text color="muted">Total:</Text>
|
||||||
|
<Text weight="medium">{formatBytes(storage.total)}</Text>
|
||||||
|
</Row>
|
||||||
|
<Row justifyContent="space-between">
|
||||||
|
<Text color="muted">Used:</Text>
|
||||||
|
<Text weight="medium">{formatBytes(storage.used)}</Text>
|
||||||
|
</Row>
|
||||||
|
<Row justifyContent="space-between">
|
||||||
|
<Text color="muted">Free:</Text>
|
||||||
|
<Text weight="medium">{formatBytes(storage.free)}</Text>
|
||||||
|
</Row>
|
||||||
|
<Row justifyContent="space-between">
|
||||||
|
<Text color="muted">Usage:</Text>
|
||||||
|
<Text weight="medium">{storage.percentage}%</Text>
|
||||||
|
</Row>
|
||||||
|
</Column>
|
||||||
|
<ProgressBar value={storage.percentage} max={100} />
|
||||||
|
<Text size="2" color="muted">
|
||||||
|
Path: {storage.path}
|
||||||
|
</Text>
|
||||||
|
</Column>
|
||||||
|
</StatusCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<StatusCard
|
||||||
|
title={formatMessage(labels.updates) || 'Updates'}
|
||||||
|
icon={<Download size={24} />}
|
||||||
|
status={status}
|
||||||
|
>
|
||||||
|
<Column gap="2">
|
||||||
|
<Row justifyContent="space-between">
|
||||||
|
<Text color="muted">Current Version:</Text>
|
||||||
|
<Text weight="medium">{updates.current}</Text>
|
||||||
|
</Row>
|
||||||
|
{updates.latest ? (
|
||||||
|
<>
|
||||||
|
<Row justifyContent="space-between">
|
||||||
|
<Text color="muted">Latest Version:</Text>
|
||||||
|
<Text weight="medium">{updates.latest}</Text>
|
||||||
|
</Row>
|
||||||
|
{updates.updateAvailable && (
|
||||||
|
<Row alignItems="center" gap="2" marginTop="2">
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
<Text size="2" color="warning">
|
||||||
|
Update available
|
||||||
|
</Text>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{!updates.updateAvailable && (
|
||||||
|
<Text size="2" color="muted" marginTop="2">
|
||||||
|
You are running the latest version
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text size="2" color="muted" marginTop="2">
|
||||||
|
{updates.error || 'Unable to check for updates'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Column>
|
||||||
|
</StatusCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusPage() {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const { get, useQuery: useApiQuery } = useApi();
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useApiQuery<SystemStatus>({
|
||||||
|
queryKey: ['admin', 'status'],
|
||||||
|
queryFn: () => get('/admin/status'),
|
||||||
|
refetchInterval: 60000, // Refetch every minute
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Column gap="6" margin="2">
|
||||||
|
<PageHeader title={formatMessage(labels.systemStatus) || 'System Status'} />
|
||||||
|
<Text>Loading...</Text>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<Column gap="6" margin="2">
|
||||||
|
<PageHeader title={formatMessage(labels.systemStatus) || 'System Status'} />
|
||||||
|
<Text color="error">Failed to load system status</Text>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column gap="6" margin="2">
|
||||||
|
<PageHeader title={formatMessage(labels.systemStatus) || 'System Status'} />
|
||||||
|
<Grid columns={{ xs: '1fr', md: '1fr 1fr', lg: '1fr 1fr 1fr' }} gap="3">
|
||||||
|
<DatabaseStatusCard database={data.database} />
|
||||||
|
<StorageStatusCard storage={data.storage} />
|
||||||
|
<UpdateStatusCard updates={data.updates} />
|
||||||
|
</Grid>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
11
src/app/(main)/admin/status/page.tsx
Normal file
11
src/app/(main)/admin/status/page.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import { StatusPage } from './StatusPage';
|
||||||
|
|
||||||
|
export default async function () {
|
||||||
|
return <StatusPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'System Status',
|
||||||
|
};
|
||||||
|
|
||||||
234
src/app/api/admin/status/route.ts
Normal file
234
src/app/api/admin/status/route.ts
Normal file
|
|
@ -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 { execFileSync } 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<DatabaseStatus> {
|
||||||
|
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<StorageStatus> {
|
||||||
|
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 = execFileSync('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 = execFileSync('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<UpdateStatus> {
|
||||||
|
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.valid(latest) && semver.valid(current) ? semver.gt(latest, current) : false;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -363,6 +363,10 @@ export const labels = defineMessages({
|
||||||
support: { id: 'label.support', defaultMessage: 'Support' },
|
support: { id: 'label.support', defaultMessage: 'Support' },
|
||||||
documentation: { id: 'label.documentation', defaultMessage: 'Documentation' },
|
documentation: { id: 'label.documentation', defaultMessage: 'Documentation' },
|
||||||
switchAccount: { id: 'label.switch-account', defaultMessage: 'Switch account' },
|
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({
|
export const messages = defineMessages({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue