mirror of
https://github.com/perfect-panel/ppanel-web.git
synced 2026-02-14 20:31:10 -05:00
🐛 fix: Added system version card and system log dialog components; updated statistics page to include total server and user statistics
This commit is contained in:
parent
3e5402f2fc
commit
fe699809a4
@ -1,351 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { getSystemLog, getVersion, restartSystem } from '@/services/admin/tool';
|
|
||||||
import { formatDate } from '@/utils/common';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from '@workspace/ui/components/accordion';
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from '@workspace/ui/components/alert-dialog';
|
|
||||||
import { Badge } from '@workspace/ui/components/badge';
|
|
||||||
import { Button } from '@workspace/ui/components/button';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@workspace/ui/components/card';
|
|
||||||
import { ScrollArea } from '@workspace/ui/components/scroll-area';
|
|
||||||
import { Icon } from '@workspace/ui/custom-components/icon';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import packageJson from '../../../../../package.json';
|
|
||||||
|
|
||||||
const getLogLevelColor = (level: string) => {
|
|
||||||
const colorMap: { [key: string]: string } = {
|
|
||||||
INFO: 'bg-blue-100 text-blue-800 hover:bg-blue-200',
|
|
||||||
WARN: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200',
|
|
||||||
ERROR: 'bg-red-100 text-red-800 hover:bg-red-200',
|
|
||||||
};
|
|
||||||
return colorMap[level] || 'bg-gray-100 text-gray-800 hover:bg-gray-200';
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const t = useTranslations('tool');
|
|
||||||
const {
|
|
||||||
data: logs,
|
|
||||||
refetch,
|
|
||||||
isLoading,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ['getSystemLog'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data } = await getSystemLog();
|
|
||||||
return data.data?.list || [];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [openRestart, setOpenRestart] = useState(false);
|
|
||||||
const [isRestarting, setIsRestarting] = useState(false);
|
|
||||||
|
|
||||||
const { data: latestReleases } = useQuery({
|
|
||||||
queryKey: ['getLatestReleases'],
|
|
||||||
queryFn: async () => {
|
|
||||||
try {
|
|
||||||
const [webResponse, serverResponse] = await Promise.all([
|
|
||||||
fetch('https://api.github.com/repos/perfect-panel/ppanel-web/releases/latest'),
|
|
||||||
fetch('https://api.github.com/repos/perfect-panel/server/releases/latest'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const webData = webResponse.ok ? await webResponse.json() : null;
|
|
||||||
const serverData = serverResponse.ok ? await serverResponse.json() : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
web: webData
|
|
||||||
? {
|
|
||||||
version: webData.tag_name,
|
|
||||||
url: webData.html_url,
|
|
||||||
publishedAt: webData.published_at,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
server: serverData
|
|
||||||
? {
|
|
||||||
version: serverData.tag_name,
|
|
||||||
url: serverData.html_url,
|
|
||||||
publishedAt: serverData.published_at,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch latest releases:', error);
|
|
||||||
return { web: null, server: null };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
staleTime: 60 * 60 * 1000,
|
|
||||||
retry: 1,
|
|
||||||
retryDelay: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasNewVersion =
|
|
||||||
latestReleases?.web && packageJson.version !== latestReleases.web.version.replace(/^v/, '');
|
|
||||||
|
|
||||||
const { data: systemInfo } = useQuery({
|
|
||||||
queryKey: ['getVersion'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data } = await getVersion();
|
|
||||||
|
|
||||||
const versionString = data.data?.version || '';
|
|
||||||
const releaseVersionRegex = /^[Vv]?\d+\.\d+\.\d+(-[a-zA-Z]+(\.\d+)?)?$/;
|
|
||||||
const timeMatch = versionString.match(/\(([^)]+)\)/);
|
|
||||||
const timeInBrackets = timeMatch ? timeMatch[1] : '';
|
|
||||||
|
|
||||||
const versionWithoutTime = versionString.replace(/\([^)]*\)/, '').trim();
|
|
||||||
|
|
||||||
const isDevelopment =
|
|
||||||
versionWithoutTime.includes('-dev') ||
|
|
||||||
versionWithoutTime.includes('-debug') ||
|
|
||||||
versionWithoutTime.includes('-nightly') ||
|
|
||||||
versionWithoutTime.includes('dev') ||
|
|
||||||
!releaseVersionRegex.test(versionWithoutTime);
|
|
||||||
|
|
||||||
let baseVersion = versionWithoutTime;
|
|
||||||
let lastUpdated = '';
|
|
||||||
|
|
||||||
if (isDevelopment && versionWithoutTime.includes('-')) {
|
|
||||||
const parts = versionWithoutTime.split('-');
|
|
||||||
baseVersion = parts[0] || versionWithoutTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastUpdated = formatDate(new Date(timeInBrackets || Date.now())) || '';
|
|
||||||
|
|
||||||
const displayVersion =
|
|
||||||
baseVersion.startsWith('V') || baseVersion.startsWith('v')
|
|
||||||
? baseVersion
|
|
||||||
: `V${baseVersion}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
isRelease: !isDevelopment,
|
|
||||||
version: displayVersion,
|
|
||||||
lastUpdated,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasServerNewVersion =
|
|
||||||
latestReleases?.server &&
|
|
||||||
systemInfo &&
|
|
||||||
systemInfo.version.replace(/^V/, '') !== latestReleases.server.version.replace(/^v/, '');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className='border-none'>
|
|
||||||
<CardHeader className='flex flex-col items-start justify-between sm:flex-row sm:items-center'>
|
|
||||||
<div>
|
|
||||||
<CardTitle>{t('systemServices')}</CardTitle>
|
|
||||||
<CardDescription>{t('viewLogsAndManage')}</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className='mt-4 flex flex-col space-y-2 sm:mt-0 sm:flex-row sm:space-x-2 sm:space-y-0'>
|
|
||||||
{/* <AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button>{t('systemUpgrade')}</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>{t('confirmSystemUpgrade')}</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>{t('upgradeDescription')}</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
|
|
||||||
<AlertDialogAction>{t('confirmUpgrade')}</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog> */}
|
|
||||||
<AlertDialog open={openRestart} onOpenChange={setOpenRestart}>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button variant='destructive'>{t('systemReboot')}</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>{t('confirmSystemReboot')}</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>{t('rebootDescription')}</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
|
|
||||||
<Button
|
|
||||||
onClick={async () => {
|
|
||||||
setIsRestarting(true);
|
|
||||||
await restartSystem();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
||||||
setIsRestarting(false);
|
|
||||||
setOpenRestart(false);
|
|
||||||
}}
|
|
||||||
disabled={isRestarting}
|
|
||||||
>
|
|
||||||
{isRestarting && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}{' '}
|
|
||||||
{isRestarting ? t('rebooting') : t('confirmReboot')}
|
|
||||||
</Button>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className='space-y-4'>
|
|
||||||
<div className='flex flex-col space-y-2 sm:flex-row sm:space-x-3 sm:space-y-0'>
|
|
||||||
<div className='bg-muted/30 flex flex-1 items-center justify-between rounded-md p-2'>
|
|
||||||
<div className='flex items-center'>
|
|
||||||
<Icon icon='mdi:web' className='mr-2 h-4 w-4 text-green-600' />
|
|
||||||
<div className='flex items-center space-x-2'>
|
|
||||||
<span className='text-xs font-medium'>{t('webVersion')}</span>
|
|
||||||
<Badge variant='default' className='px-1.5 py-0.5 text-xs'>
|
|
||||||
V{packageJson.version}
|
|
||||||
</Badge>
|
|
||||||
{hasNewVersion && (
|
|
||||||
<Badge variant='destructive' className='animate-pulse px-1.5 py-0.5 text-xs'>
|
|
||||||
{t('newVersionAvailable')}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{hasNewVersion && (
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
size='sm'
|
|
||||||
className='h-6 px-2 text-xs'
|
|
||||||
onClick={() =>
|
|
||||||
window.open(
|
|
||||||
latestReleases?.web?.url ||
|
|
||||||
'https://github.com/perfect-panel/ppanel-web/releases',
|
|
||||||
'_blank',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon icon='mdi:open-in-new' className='mr-1 h-3 w-3' />
|
|
||||||
{t('viewNewVersion')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='bg-muted/30 flex flex-1 items-center justify-between rounded-md p-2'>
|
|
||||||
<div className='flex items-center'>
|
|
||||||
<Icon icon='mdi:server' className='mr-2 h-4 w-4 text-blue-600' />
|
|
||||||
<div className='flex items-center space-x-2'>
|
|
||||||
<span className='text-xs font-medium'>{t('serverVersion')}</span>
|
|
||||||
<Badge
|
|
||||||
variant={!systemInfo?.isRelease ? 'destructive' : 'default'}
|
|
||||||
className='px-1.5 py-0.5 text-xs'
|
|
||||||
>
|
|
||||||
{systemInfo?.version || 'V1.0.0'}
|
|
||||||
{!systemInfo?.isRelease && (
|
|
||||||
<span className='ml-1'>{t('developmentVersion')}</span>
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
{hasServerNewVersion && (
|
|
||||||
<Badge variant='destructive' className='animate-pulse px-1.5 py-0.5 text-xs'>
|
|
||||||
{t('newVersionAvailable')}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex items-center space-x-2'>
|
|
||||||
{hasServerNewVersion && (
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
size='sm'
|
|
||||||
className='h-6 px-2 text-xs'
|
|
||||||
onClick={() =>
|
|
||||||
window.open(
|
|
||||||
latestReleases?.server?.url ||
|
|
||||||
'https://github.com/perfect-panel/server/releases',
|
|
||||||
'_blank',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon icon='mdi:open-in-new' className='mr-1 h-3 w-3' />
|
|
||||||
{t('viewNewVersion')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<div className='text-muted-foreground hidden text-right text-xs sm:block'>
|
|
||||||
<div className='font-mono'>{systemInfo?.lastUpdated || '--'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Card className='overflow-hidden'>
|
|
||||||
<CardHeader className='bg-secondary py-1'>
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
<CardTitle>{t('systemLogs')}</CardTitle>
|
|
||||||
<Button variant='ghost' size='icon' disabled={isLoading} onClick={() => refetch()}>
|
|
||||||
<Icon
|
|
||||||
icon='uil:refresh'
|
|
||||||
className={`h-5 w-5 ${isLoading ? 'animate-spin' : ''}`}
|
|
||||||
/>
|
|
||||||
<span className='sr-only'>{t('refreshLogs')}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className='p-0'>
|
|
||||||
<ScrollArea className='h-[calc(100dvh-300px)] w-full rounded-md'>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className='flex h-full items-center justify-center'>
|
|
||||||
<Icon icon='uil:loading' className='text-primary h-8 w-8 animate-spin' />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Accordion type='single' collapsible className='w-full'>
|
|
||||||
{logs?.map((log: any, index: number) => (
|
|
||||||
<AccordionItem key={index} value={`item-${index}`} className='px-4'>
|
|
||||||
<AccordionTrigger className='hover:no-underline'>
|
|
||||||
<div className='flex w-full flex-col items-start space-y-2 sm:flex-row sm:items-center sm:space-x-4 sm:space-y-0'>
|
|
||||||
<span className='text-xs font-medium sm:text-sm'>{log.timestamp}</span>
|
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className='px-2'>
|
|
||||||
{Object.entries(log).map(([key, value]) => (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className='grid grid-cols-1 gap-2 text-xs sm:grid-cols-2 sm:text-sm'
|
|
||||||
>
|
|
||||||
<span className='font-medium'>{key}:</span>
|
|
||||||
<span className='break-all'>{value as string}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{/* <div className='grid grid-cols-1 gap-2 text-xs sm:grid-cols-2 sm:text-sm'>
|
|
||||||
<div className='font-medium'>{t('ip')}:</div>
|
|
||||||
<div>{log.ip}</div>
|
|
||||||
<div className='font-medium'>{t('request')}:</div>
|
|
||||||
<div className='break-all'>{log.request}</div>
|
|
||||||
<div className='font-medium'>{t('status')}:</div>
|
|
||||||
<div>{log.status}</div>
|
|
||||||
<div className='font-medium'>{t('caller')}:</div>
|
|
||||||
<div className='break-all'>{log.caller}</div>
|
|
||||||
<div className='font-medium'>{t('errors')}:</div>
|
|
||||||
<div className='break-all'>{log.errors || t('none')}</div>
|
|
||||||
<div className='font-medium'>{t('query')}:</div>
|
|
||||||
<div className='break-all'>{log.query || t('none')}</div>
|
|
||||||
<div className='font-medium'>{t('userAgent')}:</div>
|
|
||||||
<div className='break-all'>{log['user-agent']}</div>
|
|
||||||
</div> */}
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
))}
|
|
||||||
</Accordion>
|
|
||||||
)}
|
|
||||||
</ScrollArea>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -22,6 +22,7 @@ import { useState } from 'react';
|
|||||||
import { Bar, BarChart, CartesianGrid, LabelList, XAxis, YAxis } from 'recharts';
|
import { Bar, BarChart, CartesianGrid, LabelList, XAxis, YAxis } from 'recharts';
|
||||||
import { Empty } from '../empty';
|
import { Empty } from '../empty';
|
||||||
import { RevenueStatisticsCard } from './revenue-statistics-card';
|
import { RevenueStatisticsCard } from './revenue-statistics-card';
|
||||||
|
import SystemVersionCard from './system-version-card';
|
||||||
import { UserStatisticsCard } from './user-statistics-card';
|
import { UserStatisticsCard } from './user-statistics-card';
|
||||||
|
|
||||||
export default function Statistics() {
|
export default function Statistics() {
|
||||||
@ -78,26 +79,18 @@ export default function Statistics() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-5'>
|
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
title: t('onlineUsersCount'),
|
title: t('onlineUsersCount'),
|
||||||
value: ServerTotal?.online_users || 0,
|
value: ServerTotal?.online_users || 0,
|
||||||
subtitle: t('currentlyOnline'),
|
subtitle: t('currentlyOnline'),
|
||||||
icon: 'uil:users-alt',
|
icon: 'uil:users-alt',
|
||||||
href: '/dashboard/servers',
|
href: '/dashboard/users',
|
||||||
color: 'text-blue-600 dark:text-blue-400',
|
color: 'text-blue-600 dark:text-blue-400',
|
||||||
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
|
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: t('totalServers'),
|
|
||||||
value: (ServerTotal?.online_servers || 0) + (ServerTotal?.offline_servers || 0),
|
|
||||||
subtitle: `${t('online')} ${ServerTotal?.online_servers || 0} ${t('offline')} ${ServerTotal?.offline_servers || 0}`,
|
|
||||||
icon: 'uil:server-network',
|
|
||||||
href: '/dashboard/servers',
|
|
||||||
color: 'text-green-600 dark:text-green-400',
|
|
||||||
iconBg: 'bg-green-100 dark:bg-green-900/30',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: t('todayTraffic'),
|
title: t('todayTraffic'),
|
||||||
value: formatBytes(
|
value: formatBytes(
|
||||||
@ -118,6 +111,15 @@ export default function Statistics() {
|
|||||||
color: 'text-orange-600 dark:text-orange-400',
|
color: 'text-orange-600 dark:text-orange-400',
|
||||||
iconBg: 'bg-orange-100 dark:bg-orange-900/30',
|
iconBg: 'bg-orange-100 dark:bg-orange-900/30',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('totalServers'),
|
||||||
|
value: (ServerTotal?.online_servers || 0) + (ServerTotal?.offline_servers || 0),
|
||||||
|
subtitle: `${t('online')} ${ServerTotal?.online_servers || 0} ${t('offline')} ${ServerTotal?.offline_servers || 0}`,
|
||||||
|
icon: 'uil:server-network',
|
||||||
|
href: '/dashboard/servers',
|
||||||
|
color: 'text-green-600 dark:text-green-400',
|
||||||
|
iconBg: 'bg-green-100 dark:bg-green-900/30',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t('pendingTickets'),
|
title: t('pendingTickets'),
|
||||||
value: TicketTotal || 0,
|
value: TicketTotal || 0,
|
||||||
@ -151,6 +153,7 @@ export default function Statistics() {
|
|||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
<SystemVersionCard />
|
||||||
</div>
|
</div>
|
||||||
<div className='grid gap-3 md:grid-cols-2 lg:grid-cols-3'>
|
<div className='grid gap-3 md:grid-cols-2 lg:grid-cols-3'>
|
||||||
<RevenueStatisticsCard />
|
<RevenueStatisticsCard />
|
||||||
|
|||||||
118
apps/admin/components/dashboard/system-logs-dialog.tsx
Normal file
118
apps/admin/components/dashboard/system-logs-dialog.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { getSystemLog } from '@/services/admin/tool';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from '@workspace/ui/components/accordion';
|
||||||
|
import { Button } from '@workspace/ui/components/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@workspace/ui/components/dialog';
|
||||||
|
import { ScrollArea } from '@workspace/ui/components/scroll-area';
|
||||||
|
import { Icon } from '@workspace/ui/custom-components/icon';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
const getLogLevelColor = (level: string) => {
|
||||||
|
const colorMap: { [key: string]: string } = {
|
||||||
|
INFO: 'bg-blue-100 text-blue-800 hover:bg-blue-200',
|
||||||
|
WARN: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200',
|
||||||
|
ERROR: 'bg-red-100 text-red-800 hover:bg-red-200',
|
||||||
|
};
|
||||||
|
return colorMap[level] || 'bg-gray-100 text-gray-800 hover:bg-gray-200';
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SystemLogsDialogProps {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
variant?: 'default' | 'outline' | 'ghost' | 'secondary';
|
||||||
|
size?: 'sm' | 'default' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SystemLogsDialog({
|
||||||
|
trigger,
|
||||||
|
variant = 'outline',
|
||||||
|
size = 'sm',
|
||||||
|
}: SystemLogsDialogProps) {
|
||||||
|
const t = useTranslations('tool');
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: logs,
|
||||||
|
refetch,
|
||||||
|
isLoading,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['getSystemLog'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getSystemLog();
|
||||||
|
return data.data?.list || [];
|
||||||
|
},
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultTrigger = (
|
||||||
|
<Button variant={variant} size={size}>
|
||||||
|
{t('systemLogs')}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>{trigger || defaultTrigger}</DialogTrigger>
|
||||||
|
<DialogContent className='max-w-4xl'>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('systemLogs')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<ScrollArea className='bg-muted/30 h-[60vh] max-h-[80vh] min-h-[400px] w-full rounded-lg border p-1'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='flex h-full items-center justify-center'>
|
||||||
|
<Icon icon='uil:loading' className='text-primary h-8 w-8 animate-spin' />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Accordion type='single' collapsible className='w-full'>
|
||||||
|
{logs?.map((log: any, index: number) => (
|
||||||
|
<AccordionItem key={index} value={`item-${index}`} className='px-4'>
|
||||||
|
<AccordionTrigger className='hover:no-underline'>
|
||||||
|
<div className='flex w-full flex-col items-start space-y-2 sm:flex-row sm:items-center sm:space-x-4 sm:space-y-0'>
|
||||||
|
<span className='text-xs font-medium sm:text-sm'>{log.timestamp}</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className='px-2'>
|
||||||
|
{Object.entries(log).map(([key, value]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className='grid grid-cols-1 gap-2 text-xs sm:grid-cols-2 sm:text-sm'
|
||||||
|
>
|
||||||
|
<span className='font-medium'>{key}:</span>
|
||||||
|
<span className='break-all'>{value as string}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
onClick={() => {
|
||||||
|
refetch();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon='uil:refresh' className={`h-5 w-5 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
<span>{t('refreshLogs')}</span>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
apps/admin/components/dashboard/system-version-card.tsx
Normal file
212
apps/admin/components/dashboard/system-version-card.tsx
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { getVersion, restartSystem } from '@/services/admin/tool';
|
||||||
|
import { formatDate } from '@/utils/common';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@workspace/ui/components/alert-dialog';
|
||||||
|
import { Badge } from '@workspace/ui/components/badge';
|
||||||
|
import { Button } from '@workspace/ui/components/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card';
|
||||||
|
import { Icon } from '@workspace/ui/custom-components/icon';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import packageJson from '../../../../package.json';
|
||||||
|
import SystemLogsDialog from './system-logs-dialog';
|
||||||
|
|
||||||
|
export default function SystemVersionCard() {
|
||||||
|
const t = useTranslations('tool');
|
||||||
|
const [openRestart, setOpenRestart] = useState(false);
|
||||||
|
const [isRestarting, setIsRestarting] = useState(false);
|
||||||
|
|
||||||
|
const { data: latestReleases } = useQuery({
|
||||||
|
queryKey: ['getLatestReleases'],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const [webResponse, serverResponse] = await Promise.all([
|
||||||
|
fetch('https://api.github.com/repos/perfect-panel/ppanel-web/releases/latest'),
|
||||||
|
fetch('https://api.github.com/repos/perfect-panel/server/releases/latest'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const webData = webResponse.ok ? await webResponse.json() : null;
|
||||||
|
const serverData = serverResponse.ok ? await serverResponse.json() : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
web: webData
|
||||||
|
? {
|
||||||
|
version: webData.tag_name,
|
||||||
|
url: webData.html_url,
|
||||||
|
publishedAt: webData.published_at,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
server: serverData
|
||||||
|
? {
|
||||||
|
version: serverData.tag_name,
|
||||||
|
url: serverData.html_url,
|
||||||
|
publishedAt: serverData.published_at,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch latest releases:', error);
|
||||||
|
return { web: null, server: null };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
staleTime: 60 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
retryDelay: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasNewVersion =
|
||||||
|
latestReleases?.web && packageJson.version !== latestReleases.web.version.replace(/^v/, '');
|
||||||
|
|
||||||
|
const { data: systemInfo } = useQuery({
|
||||||
|
queryKey: ['getVersion'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getVersion();
|
||||||
|
|
||||||
|
const versionString = (data.data?.version || '').replace(' Develop', '').trim();
|
||||||
|
const releaseVersionRegex = /^[Vv]?\d+\.\d+\.\d+(-[a-zA-Z]+(\.\d+)?)?$/;
|
||||||
|
const timeMatch = versionString.match(/\(([^)]+)\)/);
|
||||||
|
const timeInBrackets = timeMatch ? timeMatch[1] : '';
|
||||||
|
|
||||||
|
const versionWithoutTime = versionString.replace(/\([^)]*\)/, '').trim();
|
||||||
|
|
||||||
|
const isDevelopment =
|
||||||
|
versionWithoutTime.includes('-dev') ||
|
||||||
|
versionWithoutTime.includes('-debug') ||
|
||||||
|
versionWithoutTime.includes('-nightly') ||
|
||||||
|
versionWithoutTime.includes('dev') ||
|
||||||
|
!releaseVersionRegex.test(versionWithoutTime);
|
||||||
|
|
||||||
|
let baseVersion = versionWithoutTime;
|
||||||
|
let lastUpdated = '';
|
||||||
|
|
||||||
|
if (isDevelopment && versionWithoutTime.includes('-')) {
|
||||||
|
const parts = versionWithoutTime.split('-');
|
||||||
|
baseVersion = parts[0] || versionWithoutTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUpdated = formatDate(new Date(timeInBrackets || Date.now())) || '';
|
||||||
|
|
||||||
|
const displayVersion =
|
||||||
|
baseVersion.startsWith('V') || baseVersion.startsWith('v')
|
||||||
|
? baseVersion
|
||||||
|
: `V${baseVersion}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRelease: !isDevelopment,
|
||||||
|
version: displayVersion,
|
||||||
|
lastUpdated,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasServerNewVersion =
|
||||||
|
latestReleases?.server &&
|
||||||
|
systemInfo &&
|
||||||
|
systemInfo.version.replace(/^V/, '') !== latestReleases.server.version.replace(/^v/, '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className='p-3'>
|
||||||
|
<CardHeader className='mb-2 p-0'>
|
||||||
|
<CardTitle className='flex items-center justify-between'>
|
||||||
|
{t('systemServices')}
|
||||||
|
<div className='flex items-center space-x-2'>
|
||||||
|
<SystemLogsDialog variant='outline' size='sm' />
|
||||||
|
<AlertDialog open={openRestart} onOpenChange={setOpenRestart}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant='destructive' size='sm'>
|
||||||
|
{t('systemReboot')}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t('confirmSystemReboot')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t('rebootDescription')}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
setIsRestarting(true);
|
||||||
|
await restartSystem();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||||
|
setIsRestarting(false);
|
||||||
|
setOpenRestart(false);
|
||||||
|
}}
|
||||||
|
disabled={isRestarting}
|
||||||
|
>
|
||||||
|
{isRestarting && <Icon icon='mdi:loading' className='mr-2 animate-spin' />}
|
||||||
|
{isRestarting ? t('rebooting') : t('confirmReboot')}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-3 p-0'>
|
||||||
|
<div className='flex flex-1 items-center justify-between'>
|
||||||
|
<div className='flex items-center'>
|
||||||
|
<Icon icon='mdi:web' className='mr-2 h-4 w-4 text-green-600' />
|
||||||
|
<span className='text-sm font-medium'>{t('webVersion')}</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center space-x-2'>
|
||||||
|
<Badge>V{packageJson.version}</Badge>
|
||||||
|
{hasNewVersion && (
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
latestReleases?.web?.url || 'https://github.com/perfect-panel/ppanel-web/releases'
|
||||||
|
}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
className='flex items-center space-x-1'
|
||||||
|
>
|
||||||
|
<Badge variant='destructive' className='animate-pulse px-2 py-0.5 text-xs'>
|
||||||
|
{t('newVersionAvailable')}
|
||||||
|
</Badge>
|
||||||
|
<Icon icon='mdi:open-in-new' className='h-3 w-3' />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-1 items-center justify-between'>
|
||||||
|
<div className='flex items-center'>
|
||||||
|
<Icon icon='mdi:server' className='mr-2 h-4 w-4 text-blue-600' />
|
||||||
|
<span className='text-sm font-medium'>{t('serverVersion')}</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center space-x-2'>
|
||||||
|
<Badge variant={!systemInfo?.isRelease ? 'destructive' : 'default'}>
|
||||||
|
{systemInfo?.version || 'V1.0.0'}
|
||||||
|
</Badge>
|
||||||
|
{hasServerNewVersion && (
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
latestReleases?.server?.url || 'https://github.com/perfect-panel/server/releases'
|
||||||
|
}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
className='flex items-center space-x-1'
|
||||||
|
>
|
||||||
|
<Badge variant='destructive' className='animate-pulse px-2 py-0.5 text-xs'>
|
||||||
|
{t('newVersionAvailable')}
|
||||||
|
</Badge>
|
||||||
|
<Icon icon='mdi:open-in-new' className='h-3 w-3' />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -77,7 +77,6 @@ export const navs = [
|
|||||||
icon: 'flat-color-icons:currency-exchange',
|
icon: 'flat-color-icons:currency-exchange',
|
||||||
},
|
},
|
||||||
{ title: 'ADS Config', url: '/dashboard/ads', icon: 'flat-color-icons:electrical-sensor' },
|
{ title: 'ADS Config', url: '/dashboard/ads', icon: 'flat-color-icons:electrical-sensor' },
|
||||||
{ title: 'System Tool', url: '/dashboard/tool', icon: 'flat-color-icons:info' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user