This commit is contained in:
Bilal Teke 2026-04-15 21:54:46 +02:00
parent 63c93fa7e4
commit b2d2b8b2f3
11 changed files with 258 additions and 171 deletions

View file

@ -1,13 +1,13 @@
'use client'; 'use client';
import { ServiceCategory, ServiceStatus } from '@/lib/data'; import { ServiceStatus } from '@/src/types/service';
interface FilterBarProps { interface FilterBarProps {
categories: ServiceCategory[]; categories: string[];
statuses: ServiceStatus[]; statuses: ServiceStatus[];
selectedCategories: ServiceCategory[]; selectedCategories: string[];
selectedStatuses: ServiceStatus[]; selectedStatuses: ServiceStatus[];
onCategoryChange: (category: ServiceCategory) => void; onCategoryChange: (category: string) => void;
onStatusChange: (status: ServiceStatus) => void; onStatusChange: (status: ServiceStatus) => void;
onReset: () => void; onReset: () => void;
} }

View file

@ -1,9 +1,10 @@
'use client'; 'use client';
import { Service } from '@/lib/data'; import { Service, ServiceCheckResult } from '@/src/types/service';
interface ServiceCardProps { interface ServiceCardProps {
service: Service; service: Service;
result?: ServiceCheckResult;
} }
const STATUS_CONFIG = { const STATUS_CONFIG = {
@ -25,14 +26,22 @@ const STATUS_CONFIG = {
dotColor: 'bg-red-500', dotColor: 'bg-red-500',
label: 'Offline', label: 'Offline',
}, },
unknown: {
bgColor: 'bg-slate-50 dark:bg-slate-950',
textColor: 'text-slate-700 dark:text-slate-300',
dotColor: 'bg-slate-500',
label: 'Unbekannt',
},
} as const; } as const;
/** /**
* ServiceCard - Zeigt einen einzelnen Service mit Status, Icon und Öffnen-Button * ServiceCard - Zeigt einen einzelnen Service mit Status, Icon und Öffnen-Button
* @param service - Das Service-Objekt mit Name, Beschreibung, Status, etc. * @param service - Das Service-Objekt mit Name, Beschreibung, etc.
* @param result - Optionale Status-Check-Ergebnisse
*/ */
export function ServiceCard({ service }: ServiceCardProps) { export function ServiceCard({ service, result }: ServiceCardProps) {
const config = STATUS_CONFIG[service.status]; const status = result?.status || 'unknown';
const config = STATUS_CONFIG[status];
return ( return (
<a <a
@ -43,15 +52,9 @@ export function ServiceCard({ service }: ServiceCardProps) {
> >
<div className="h-full bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-sm hover:shadow-md transition-all duration-300 overflow-hidden hover:border-slate-300 dark:hover:border-slate-600"> <div className="h-full bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-sm hover:shadow-md transition-all duration-300 overflow-hidden hover:border-slate-300 dark:hover:border-slate-600">
<div className="p-6 flex flex-col gap-4 h-full"> <div className="p-6 flex flex-col gap-4 h-full">
{/* Header mit Icon, Name, Kategorie und Status */} {/* Header mit Name, Kategorie und Status */}
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex-1 flex items-center gap-3"> <div className="flex-1">
{service.icon && (
<span className="text-2xl flex-shrink-0" aria-hidden="true">
{service.icon}
</span>
)}
<div className="min-w-0 flex-1">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors truncate"> <h3 className="text-lg font-semibold text-slate-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors truncate">
{service.name} {service.name}
</h3> </h3>
@ -59,14 +62,13 @@ export function ServiceCard({ service }: ServiceCardProps) {
{service.category} {service.category}
</p> </p>
</div> </div>
</div>
{/* Status Badge */} {/* Status Badge */}
<div <div
className={`flex items-center gap-2 px-3 py-1 rounded-full whitespace-nowrap flex-shrink-0 ${config.bgColor}`} className={`flex items-center gap-2 px-3 py-1 rounded-full whitespace-nowrap flex-shrink-0 ${config.bgColor}`}
> >
<span <span
className={`w-2 h-2 rounded-full ${config.dotColor} animate-pulse`} className={`w-2 h-2 rounded-full ${config.dotColor} ${status !== 'unknown' ? 'animate-pulse' : ''}`}
/> />
<span className={`text-xs font-medium ${config.textColor}`}> <span className={`text-xs font-medium ${config.textColor}`}>
{config.label} {config.label}
@ -74,6 +76,18 @@ export function ServiceCard({ service }: ServiceCardProps) {
</div> </div>
</div> </div>
{/* Status Details */}
{result && (
<div className="text-xs text-slate-500 dark:text-slate-400 space-y-1">
{result.httpStatus && (
<div>HTTP: {result.httpStatus}</div>
)}
{result.responseTimeMs !== null && (
<div>Zeit: {result.responseTimeMs}ms</div>
)}
</div>
)}
{/* Beschreibung */} {/* Beschreibung */}
<p className="text-sm text-slate-600 dark:text-slate-400 flex-grow line-clamp-2"> <p className="text-sm text-slate-600 dark:text-slate-400 flex-grow line-clamp-2">
{service.description} {service.description}

View file

@ -1,6 +1,6 @@
'use client'; 'use client';
import { Service } from '@/lib/data'; import { Service } from '@/src/types/service';
import { ServiceCard } from './ServiceCard'; import { ServiceCard } from './ServiceCard';
import { EmptyState } from './EmptyState'; import { EmptyState } from './EmptyState';

View file

@ -1,84 +0,0 @@
'use client';
import { useState, useMemo } from 'react';
import { Service, ServiceCategory, ServiceStatus } from '@/lib/data';
import { FilterBar } from './FilterBar';
import { ServiceGrid } from './ServiceGrid';
interface ServiceSectionProps {
services: Service[];
}
/**
* ServiceSection - Verwaltet Filterlogik und rendert FilterBar + ServiceGrid
* @param services - Array aller verfügbaren Services
*/
export function ServiceSection({ services }: ServiceSectionProps) {
const [selectedCategories, setSelectedCategories] = useState<
ServiceCategory[]
>([]);
const [selectedStatuses, setSelectedStatuses] = useState<ServiceStatus[]>([]);
// Extrahiere eindeutige Kategorien und Status aus Services
const categories = useMemo(
() => Array.from(new Set(services.map((s) => s.category))).sort(),
[services]
);
const statuses = useMemo(
() =>
(['online', 'warning', 'offline'] as ServiceStatus[]).filter((status) =>
services.some((s) => s.status === status)
),
[services]
);
// Filtere Services basierend auf ausgewählten Filtern
const filteredServices = useMemo(() => {
return services.filter((service) => {
const categoryMatch =
selectedCategories.length === 0 ||
selectedCategories.includes(service.category);
const statusMatch =
selectedStatuses.length === 0 ||
selectedStatuses.includes(service.status);
return categoryMatch && statusMatch;
});
}, [services, selectedCategories, selectedStatuses]);
const handleCategoryChange = (category: ServiceCategory) => {
setSelectedCategories((prev) =>
prev.includes(category)
? prev.filter((c) => c !== category)
: [...prev, category]
);
};
const handleStatusChange = (status: ServiceStatus) => {
setSelectedStatuses((prev) =>
prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status]
);
};
const handleReset = () => {
setSelectedCategories([]);
setSelectedStatuses([]);
};
return (
<>
<FilterBar
categories={categories}
statuses={statuses}
selectedCategories={selectedCategories}
selectedStatuses={selectedStatuses}
onCategoryChange={handleCategoryChange}
onStatusChange={handleStatusChange}
onReset={handleReset}
/>
<ServiceGrid services={filteredServices} />
</>
);
}

View file

@ -0,0 +1,105 @@
'use client';
import { useState, useEffect } from 'react';
import { ServiceCheckResult } from '@/src/types/service';
import { services } from '@/src/data/services';
import { ServiceCard } from './ServiceCard';
export function DashboardClient() {
const [results, setResults] = useState<ServiceCheckResult[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchStatus = async () => {
try {
setError(null);
const response = await fetch('/api/status');
if (!response.ok) {
throw new Error('Failed to fetch status');
}
const data: ServiceCheckResult[] = await response.json();
setResults(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStatus();
// Auto-refresh every 30 seconds
const interval = setInterval(fetchStatus, 30000);
return () => clearInterval(interval);
}, []);
const getResultForService = (serviceId: string): ServiceCheckResult | undefined => {
return results.find(result => result.id === serviceId);
};
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
{/* Header */}
<header className="bg-gradient-to-r from-slate-900 to-slate-800 border-b border-slate-700 shadow-lg">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h1 className="text-3xl sm:text-4xl font-bold text-white tracking-tight">
Homelab Dashboard
</h1>
<div className="text-sm text-slate-400">
{loading ? (
'Lade Status...'
) : (
'Automatische Aktualisierung: 30s'
)}
</div>
</div>
<p className="text-slate-400 text-sm">
Live-Status aller verwalteten Dienste
</p>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{error && (
<div className="mb-8 p-4 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-center gap-2">
<svg
className="w-5 h-5 text-red-600 dark:text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<span className="text-red-800 dark:text-red-200">
Fehler beim Laden der Status-Daten: {error}
</span>
</div>
</div>
)}
{/* Services Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{services.map((service) => (
<ServiceCard
key={service.id}
service={service}
result={getResultForService(service.id)}
/>
))}
</div>
</main>
</div>
);
}

View file

@ -4,6 +4,6 @@
export { Header } from './Header'; export { Header } from './Header';
export { ServiceCard } from './ServiceCard'; export { ServiceCard } from './ServiceCard';
export { ServiceGrid } from './ServiceGrid'; export { ServiceGrid } from './ServiceGrid';
export { ServiceSection } from './ServiceSection';
export { FilterBar } from './FilterBar'; export { FilterBar } from './FilterBar';
export { EmptyState } from './EmptyState'; export { EmptyState } from './EmptyState';
export { DashboardClient } from './dashboard-client';

View file

@ -1,4 +1,3 @@
import type { Metadata } from 'next';
import { Metadata as NextMetadata } from 'next'; import { Metadata as NextMetadata } from 'next';
export const metadata: NextMetadata = { export const metadata: NextMetadata = {

View file

@ -1,21 +1,12 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { Header, ServiceSection } from './components'; import { DashboardClient } from './components/dashboard-client';
import { services } from '@/lib/data';
import './globals.css'; import './globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Homelab Dashboard', title: 'Homelab Dashboard',
description: 'Verwaltungs-Dashboard für dein Homelab', description: 'Live-Monitoring-Dashboard für dein Homelab',
}; };
export default function Home() { export default function Home() {
return ( return <DashboardClient />;
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
<Header />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<ServiceSection services={services} />
</main>
</div>
);
} }

View file

@ -0,0 +1,67 @@
import { NextResponse } from 'next/server';
import { services } from '@/src/data/services';
import { ServiceCheckResult } from '@/src/types/service';
export const dynamic = 'force-dynamic';
async function checkService(service: typeof services[0]): Promise<ServiceCheckResult> {
const startTime = Date.now();
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
const response = await fetch(service.monitorUrl, {
method: 'HEAD', // Use HEAD to minimize data transfer
cache: 'no-store',
signal: controller.signal,
});
clearTimeout(timeoutId);
const responseTime = Date.now() - startTime;
let status: ServiceCheckResult['status'];
if (response.ok) {
status = responseTime <= 2000 ? 'online' : 'warning';
} else if (response.status >= 400 && response.status < 500) {
status = 'warning';
} else {
status = 'offline';
}
return {
id: service.id,
status,
responseTimeMs: responseTime,
httpStatus: response.status,
checkedAt: new Date().toISOString(),
};
} catch (error) {
const responseTime = Date.now() - startTime;
return {
id: service.id,
status: 'offline',
responseTimeMs: responseTime,
httpStatus: null,
checkedAt: new Date().toISOString(),
};
}
}
export async function GET() {
try {
const results = await Promise.all(
services.map(service => checkService(service))
);
return NextResponse.json(results);
} catch (error) {
console.error('Error checking services:', error);
return NextResponse.json(
{ error: 'Failed to check services' },
{ status: 500 }
);
}
}

View file

@ -1,15 +1,4 @@
export type ServiceCategory = 'Infrastruktur' | 'Smart Home' | 'Medien' | 'Dokumente'; import { Service } from '@/src/types/service';
export type ServiceStatus = 'online' | 'warning' | 'offline';
export interface Service {
id: string;
name: string;
description: string;
category: ServiceCategory;
status: ServiceStatus;
url: string;
icon?: string;
}
export const services: Service[] = [ export const services: Service[] = [
// Infrastruktur // Infrastruktur
@ -17,37 +6,33 @@ export const services: Service[] = [
id: 'unraid', id: 'unraid',
name: 'Unraid', name: 'Unraid',
description: 'NAS und Server Management Platform', description: 'NAS und Server Management Platform',
category: 'Infrastruktur',
status: 'online',
url: 'http://localhost', url: 'http://localhost',
icon: '🖥️', monitorUrl: 'http://localhost',
category: 'Infrastruktur',
}, },
{ {
id: 'unifi', id: 'unifi',
name: 'UniFi', name: 'UniFi',
description: 'Netzwerk-Management und WLAN-Controller', description: 'Netzwerk-Management und WLAN-Controller',
category: 'Infrastruktur',
status: 'online',
url: 'http://localhost:8443', url: 'http://localhost:8443',
icon: '🌐', monitorUrl: 'http://localhost:8443',
category: 'Infrastruktur',
}, },
{ {
id: 'checkmk', id: 'checkmk',
name: 'Checkmk', name: 'Checkmk',
description: 'Monitoring und Alerting System', description: 'Monitoring und Alerting System',
category: 'Infrastruktur',
status: 'online',
url: 'http://localhost/checkmk', url: 'http://localhost/checkmk',
icon: '📊', monitorUrl: 'http://localhost/checkmk',
category: 'Infrastruktur',
}, },
{ {
id: 'nginx-proxy', id: 'nginx-proxy',
name: 'Nginx Proxy Manager', name: 'Nginx Proxy Manager',
description: 'Reverse Proxy und SSL-Verwaltung', description: 'Reverse Proxy und SSL-Verwaltung',
category: 'Infrastruktur',
status: 'online',
url: 'http://localhost:81', url: 'http://localhost:81',
icon: '⚙️', monitorUrl: 'http://localhost:81',
category: 'Infrastruktur',
}, },
// Smart Home // Smart Home
@ -55,10 +40,9 @@ export const services: Service[] = [
id: 'home-assistant', id: 'home-assistant',
name: 'Home Assistant', name: 'Home Assistant',
description: 'Smart Home Automation und Integration', description: 'Smart Home Automation und Integration',
category: 'Smart Home',
status: 'offline',
url: 'http://localhost:8123', url: 'http://localhost:8123',
icon: '🏠', monitorUrl: 'http://localhost:8123',
category: 'Smart Home',
}, },
// Medien // Medien
@ -66,46 +50,41 @@ export const services: Service[] = [
id: 'plex', id: 'plex',
name: 'Plex', name: 'Plex',
description: 'Medienserver und Streaming-Dienst', description: 'Medienserver und Streaming-Dienst',
category: 'Medien',
status: 'online',
url: 'http://localhost:32400', url: 'http://localhost:32400',
icon: '🎬', monitorUrl: 'http://localhost:32400',
category: 'Medien',
}, },
{ {
id: 'jellyfin', id: 'jellyfin',
name: 'Jellyfin', name: 'Jellyfin',
description: 'Open-Source Medienserver', description: 'Open-Source Medienserver',
category: 'Medien',
status: 'online',
url: 'http://localhost:8096', url: 'http://localhost:8096',
icon: '📺', monitorUrl: 'http://localhost:8096',
category: 'Medien',
}, },
{ {
id: 'radarr', id: 'radarr',
name: 'Radarr', name: 'Radarr',
description: 'Automatisches Filmmanagement', description: 'Automatisches Filmmanagement',
category: 'Medien',
status: 'online',
url: 'http://localhost:7878', url: 'http://localhost:7878',
icon: '🎥', monitorUrl: 'http://localhost:7878',
category: 'Medien',
}, },
{ {
id: 'sonarr', id: 'sonarr',
name: 'Sonarr', name: 'Sonarr',
description: 'Automatisches Serienmanagement', description: 'Automatisches Serienmanagement',
category: 'Medien',
status: 'online',
url: 'http://localhost:8989', url: 'http://localhost:8989',
icon: '📺', monitorUrl: 'http://localhost:8989',
category: 'Medien',
}, },
{ {
id: 'prowlarr', id: 'prowlarr',
name: 'Prowlarr', name: 'Prowlarr',
description: 'Indexer und Proxy Manager', description: 'Indexer und Proxy Manager',
category: 'Medien',
status: 'warning',
url: 'http://localhost:9696', url: 'http://localhost:9696',
icon: '🔍', monitorUrl: 'http://localhost:9696',
category: 'Medien',
}, },
// Dokumente // Dokumente
@ -113,18 +92,16 @@ export const services: Service[] = [
id: 'seafile', id: 'seafile',
name: 'Seafile', name: 'Seafile',
description: 'Dateifreigabe und Cloud Storage', description: 'Dateifreigabe und Cloud Storage',
category: 'Dokumente',
status: 'online',
url: 'http://localhost:8000', url: 'http://localhost:8000',
icon: '☁️', monitorUrl: 'http://localhost:8000',
category: 'Dokumente',
}, },
{ {
id: 'forgejo', id: 'forgejo',
name: 'Forgejo', name: 'Forgejo',
description: 'Self-hosted Git und Code Repository', description: 'Self-hosted Git und Code Repository',
category: 'Dokumente',
status: 'online',
url: 'http://localhost:3000', url: 'http://localhost:3000',
icon: '💾', monitorUrl: 'http://localhost:3000',
category: 'Dokumente',
}, },
]; ];

18
src/types/service.ts Normal file
View file

@ -0,0 +1,18 @@
export type ServiceStatus = 'online' | 'warning' | 'offline' | 'unknown';
export interface Service {
id: string;
name: string;
description: string;
url: string;
monitorUrl: string;
category: string;
}
export interface ServiceCheckResult {
id: string;
status: ServiceStatus;
responseTimeMs: number | null;
httpStatus: number | null;
checkedAt: string;
}