Files
location-mqtt-tracker-app/app/admin/page.tsx
Joachim Hummel 5f637817ce Improve admin page UI with modern SaaS design
- Add gradient hero section with welcome message
- Redesign stat cards with colorful gradients and hover effects
- Update system status cards with color-coded backgrounds
- Enhance database statistics section with modern cards
- Modernize device list table with gradient headers
- Improve database maintenance section with better visual hierarchy
- Add gradient background to entire admin layout
- Update header with glassmorphism effect and modern badges
- Enhance navigation with improved active states and transitions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 22:37:08 +00:00

591 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { LocationResponse } from "@/types/location";
interface DeviceInfo {
id: string;
name: string;
color: string;
}
export default function AdminDashboard() {
const { data: session } = useSession();
const userRole = (session?.user as any)?.role;
const username = session?.user?.name || '';
const isAdmin = userRole === 'ADMIN';
const isSuperAdmin = username === 'admin';
const [stats, setStats] = useState({
totalDevices: 0,
totalPoints: 0,
lastUpdated: "",
onlineDevices: 0,
});
const [devices, setDevices] = useState<DeviceInfo[]>([]);
const [cleanupStatus, setCleanupStatus] = useState<{
loading: boolean;
message: string;
type: 'success' | 'error' | '';
}>({
loading: false,
message: '',
type: '',
});
const [optimizeStatus, setOptimizeStatus] = useState<{
loading: boolean;
message: string;
type: 'success' | 'error' | '';
}>({
loading: false,
message: '',
type: '',
});
const [dbStats, setDbStats] = useState<any>(null);
const [systemStatus, setSystemStatus] = useState<any>(null);
// Fetch devices from API
useEffect(() => {
const fetchDevices = async () => {
try {
const response = await fetch("/api/devices/public");
if (response.ok) {
const data = await response.json();
setDevices(data.devices);
}
} catch (err) {
console.error("Failed to fetch devices:", err);
}
};
fetchDevices();
// Refresh devices every 30 seconds
const interval = setInterval(fetchDevices, 30000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
const fetchStats = async () => {
try {
// Fetch from local API (reads from SQLite cache)
const response = await fetch("/api/locations?sync=false"); // sync=false for faster response
const data: LocationResponse = await response.json();
// Calculate online devices (last location < 10 minutes)
const now = Date.now();
const tenMinutesAgo = now - 10 * 60 * 1000;
const recentDevices = new Set(
data.history
.filter((loc) => {
const locationTime = new Date(loc.timestamp).getTime();
return locationTime > tenMinutesAgo;
})
.map((loc) => loc.username)
);
setStats({
totalDevices: devices.length,
totalPoints: data.total_points || data.history.length,
lastUpdated: data.last_updated || new Date().toISOString(),
onlineDevices: recentDevices.size,
});
} catch (err) {
console.error("Failed to fetch stats", err);
}
};
if (devices.length > 0) {
fetchStats();
const interval = setInterval(fetchStats, 10000);
return () => clearInterval(interval);
}
}, [devices]);
// Fetch detailed database statistics
useEffect(() => {
const fetchDbStats = async () => {
try {
const response = await fetch('/api/locations/stats');
if (response.ok) {
const data = await response.json();
setDbStats(data);
}
} catch (err) {
console.error('Failed to fetch DB stats:', err);
}
};
fetchDbStats();
// Refresh DB stats every 30 seconds
const interval = setInterval(fetchDbStats, 30000);
return () => clearInterval(interval);
}, []);
// Fetch system status (uptime, memory)
useEffect(() => {
const fetchSystemStatus = async () => {
try {
const response = await fetch('/api/system/status');
if (response.ok) {
const data = await response.json();
setSystemStatus(data);
}
} catch (err) {
console.error('Failed to fetch system status:', err);
}
};
fetchSystemStatus();
// Refresh every 10 seconds for live uptime
const interval = setInterval(fetchSystemStatus, 10000);
return () => clearInterval(interval);
}, []);
// Cleanup old locations
const handleCleanup = async (retentionHours: number) => {
if (!confirm(`Delete all locations older than ${Math.round(retentionHours / 24)} days?`)) {
return;
}
setCleanupStatus({ loading: true, message: '', type: '' });
try {
const response = await fetch('/api/locations/cleanup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ retentionHours }),
});
const data = await response.json();
if (response.ok) {
setCleanupStatus({
loading: false,
message: `✓ Deleted ${data.deleted} records. Freed ${data.freedKB} KB.`,
type: 'success',
});
// Refresh stats
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
setCleanupStatus({
loading: false,
message: `Error: ${data.error}`,
type: 'error',
});
}
} catch (error) {
setCleanupStatus({
loading: false,
message: 'Failed to cleanup locations',
type: 'error',
});
}
// Clear message after 5 seconds
setTimeout(() => {
setCleanupStatus({ loading: false, message: '', type: '' });
}, 5000);
};
// Optimize database
const handleOptimize = async () => {
if (!confirm('Optimize database? This may take a few seconds.')) {
return;
}
setOptimizeStatus({ loading: true, message: '', type: '' });
try {
const response = await fetch('/api/locations/optimize', {
method: 'POST',
});
const data = await response.json();
if (response.ok) {
setOptimizeStatus({
loading: false,
message: `✓ Database optimized. Freed ${data.freedMB} MB. (${data.before.sizeMB}${data.after.sizeMB} MB)`,
type: 'success',
});
// Refresh stats
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
setOptimizeStatus({
loading: false,
message: `Error: ${data.error}`,
type: 'error',
});
}
} catch (error) {
setOptimizeStatus({
loading: false,
message: 'Failed to optimize database',
type: 'error',
});
}
// Clear message after 5 seconds
setTimeout(() => {
setOptimizeStatus({ loading: false, message: '', type: '' });
}, 5000);
};
const statCards = [
{
title: "Total Devices",
value: stats.totalDevices,
icon: "📱",
},
{
title: "Online Devices",
value: stats.onlineDevices,
icon: "🟢",
},
{
title: "Total Locations",
value: stats.totalPoints,
icon: "📍",
},
];
return (
<div className="space-y-8">
{/* Hero Section with Gradient */}
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-blue-600 via-blue-700 to-indigo-800 p-8 shadow-xl">
<div className="absolute top-0 right-0 -mt-4 -mr-4 h-40 w-40 rounded-full bg-white/10 blur-3xl"></div>
<div className="absolute bottom-0 left-0 -mb-4 -ml-4 h-40 w-40 rounded-full bg-white/10 blur-3xl"></div>
<div className="relative">
<h2 className="text-4xl font-bold text-white mb-2">Dashboard</h2>
<p className="text-blue-100 text-lg">Willkommen zurück! Hier ist ein Überblick über dein System.</p>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{statCards.map((stat, index) => {
const gradients = [
'from-emerald-500 to-teal-600',
'from-blue-500 to-indigo-600',
'from-violet-500 to-purple-600'
];
return (
<div
key={stat.title}
className="group relative overflow-hidden bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1"
>
<div className={`absolute inset-0 bg-gradient-to-br ${gradients[index]} opacity-0 group-hover:opacity-5 transition-opacity`}></div>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<div className={`flex items-center justify-center w-14 h-14 rounded-xl bg-gradient-to-br ${gradients[index]} text-white text-2xl shadow-lg`}>
{stat.icon}
</div>
</div>
<p className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-1">{stat.title}</p>
<p className="text-4xl font-bold bg-gradient-to-br from-gray-900 to-gray-700 bg-clip-text text-transparent">{stat.value}</p>
</div>
</div>
);
})}
</div>
{/* System Status */}
{systemStatus && (
<div className="bg-white rounded-2xl shadow-lg overflow-hidden">
<div className="bg-gradient-to-r from-slate-50 to-gray-50 px-6 py-5 border-b border-gray-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-slate-600 to-slate-700 flex items-center justify-center text-white text-xl">
</div>
<h3 className="text-xl font-bold text-gray-900">
System Status
</h3>
</div>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="relative overflow-hidden bg-gradient-to-br from-green-50 to-emerald-50 p-5 rounded-xl border border-green-100">
<div className="absolute top-0 right-0 text-6xl opacity-10"></div>
<p className="text-sm font-semibold text-green-700 uppercase tracking-wide mb-1">App Uptime</p>
<p className="text-3xl font-bold text-green-900">{systemStatus.uptime.formatted}</p>
<p className="text-xs text-green-600 mt-2">Running since server start</p>
</div>
<div className="relative overflow-hidden bg-gradient-to-br from-blue-50 to-indigo-50 p-5 rounded-xl border border-blue-100">
<div className="absolute top-0 right-0 text-6xl opacity-10">💾</div>
<p className="text-sm font-semibold text-blue-700 uppercase tracking-wide mb-1">Memory Usage</p>
<p className="text-3xl font-bold text-blue-900">{systemStatus.memory.heapUsed} MB</p>
<p className="text-xs text-blue-600 mt-2">Heap: {systemStatus.memory.heapTotal} MB / RSS: {systemStatus.memory.rss} MB</p>
</div>
<div className="relative overflow-hidden bg-gradient-to-br from-purple-50 to-violet-50 p-5 rounded-xl border border-purple-100">
<div className="absolute top-0 right-0 text-6xl opacity-10">🚀</div>
<p className="text-sm font-semibold text-purple-700 uppercase tracking-wide mb-1">Runtime</p>
<p className="text-3xl font-bold text-purple-900">{systemStatus.nodejs}</p>
<p className="text-xs text-purple-600 mt-2">Platform: {systemStatus.platform}</p>
</div>
</div>
</div>
</div>
)}
{/* Database Statistics */}
{dbStats && (
<div className="bg-white rounded-2xl shadow-lg overflow-hidden">
<div className="bg-gradient-to-r from-amber-50 to-orange-50 px-6 py-5 border-b border-orange-100">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-amber-600 to-orange-600 flex items-center justify-center text-white text-xl">
📊
</div>
<h3 className="text-xl font-bold text-gray-900">
Database Statistics
</h3>
</div>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="relative overflow-hidden bg-gradient-to-br from-cyan-50 to-blue-50 p-5 rounded-xl border border-cyan-100">
<div className="absolute top-0 right-0 text-6xl opacity-10">💽</div>
<p className="text-sm font-semibold text-cyan-700 uppercase tracking-wide mb-1">Database Size</p>
<p className="text-3xl font-bold text-cyan-900">{dbStats.sizeMB} MB</p>
<p className="text-xs text-cyan-600 mt-2">WAL Mode: {dbStats.walMode}</p>
</div>
<div className="relative overflow-hidden bg-gradient-to-br from-indigo-50 to-blue-50 p-5 rounded-xl border border-indigo-100">
<div className="absolute top-0 right-0 text-6xl opacity-10">📅</div>
<p className="text-sm font-semibold text-indigo-700 uppercase tracking-wide mb-1">Time Range</p>
<p className="text-sm font-semibold text-indigo-900">
{dbStats.oldest ? new Date(dbStats.oldest).toLocaleDateString() : 'N/A'}
</p>
<p className="text-xs text-indigo-600">to</p>
<p className="text-sm font-semibold text-indigo-900">
{dbStats.newest ? new Date(dbStats.newest).toLocaleDateString() : 'N/A'}
</p>
</div>
<div className="relative overflow-hidden bg-gradient-to-br from-pink-50 to-rose-50 p-5 rounded-xl border border-pink-100">
<div className="absolute top-0 right-0 text-6xl opacity-10">📈</div>
<p className="text-sm font-semibold text-pink-700 uppercase tracking-wide mb-1">Average Per Day</p>
<p className="text-3xl font-bold text-pink-900">{dbStats.avgPerDay}</p>
<p className="text-xs text-pink-600 mt-2">locations (last 7 days)</p>
</div>
</div>
{/* Locations per Device */}
{dbStats.perDevice && dbStats.perDevice.length > 0 && (
<div className="bg-gradient-to-br from-gray-50 to-slate-50 p-5 rounded-xl border border-gray-200">
<h4 className="text-sm font-bold text-gray-700 uppercase tracking-wide mb-4 flex items-center gap-2">
<span className="text-lg">📱</span>
Locations per Device
</h4>
<div className="space-y-2">
{dbStats.perDevice.map((device: any, idx: number) => (
<div key={device.username} className="group flex items-center justify-between py-3 px-4 bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow border border-gray-100">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white text-xs font-bold">
{idx + 1}
</div>
<span className="text-sm font-semibold text-gray-700">Device {device.username}</span>
</div>
<span className="text-sm font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">{device.count.toLocaleString()} locations</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* Device List */}
<div className="bg-white rounded-2xl shadow-lg overflow-hidden">
<div className="bg-gradient-to-r from-teal-50 to-cyan-50 px-6 py-5 border-b border-teal-100">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-teal-600 to-cyan-600 flex items-center justify-center text-white text-xl">
📱
</div>
<h3 className="text-xl font-bold text-gray-900">
Configured Devices
</h3>
</div>
</div>
<div className="p-6">
<div className="overflow-x-auto">
<table className="min-w-full">
<thead>
<tr className="border-b-2 border-gray-200">
<th className="text-left py-4 px-4 text-xs font-bold text-gray-600 uppercase tracking-wider">
ID
</th>
<th className="text-left py-4 px-4 text-xs font-bold text-gray-600 uppercase tracking-wider">
Name
</th>
<th className="text-left py-4 px-4 text-xs font-bold text-gray-600 uppercase tracking-wider">
Color
</th>
</tr>
</thead>
<tbody>
{devices.map((device, idx) => (
<tr
key={device.id}
className="border-b border-gray-100 hover:bg-gradient-to-r hover:from-blue-50 hover:to-indigo-50 transition-all"
>
<td className="py-4 px-4 text-sm font-semibold text-gray-900">
{device.id}
</td>
<td className="py-4 px-4 text-sm font-semibold text-gray-900">
{device.name}
</td>
<td className="py-4 px-4">
<div className="flex items-center gap-3">
<div
className="w-8 h-8 rounded-lg shadow-md ring-2 ring-white"
style={{ backgroundColor: device.color }}
/>
<span className="text-sm font-mono text-gray-600 bg-gray-100 px-2 py-1 rounded">
{device.color}
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
{/* Database Maintenance - SUPER ADMIN ONLY (username "admin") */}
{isSuperAdmin && (
<div className="bg-white rounded-2xl shadow-lg overflow-hidden border border-red-100">
<div className="bg-gradient-to-r from-red-50 to-orange-50 px-6 py-5 border-b border-red-100">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-red-600 to-orange-600 flex items-center justify-center text-white text-xl">
🛠
</div>
<div>
<h3 className="text-xl font-bold text-gray-900">
Database Maintenance
</h3>
<p className="text-xs text-red-600 font-semibold">SUPER ADMIN ONLY</p>
</div>
</div>
</div>
<div className="p-6 space-y-6">
{/* Cleanup Section */}
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-6 rounded-xl border border-blue-100">
<h4 className="text-base font-bold text-gray-900 mb-2 flex items-center gap-2">
<span className="text-xl">🧹</span>
Clean up old data
</h4>
<p className="text-sm text-gray-700 mb-4">
Delete old location data to keep the database size manageable.
</p>
{/* Cleanup Status Message */}
{cleanupStatus.message && (
<div
className={`mb-4 p-4 rounded-lg font-semibold ${
cleanupStatus.type === 'success'
? 'bg-green-100 text-green-800 border border-green-200'
: 'bg-red-100 text-red-800 border border-red-200'
}`}
>
{cleanupStatus.message}
</div>
)}
{/* Cleanup Buttons */}
<div className="flex flex-wrap gap-3">
<button
onClick={() => handleCleanup(168)}
disabled={cleanupStatus.loading}
className="px-5 py-2.5 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg hover:from-blue-700 hover:to-blue-800 disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed font-semibold shadow-md hover:shadow-lg transition-all"
>
{cleanupStatus.loading ? 'Cleaning...' : 'Delete > 7 days'}
</button>
<button
onClick={() => handleCleanup(360)}
disabled={cleanupStatus.loading}
className="px-5 py-2.5 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg hover:from-blue-700 hover:to-blue-800 disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed font-semibold shadow-md hover:shadow-lg transition-all"
>
{cleanupStatus.loading ? 'Cleaning...' : 'Delete > 15 days'}
</button>
<button
onClick={() => handleCleanup(720)}
disabled={cleanupStatus.loading}
className="px-5 py-2.5 bg-gradient-to-r from-indigo-600 to-indigo-700 text-white rounded-lg hover:from-indigo-700 hover:to-indigo-800 disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed font-semibold shadow-md hover:shadow-lg transition-all"
>
{cleanupStatus.loading ? 'Cleaning...' : 'Delete > 30 days'}
</button>
<button
onClick={() => handleCleanup(2160)}
disabled={cleanupStatus.loading}
className="px-5 py-2.5 bg-gradient-to-r from-orange-600 to-red-600 text-white rounded-lg hover:from-orange-700 hover:to-red-700 disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed font-semibold shadow-md hover:shadow-lg transition-all"
>
{cleanupStatus.loading ? 'Cleaning...' : 'Delete > 90 days'}
</button>
</div>
<div className="mt-4 bg-white/70 p-3 rounded-lg border border-blue-200">
<p className="text-xs text-gray-600">
<span className="font-semibold">Current database size:</span> {stats.totalPoints.toLocaleString()} locations
</p>
</div>
</div>
{/* Optimize Section */}
<div className="bg-gradient-to-br from-purple-50 to-violet-50 p-6 rounded-xl border border-purple-100">
<h4 className="text-base font-bold text-gray-900 mb-2 flex items-center gap-2">
<span className="text-xl"></span>
Optimize Database
</h4>
<p className="text-sm text-gray-700 mb-4">
Run VACUUM and ANALYZE to reclaim disk space and improve query performance. Recommended after cleanup.
</p>
{/* Optimize Status Message */}
{optimizeStatus.message && (
<div
className={`mb-4 p-4 rounded-lg font-semibold ${
optimizeStatus.type === 'success'
? 'bg-green-100 text-green-800 border border-green-200'
: 'bg-red-100 text-red-800 border border-red-200'
}`}
>
{optimizeStatus.message}
</div>
)}
<button
onClick={handleOptimize}
disabled={optimizeStatus.loading}
className="px-6 py-3 bg-gradient-to-r from-purple-600 to-violet-600 text-white rounded-lg hover:from-purple-700 hover:to-violet-700 disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed font-semibold shadow-md hover:shadow-lg transition-all flex items-center gap-2"
>
<span className="text-xl">{optimizeStatus.loading ? '⚙️' : '⚡'}</span>
{optimizeStatus.loading ? 'Optimizing...' : 'Optimize Now'}
</button>
</div>
</div>
</div>
)}
{/* Last Updated */}
<div className="bg-gradient-to-r from-gray-50 to-slate-50 rounded-xl p-4 border border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-gray-600">
<span className="text-lg">🕒</span>
<span className="text-sm font-semibold">Last Updated</span>
</div>
<span className="text-sm font-mono text-gray-700 bg-white px-3 py-1 rounded-lg shadow-sm">
{new Date(stats.lastUpdated).toLocaleString()}
</span>
</div>
</div>
</div>
);
}