Add admin-core frontend (Port 3008)

Next.js admin frontend for Core with 3 categories
(Communication, Infrastructure, Development), 13 modules,
2 roles (developer, ops), and 11 API proxy routes.
Includes docker-compose service and nginx SSL config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Boenisch
2026-02-12 14:44:37 +01:00
parent 2498b0eb1f
commit 97373580a8
47 changed files with 14541 additions and 0 deletions

View File

@@ -0,0 +1,173 @@
'use client'
import { useEffect, useState } from 'react'
import { navigation } from '@/lib/navigation'
import { getStoredRole, isCategoryVisibleForRole, RoleId } from '@/lib/roles'
import Link from 'next/link'
interface ServiceHealth {
name: string
status: 'healthy' | 'unhealthy' | 'unknown'
url?: string
}
export default function DashboardPage() {
const [currentRole, setCurrentRole] = useState<RoleId | null>(null)
const [services, setServices] = useState<ServiceHealth[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const role = getStoredRole()
setCurrentRole(role)
// Check service health
const checkServices = async () => {
const serviceList: ServiceHealth[] = [
{ name: 'Matrix Synapse', status: 'unknown' },
{ name: 'Jitsi Meet', status: 'unknown' },
{ name: 'Mailpit', status: 'unknown' },
{ name: 'Gitea', status: 'unknown' },
{ name: 'Woodpecker CI', status: 'unknown' },
{ name: 'Backend Core', status: 'unknown' },
]
try {
const response = await fetch('/api/admin/health')
if (response.ok) {
const data = await response.json()
if (data.services) {
serviceList.forEach(s => {
const key = s.name.toLowerCase().replace(/\s+/g, '-')
if (data.services[key]) {
s.status = data.services[key].healthy ? 'healthy' : 'unhealthy'
}
})
}
}
} catch {
// Services stay as unknown
}
setServices(serviceList)
setLoading(false)
}
checkServices()
}, [])
const visibleCategories = currentRole
? navigation.filter(cat => isCategoryVisibleForRole(cat.id, currentRole))
: navigation
const stats = [
{ label: 'Kategorien', value: visibleCategories.length, color: 'text-primary-600' },
{ label: 'Module', value: visibleCategories.reduce((acc, cat) => acc + cat.modules.length, 0), color: 'text-blue-600' },
{ label: 'Services', value: services.filter(s => s.status === 'healthy').length, color: 'text-green-600' },
{ label: 'Warnungen', value: services.filter(s => s.status === 'unhealthy').length, color: services.some(s => s.status === 'unhealthy') ? 'text-orange-600' : 'text-slate-600' },
]
return (
<div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
{stats.map((stat) => (
<div key={stat.label} className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className={`text-3xl font-bold ${stat.color}`}>
{loading ? '-' : stat.value}
</div>
<div className="text-sm text-slate-500 mt-1">{stat.label}</div>
</div>
))}
</div>
{/* Categories */}
<h2 className="text-lg font-semibold text-slate-900 mb-4">Bereiche</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
{visibleCategories.map((category) => (
<Link
key={category.id}
href={`/${category.id}`}
className="bg-white rounded-xl border border-slate-200 p-5 hover:border-primary-300 hover:shadow-md transition-all group"
>
<div className="flex items-center gap-3 mb-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center"
style={{ backgroundColor: category.color + '20', color: category.color }}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{category.icon === 'message-circle' && (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
)}
{category.icon === 'server' && (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
)}
{category.icon === 'code' && (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
)}
</svg>
</div>
<div>
<h3 className="font-semibold text-slate-900 group-hover:text-primary-600 transition-colors">
{category.name}
</h3>
<p className="text-xs text-slate-500">{category.modules.length} Module</p>
</div>
</div>
<p className="text-sm text-slate-500">{category.description}</p>
<div className="mt-3 flex flex-wrap gap-1">
{category.modules.slice(0, 4).map((module) => (
<span
key={module.id}
className="px-2 py-0.5 rounded text-xs"
style={{ backgroundColor: category.color + '15', color: category.color }}
>
{module.name}
</span>
))}
{category.modules.length > 4 && (
<span className="px-2 py-0.5 rounded text-xs bg-slate-100 text-slate-500">
+{category.modules.length - 4}
</span>
)}
</div>
</Link>
))}
</div>
{/* Service Status */}
<h2 className="text-lg font-semibold text-slate-900 mb-4">Service Status</h2>
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 divide-x divide-slate-200">
{services.map((service) => (
<div key={service.name} className="p-4 text-center">
<div className={`inline-block w-3 h-3 rounded-full mb-2 ${
service.status === 'healthy' ? 'bg-green-500' :
service.status === 'unhealthy' ? 'bg-red-500' :
'bg-slate-300'
}`} />
<div className="text-sm font-medium text-slate-900">{service.name}</div>
<div className="text-xs text-slate-500 capitalize">{service.status}</div>
</div>
))}
</div>
</div>
{/* Quick Links */}
<h2 className="text-lg font-semibold text-slate-900 mb-4 mt-8">Schnellzugriff</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{visibleCategories.flatMap(cat => cat.modules).slice(0, 8).map((module) => (
<Link
key={module.id}
href={module.href}
className="flex items-center gap-3 p-4 bg-white rounded-xl border border-slate-200 hover:border-primary-300 hover:shadow-md transition-all"
>
<div>
<h3 className="font-medium text-slate-900">{module.name}</h3>
<p className="text-sm text-slate-500">{module.description}</p>
</div>
</Link>
))}
</div>
</div>
)
}