- migrate from SQLite to PostgreSQL with Drizzle ORM - implement comprehensive AdminLayout with expandable sidebar navigation - create professional dashboard with real-time charts and metrics - add advanced monitoring, reporting, and export functionality - fix menu alignment and remove non-existent pages - eliminate duplicate headers and improve UI consistency - add Tailwind CSS v3 for professional styling - expand database schema from 6 to 15 tables - implement role-based access control and API key management - create comprehensive settings, monitoring, and system info pages
480 lines
15 KiB
TypeScript
480 lines
15 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import type { NextPage } from 'next'
|
|
import Head from 'next/head'
|
|
import AdminLayout from '../../components/AdminLayout'
|
|
import {
|
|
ChartBarIcon,
|
|
ShieldExclamationIcon,
|
|
DocumentTextIcon,
|
|
ExclamationTriangleIcon,
|
|
ClockIcon,
|
|
EyeIcon,
|
|
ServerStackIcon,
|
|
CircleStackIcon,
|
|
UserGroupIcon,
|
|
GlobeAltIcon,
|
|
ArrowTrendingUpIcon,
|
|
ArrowTrendingDownIcon,
|
|
CheckCircleIcon
|
|
} from '@heroicons/react/24/outline'
|
|
import {
|
|
Chart as ChartJS,
|
|
CategoryScale,
|
|
LinearScale,
|
|
PointElement,
|
|
LineElement,
|
|
BarElement,
|
|
Title,
|
|
Tooltip,
|
|
Legend,
|
|
ArcElement,
|
|
} from 'chart.js'
|
|
import { Line, Bar, Doughnut } from 'react-chartjs-2'
|
|
|
|
ChartJS.register(
|
|
CategoryScale,
|
|
LinearScale,
|
|
PointElement,
|
|
LineElement,
|
|
BarElement,
|
|
Title,
|
|
Tooltip,
|
|
Legend,
|
|
ArcElement
|
|
)
|
|
|
|
interface DashboardStats {
|
|
// Basic stats
|
|
total_sources: number
|
|
pending_sources: number
|
|
pending_reports: number
|
|
high_risk_sources: number
|
|
sources_added_week: number
|
|
reports_today: number
|
|
|
|
// Advanced stats
|
|
verified_sources_today: number
|
|
api_calls_today: number
|
|
unique_visitors_today: number
|
|
system_uptime: string
|
|
database_size: string
|
|
active_moderators: number
|
|
|
|
// Performance metrics
|
|
avg_response_time: number
|
|
api_success_rate: number
|
|
memory_usage: number
|
|
cpu_usage: number
|
|
|
|
// Trend data
|
|
sources_trend: Array<{date: string, count: number}>
|
|
reports_trend: Array<{date: string, count: number}>
|
|
risk_distribution: Array<{level: string, count: number}>
|
|
category_distribution: Array<{category: string, count: number}>
|
|
|
|
// Recent activities
|
|
recent_sources: Array<{
|
|
id: number
|
|
url: string
|
|
status: string
|
|
created_at: string
|
|
}>
|
|
recent_reports: Array<{
|
|
id: number
|
|
source_url: string
|
|
status: string
|
|
created_at: string
|
|
}>
|
|
}
|
|
|
|
const StatCard = ({ title, value, change, icon: Icon, trend, color = 'blue' }: {
|
|
title: string
|
|
value: string | number
|
|
change?: number
|
|
icon: any
|
|
trend?: 'up' | 'down' | 'neutral'
|
|
color?: 'blue' | 'green' | 'red' | 'yellow' | 'purple'
|
|
}) => {
|
|
const colorClasses = {
|
|
blue: 'bg-blue-500',
|
|
green: 'bg-green-500',
|
|
red: 'bg-red-500',
|
|
yellow: 'bg-yellow-500',
|
|
purple: 'bg-purple-500'
|
|
}
|
|
|
|
return (
|
|
<div className="stat-card">
|
|
<div className="flex items-center">
|
|
<div className="flex-shrink-0">
|
|
<div className={`p-3 rounded-lg ${colorClasses[color]}`}>
|
|
<Icon className="h-6 w-6 text-white" />
|
|
</div>
|
|
</div>
|
|
<div className="ml-5 w-0 flex-1">
|
|
<dl>
|
|
<dt className="text-sm font-medium text-gray-500 truncate">{title}</dt>
|
|
<dd className="flex items-baseline">
|
|
<div className="text-2xl font-semibold text-gray-900">
|
|
{typeof value === 'number' ? value.toLocaleString() : value}
|
|
</div>
|
|
{change !== undefined && (
|
|
<div className={`ml-2 flex items-baseline text-sm font-semibold ${
|
|
change > 0 ? 'text-green-600' : change < 0 ? 'text-red-600' : 'text-gray-500'
|
|
}`}>
|
|
{change > 0 ? (
|
|
<ArrowTrendingUpIcon className="self-center flex-shrink-0 h-4 w-4 text-green-500" />
|
|
) : change < 0 ? (
|
|
<ArrowTrendingDownIcon className="self-center flex-shrink-0 h-4 w-4 text-red-500" />
|
|
) : null}
|
|
<span className="ml-1">
|
|
{Math.abs(change)}%
|
|
</span>
|
|
</div>
|
|
)}
|
|
</dd>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const AdminDashboard: NextPage = () => {
|
|
const [stats, setStats] = useState<DashboardStats | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
useEffect(() => {
|
|
fetchStats()
|
|
const interval = setInterval(fetchStats, 30000) // Refresh every 30 seconds
|
|
return () => clearInterval(interval)
|
|
}, [])
|
|
|
|
const fetchStats = async () => {
|
|
try {
|
|
const response = await fetch('/api/admin/dashboard')
|
|
if (response.ok) {
|
|
const data = await response.json()
|
|
setStats(data)
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch stats:', error)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
// Chart configurations
|
|
const sourcesChartData = {
|
|
labels: stats?.sources_trend?.map(item => new Date(item.date).toLocaleDateString('sk-SK')) || [],
|
|
datasets: [
|
|
{
|
|
label: 'Nové zdroje',
|
|
data: stats?.sources_trend?.map(item => item.count) || [],
|
|
borderColor: 'rgb(59, 130, 246)',
|
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
|
tension: 0.4,
|
|
},
|
|
],
|
|
}
|
|
|
|
const reportsChartData = {
|
|
labels: stats?.reports_trend?.map(item => new Date(item.date).toLocaleDateString('sk-SK')) || [],
|
|
datasets: [
|
|
{
|
|
label: 'Nové hlásenia',
|
|
data: stats?.reports_trend?.map(item => item.count) || [],
|
|
backgroundColor: 'rgba(34, 197, 94, 0.8)',
|
|
borderColor: 'rgb(34, 197, 94)',
|
|
borderWidth: 1,
|
|
},
|
|
],
|
|
}
|
|
|
|
const riskDistributionData = {
|
|
labels: stats?.risk_distribution?.map(item => `Úroveň ${item.level}`) || [],
|
|
datasets: [
|
|
{
|
|
data: stats?.risk_distribution?.map(item => item.count) || [],
|
|
backgroundColor: [
|
|
'#ef4444', // red
|
|
'#f97316', // orange
|
|
'#eab308', // yellow
|
|
'#22c55e', // green
|
|
'#3b82f6', // blue
|
|
],
|
|
borderWidth: 2,
|
|
borderColor: '#ffffff',
|
|
},
|
|
],
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<AdminLayout title="Dashboard">
|
|
<Head>
|
|
<title>Dashboard - Hliadka.sk Admin</title>
|
|
</Head>
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
|
</div>
|
|
</AdminLayout>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<AdminLayout title="Dashboard">
|
|
<Head>
|
|
<title>Dashboard - Hliadka.sk Admin</title>
|
|
</Head>
|
|
|
|
{/* Key Metrics */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
|
<StatCard
|
|
title="Celkové zdroje"
|
|
value={stats?.total_sources || 0}
|
|
change={12}
|
|
icon={CircleStackIcon}
|
|
color="blue"
|
|
/>
|
|
<StatCard
|
|
title="Čakajúce schválenie"
|
|
value={stats?.pending_sources || 0}
|
|
change={-5}
|
|
icon={ClockIcon}
|
|
color="yellow"
|
|
/>
|
|
<StatCard
|
|
title="Vysoké riziko"
|
|
value={stats?.high_risk_sources || 0}
|
|
change={8}
|
|
icon={ExclamationTriangleIcon}
|
|
color="red"
|
|
/>
|
|
<StatCard
|
|
title="Nové hlásenia"
|
|
value={stats?.pending_reports || 0}
|
|
change={15}
|
|
icon={DocumentTextIcon}
|
|
color="green"
|
|
/>
|
|
</div>
|
|
|
|
{/* System Performance Metrics */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
|
<StatCard
|
|
title="API volania dnes"
|
|
value={stats?.api_calls_today || 0}
|
|
icon={ServerStackIcon}
|
|
color="purple"
|
|
/>
|
|
<StatCard
|
|
title="Aktívni moderátori"
|
|
value={stats?.active_moderators || 0}
|
|
icon={UserGroupIcon}
|
|
color="green"
|
|
/>
|
|
<StatCard
|
|
title="Využitie CPU"
|
|
value={`${stats?.cpu_usage || 0}%`}
|
|
icon={ChartBarIcon}
|
|
color="blue"
|
|
/>
|
|
<StatCard
|
|
title="API Success Rate"
|
|
value={`${stats?.api_success_rate || 0}%`}
|
|
icon={CheckCircleIcon}
|
|
color="green"
|
|
/>
|
|
</div>
|
|
|
|
{/* Charts Section */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
|
{/* Sources Trend */}
|
|
<div className="card p-6">
|
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Trend pridávania zdrojov</h3>
|
|
<Line
|
|
data={sourcesChartData}
|
|
options={{
|
|
responsive: true,
|
|
plugins: {
|
|
legend: {
|
|
position: 'top' as const,
|
|
},
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
},
|
|
},
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Reports Trend */}
|
|
<div className="card p-6">
|
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Trend hlásení</h3>
|
|
<Bar
|
|
data={reportsChartData}
|
|
options={{
|
|
responsive: true,
|
|
plugins: {
|
|
legend: {
|
|
position: 'top' as const,
|
|
},
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
},
|
|
},
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Risk Distribution and Category Stats */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-8">
|
|
<div className="card p-6">
|
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Distribúcia rizika</h3>
|
|
<Doughnut
|
|
data={riskDistributionData}
|
|
options={{
|
|
responsive: true,
|
|
plugins: {
|
|
legend: {
|
|
position: 'bottom' as const,
|
|
},
|
|
},
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="card p-6 col-span-2">
|
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Systémové informácie</h3>
|
|
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
|
<div>
|
|
<dt className="text-sm font-medium text-gray-500">Uptime systému</dt>
|
|
<dd className="mt-1 text-sm text-gray-900">{stats?.system_uptime || 'N/A'}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-sm font-medium text-gray-500">Veľkosť databázy</dt>
|
|
<dd className="mt-1 text-sm text-gray-900">{stats?.database_size || 'N/A'}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-sm font-medium text-gray-500">Priemerný response time</dt>
|
|
<dd className="mt-1 text-sm text-gray-900">{stats?.avg_response_time || 0}ms</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-sm font-medium text-gray-500">Využitie pamäte</dt>
|
|
<dd className="mt-1 text-sm text-gray-900">{stats?.memory_usage || 0}%</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-sm font-medium text-gray-500">Jedinečných návštevníkov dnes</dt>
|
|
<dd className="mt-1 text-sm text-gray-900">{stats?.unique_visitors_today || 0}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-sm font-medium text-gray-500">Overených zdrojov dnes</dt>
|
|
<dd className="mt-1 text-sm text-gray-900">{stats?.verified_sources_today || 0}</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recent Activity */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
<div className="card p-6">
|
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Najnovšie zdroje</h3>
|
|
<div className="flow-root">
|
|
<ul className="-mb-8">
|
|
{stats?.recent_sources?.map((source, idx) => (
|
|
<li key={source.id}>
|
|
<div className="relative pb-8">
|
|
{idx !== (stats.recent_sources?.length || 0) - 1 && (
|
|
<span
|
|
className="absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200"
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
<div className="relative flex space-x-3">
|
|
<div>
|
|
<span className={`h-8 w-8 rounded-full flex items-center justify-center ring-8 ring-white ${
|
|
source.status === 'verified' ? 'bg-green-500' :
|
|
source.status === 'pending' ? 'bg-yellow-500' :
|
|
'bg-red-500'
|
|
}`}>
|
|
<ShieldExclamationIcon className="h-4 w-4 text-white" />
|
|
</span>
|
|
</div>
|
|
<div className="min-w-0 flex-1 pt-1.5 flex justify-between space-x-4">
|
|
<div>
|
|
<p className="text-sm text-gray-500 truncate">
|
|
{source.url}
|
|
</p>
|
|
</div>
|
|
<div className="text-right text-sm whitespace-nowrap text-gray-500">
|
|
{new Date(source.created_at).toLocaleString('sk-SK')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
)) || (
|
|
<li className="text-sm text-gray-500 text-center py-4">
|
|
Žiadne nedávne aktivity
|
|
</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card p-6">
|
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Najnovšie hlásenia</h3>
|
|
<div className="flow-root">
|
|
<ul className="-mb-8">
|
|
{stats?.recent_reports?.map((report, idx) => (
|
|
<li key={report.id}>
|
|
<div className="relative pb-8">
|
|
{idx !== (stats.recent_reports?.length || 0) - 1 && (
|
|
<span
|
|
className="absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200"
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
<div className="relative flex space-x-3">
|
|
<div>
|
|
<span className={`h-8 w-8 rounded-full flex items-center justify-center ring-8 ring-white ${
|
|
report.status === 'approved' ? 'bg-green-500' :
|
|
report.status === 'pending' ? 'bg-yellow-500' :
|
|
'bg-red-500'
|
|
}`}>
|
|
<DocumentTextIcon className="h-4 w-4 text-white" />
|
|
</span>
|
|
</div>
|
|
<div className="min-w-0 flex-1 pt-1.5 flex justify-between space-x-4">
|
|
<div>
|
|
<p className="text-sm text-gray-500 truncate">
|
|
{report.source_url}
|
|
</p>
|
|
</div>
|
|
<div className="text-right text-sm whitespace-nowrap text-gray-500">
|
|
{new Date(report.created_at).toLocaleString('sk-SK')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
)) || (
|
|
<li className="text-sm text-gray-500 text-center py-4">
|
|
Žiadne nedávne hlásenia
|
|
</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</AdminLayout>
|
|
)
|
|
}
|
|
|
|
export default AdminDashboard |