From 764223db6cdaa9b09f0b32a494a4d47286b7b0e0 Mon Sep 17 00:00:00 2001 From: Bilal Teke Date: Thu, 16 Apr 2026 13:56:28 +0200 Subject: [PATCH] v3.2 --- .dockerignore | 17 + .env.example | 8 + Dockerfile | 62 ++++ README.md | 93 ++++++ app/admin/page.tsx | 5 + app/components/dashboard-client.tsx | 130 +++++++- app/components/dashboard-filters.tsx | 148 +++++++++ app/components/index.ts | 2 + app/components/status-badge.tsx | 55 ++++ app/page.tsx | 7 +- config.json | 15 + docker-compose.yml | 16 + next.config.js | 4 +- package-lock.json | 12 +- package.json | 5 +- src/app/api/config/route.ts | 90 ++++++ src/app/api/status/route.ts | 50 +-- src/components/admin/AdminPage.tsx | 459 +++++++++++++++++++++++++++ src/data/services.json | 146 +++++++++ src/data/services.ts | 128 ++------ src/lib/config.ts | 60 ++++ src/lib/config/load-config.ts | 117 +++++++ src/lib/config/save-config.ts | 87 +++++ src/lib/config/schema.ts | 65 ++++ src/types/service.ts | 4 + 25 files changed, 1643 insertions(+), 142 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 app/admin/page.tsx create mode 100644 app/components/dashboard-filters.tsx create mode 100644 app/components/status-badge.tsx create mode 100644 config.json create mode 100644 docker-compose.yml create mode 100644 src/app/api/config/route.ts create mode 100644 src/components/admin/AdminPage.tsx create mode 100644 src/data/services.json create mode 100644 src/lib/config.ts create mode 100644 src/lib/config/load-config.ts create mode 100644 src/lib/config/save-config.ts create mode 100644 src/lib/config/schema.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6f0aa13 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +Dockerfile +.dockerignore +.git +.gitignore +README.md +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +.next +.vercel +*.log +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f84fb23 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Port on which the app runs +PORT=3000 + +# Hostname for the server +HOSTNAME=0.0.0.0 + +# Optional: Polling interval for status checks (in milliseconds) +# STATUS_POLL_INTERVAL=30000 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..00c31c0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,62 @@ +# Multi-stage build for Next.js standalone output +FROM node:20-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json package-lock.json* ./ +RUN \ + if [ -f package-lock.json ]; then npm ci; \ + else echo "Lockfile not found." && exit 1; \ + fi + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry during the build. +ENV NEXT_TELEMETRY_DISABLED 1 + +RUN npm run build + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV production +# Uncomment the following line in case you want to disable telemetry during runtime. +ENV NEXT_TELEMETRY_DISABLED 1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Set the correct permission for prerender cache +RUN mkdir .next +RUN chown nextjs:nodejs .next + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT 3000 +# set hostname to localhost +ENV HOSTNAME "0.0.0.0" + +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/next-config-js/output +CMD ["node", "server.js"] \ No newline at end of file diff --git a/README.md b/README.md index 987f138..27d7dd6 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,99 @@ export const services: Service[] = [ | Status | Farbe | Beschreibung | |--------|-------|-------------| | Online | 🟢 Grün | Service läuft einwandfrei | +| Warnung | 🟡 Gelb | Service läuft, aber mit Performance-Problemen | +| Offline | 🔴 Rot | Service ist nicht erreichbar | + +## Docker Deployment + +### Lokaler Start ohne Docker + +```bash +npm install +npm run dev +``` + +### Lokaler Start mit Docker + +```bash +# Build und starte mit docker-compose +docker-compose up --build + +# Oder manuell: +docker build -t homelab-dashboard . +docker run -p 3000:3000 homelab-dashboard +``` + +### Build-Befehl + +```bash +npm run build +``` + +### Run-Befehl + +```bash +npm start +``` + +### Docker Compose Nutzung + +```bash +# Start im Hintergrund +docker-compose up -d + +# Stoppen +docker-compose down + +# Logs anzeigen +docker-compose logs -f homelab-dashboard +``` + +### Unraid Volume-Mappings + +Für Unraid empfiehlt es sich, die Konfigurationsdateien als Volumes zu mounten: + +- **config.json**: `/mnt/user/appdata/homelab-dashboard/config.json` → `/app/config.json` +- **services.json**: `/mnt/user/appdata/homelab-dashboard/services.json` → `/app/src/data/services.json` + +Dadurch können Konfigurationen außerhalb des Containers verwaltet werden. + +## Konfiguration + +### Services bearbeiten + +Bearbeite `src/data/services.json` für JSON-basierte Konfiguration oder `src/data/services.ts` für TypeScript-basierte Konfiguration. + +### App-Konfiguration + +Bearbeite `config.json` im Projektroot, um Titel, Beschreibung und andere Einstellungen anzupassen: + +```json +{ + "title": "Mein Homelab Dashboard", + "subtitle": "Live-Status aller verwalteten Dienste", + "description": "Live-Monitoring-Dashboard für mein Homelab", + "servicesFile": "src/data/services.json", + "refreshInterval": 30000, + "categories": [ + "Infrastruktur", + "Smart Home", + "Medien", + "Dokumente" + ] +} +``` + +### Environment Variables + +Kopiere `.env.example` zu `.env` und passe die Werte an: + +- `PORT`: Port für den Server (Standard: 3000) +- `HOSTNAME`: Hostname binding (Standard: 0.0.0.0) + +## Ports + +- **3000**: Haupt-HTTP-Port der Anwendung | Warning | 🟡 Orange | Service hat Probleme, läuft aber | | Offline | 🔴 Rot | Service ist nicht erreichbar | diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..bd39314 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,5 @@ +import AdminPage from '@/src/components/admin/AdminPage'; + +export default function Page() { + return ; +} diff --git a/app/components/dashboard-client.tsx b/app/components/dashboard-client.tsx index 4f55318..addc5a7 100644 --- a/app/components/dashboard-client.tsx +++ b/app/components/dashboard-client.tsx @@ -1,44 +1,103 @@ 'use client'; -import { useState, useEffect } from 'react'; -import { ServiceCheckResult } from '@/src/types/service'; -import { services } from '@/src/data/services'; +import { useState, useEffect, useMemo } from 'react'; +import { ServiceStatus } from '@/src/types/service'; +import { AppConfig, Service } from '@/src/lib/config'; import { ServiceCard } from './ServiceCard'; +import { DashboardFilters } from './dashboard-filters'; -export function DashboardClient() { +interface ServiceCheckResult { + id: string; + status: 'online' | 'warning' | 'offline'; + responseTimeMs: number; + httpStatus: number | null; + checkedAt: string; +} + +interface DashboardClientProps { + initialServices: Service[]; + config: AppConfig; +} + +export function DashboardClient({ initialServices, config }: DashboardClientProps) { const [results, setResults] = useState([]); const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); - const fetchStatus = async () => { + // Filter states + const [searchQuery, setSearchQuery] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('all'); + const [selectedStatus, setSelectedStatus] = useState('all'); + + const fetchStatus = async (isManualRefresh = false) => { try { - setError(null); + if (isManualRefresh) { + setRefreshing(true); + } else { + 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); + setLastUpdated(new Date().toISOString()); } catch (err) { setError(err instanceof Error ? err.message : 'Unknown error'); } finally { setLoading(false); + setRefreshing(false); } }; useEffect(() => { fetchStatus(); - // Auto-refresh every 30 seconds - const interval = setInterval(fetchStatus, 30000); + const interval = setInterval(() => fetchStatus(), config.refreshInterval); return () => clearInterval(interval); - }, []); + }, [config.refreshInterval]); + + // Get unique categories from config + const categories = useMemo(() => { + return config.categories; + }, [config.categories]); + + // Filter services based on search, category, and status + const filteredServices = useMemo(() => { + return initialServices.filter((service) => { + const result = results.find(r => r.id === service.id); + + // Search filter (case-insensitive) + const matchesSearch = + searchQuery === '' || + service.name.toLowerCase().includes(searchQuery.toLowerCase()) || + service.description.toLowerCase().includes(searchQuery.toLowerCase()); + + // Category filter + const matchesCategory = + selectedCategory === 'all' || service.category === selectedCategory; + + // Status filter + const matchesStatus = + selectedStatus === 'all' || (result && result.status === selectedStatus); + + return matchesSearch && matchesCategory && matchesStatus; + }); + }, [initialServices, results, searchQuery, selectedCategory, selectedStatus]); const getResultForService = (serviceId: string): ServiceCheckResult | undefined => { return results.find(result => result.id === serviceId); }; + const handleManualRefresh = () => { + fetchStatus(true); + }; + return (
{/* Header */} @@ -47,18 +106,20 @@ export function DashboardClient() {

- Homelab Dashboard + {config.title}

{loading ? ( 'Lade Status...' + ) : refreshing ? ( + 'Aktualisiere...' ) : ( - 'Automatische Aktualisierung: 30s' + `Automatische Aktualisierung: ${Math.round(config.refreshInterval / 1000)}s` )}

- Live-Status aller verwalteten Dienste + {config.subtitle}

@@ -89,9 +150,25 @@ export function DashboardClient() { )} + {/* Filters */} + + {/* Services Grid */}
- {services.map((service) => ( + {filteredServices.map((service) => ( ))}
+ + {/* No results message */} + {filteredServices.length === 0 && !loading && ( +
+ + + +

+ Keine Dienste gefunden +

+

+ Versuche andere Suchbegriffe oder Filtereinstellungen. +

+
+ )} ); -} \ No newline at end of file +} diff --git a/app/components/dashboard-filters.tsx b/app/components/dashboard-filters.tsx new file mode 100644 index 0000000..84420b4 --- /dev/null +++ b/app/components/dashboard-filters.tsx @@ -0,0 +1,148 @@ +'use client'; + +import { ServiceStatus } from '@/src/types/service'; + +interface DashboardFiltersProps { + searchQuery: string; + onSearchChange: (query: string) => void; + selectedCategory: string; + onCategoryChange: (category: string) => void; + selectedStatus: ServiceStatus | 'all'; + onStatusChange: (status: ServiceStatus | 'all') => void; + categories: string[]; + onRefresh: () => void; + isRefreshing: boolean; + visibleCount: number; + totalCount: number; + lastUpdated: string | null; +} + +export function DashboardFilters({ + searchQuery, + onSearchChange, + selectedCategory, + onCategoryChange, + selectedStatus, + onStatusChange, + categories, + onRefresh, + isRefreshing, + visibleCount, + totalCount, + lastUpdated, +}: DashboardFiltersProps) { + const formatLastUpdated = (timestamp: string) => { + const date = new Date(timestamp); + return date.toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + }; + + return ( +
+ {/* Search and Refresh Row */} +
+
+ onSearchChange(e.target.value)} + className="w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" + /> +
+ + +
+ + {/* Filters Row */} +
+
+ {/* Category Filter */} +
+ Kategorie: + +
+ + {/* Status Filter */} +
+ Status: + +
+
+ + {/* Stats */} +
+ + {visibleCount} von {totalCount} Diensten + + {lastUpdated && ( + + Letzte Aktualisierung: {formatLastUpdated(lastUpdated)} + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/app/components/index.ts b/app/components/index.ts index 750bf38..0c63e6f 100644 --- a/app/components/index.ts +++ b/app/components/index.ts @@ -7,3 +7,5 @@ export { ServiceGrid } from './ServiceGrid'; export { FilterBar } from './FilterBar'; export { EmptyState } from './EmptyState'; export { DashboardClient } from './dashboard-client'; +export { DashboardFilters } from './dashboard-filters'; +export { StatusBadge } from './status-badge'; diff --git a/app/components/status-badge.tsx b/app/components/status-badge.tsx new file mode 100644 index 0000000..ef53932 --- /dev/null +++ b/app/components/status-badge.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { ServiceStatus } from '@/src/types/service'; + +interface StatusBadgeProps { + status: ServiceStatus; + size?: 'sm' | 'md'; +} + +const STATUS_CONFIG = { + online: { + bgColor: 'bg-green-50 dark:bg-green-950', + textColor: 'text-green-700 dark:text-green-300', + dotColor: 'bg-green-500', + label: 'Online', + }, + warning: { + bgColor: 'bg-amber-50 dark:bg-amber-950', + textColor: 'text-amber-700 dark:text-amber-300', + dotColor: 'bg-amber-500', + label: 'Warnung', + }, + offline: { + bgColor: 'bg-red-50 dark:bg-red-950', + textColor: 'text-red-700 dark:text-red-300', + dotColor: 'bg-red-500', + 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; + +export function StatusBadge({ status, size = 'md' }: StatusBadgeProps) { + const config = STATUS_CONFIG[status]; + const sizeClasses = size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-3 py-1 text-xs'; + + return ( +
+ + + {config.label} + +
+ ); +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index 1395f87..364d9fc 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next'; import { DashboardClient } from './components/dashboard-client'; +import { loadFullConfig } from '@/src/lib/config'; import './globals.css'; export const metadata: Metadata = { @@ -7,6 +8,8 @@ export const metadata: Metadata = { description: 'Live-Monitoring-Dashboard für dein Homelab', }; -export default function Home() { - return ; +export default async function Home() { + const { appConfig, services } = await loadFullConfig(); + + return ; } diff --git a/config.json b/config.json new file mode 100644 index 0000000..45a8df2 --- /dev/null +++ b/config.json @@ -0,0 +1,15 @@ +{ + "title": "Homelab Dashboard", + "subtitle": "Live-Status aller verwalteten Dienste", + "description": "Live-Monitoring-Dashboard für dein Homelab", + "servicesFile": "src/data/services.json", + "refreshInterval": 30000, + "categories": [ + "Infrastruktur", + "Smart Home", + "Medien", + "Dokumente" + ], + "theme": "auto", + "loggingLevel": "info" +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3a0a927 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.8' + +services: + homelab-dashboard: + build: . + container_name: homelab-dashboard + ports: + - "3000:3000" + restart: unless-stopped + volumes: + - ./config.json:/app/config.json:ro + - ./src/data/services.json:/app/src/data/services.json:ro + environment: + - NODE_ENV=production + - PORT=3000 + - HOSTNAME=0.0.0.0 \ No newline at end of file diff --git a/next.config.js b/next.config.js index 767719f..5cd8cc3 100644 --- a/next.config.js +++ b/next.config.js @@ -1,4 +1,6 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {} +const nextConfig = { + output: 'standalone', +} module.exports = nextConfig diff --git a/package-lock.json b/package-lock.json index a3b467f..55b8eb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "next": "^16.2.3", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^20.10.6", @@ -6516,6 +6517,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index bd2771a..a9fd107 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,13 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "eslint app src lib --ext .ts,.tsx" }, "dependencies": { "next": "^16.2.3", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^20.10.6", diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts new file mode 100644 index 0000000..793e53a --- /dev/null +++ b/src/app/api/config/route.ts @@ -0,0 +1,90 @@ +import { NextResponse } from 'next/server'; +import { backupConfig, loadFullConfig, saveAppConfig, saveServices } from '@/src/lib/config'; +import { appConfigSchema, servicesArraySchema } from '@/src/lib/config/schema'; +import { resolveProjectPath } from '@/src/lib/config/load-config'; +import { promises as fs } from 'fs'; +import path from 'path'; + +const ADMIN_TOKEN = process.env.ADMIN_TOKEN; +const CONFIG_PATH = path.join(process.cwd(), 'config.json'); + +function jsonError(message: string, status: number) { + return NextResponse.json({ error: message }, { status }); +} + +function getBearerToken(request: Request): string { + const authorization = request.headers.get('authorization') || ''; + return authorization.startsWith('Bearer ') ? authorization.slice(7) : ''; +} + +function isAuthorized(request: Request): boolean { + if (!ADMIN_TOKEN) { + return false; + } + + const token = getBearerToken(request); + return token.length > 0 && token === ADMIN_TOKEN; +} + +export async function GET(request: Request) { + if (!ADMIN_TOKEN) { + return jsonError('Server is not configured for admin access', 500); + } + + if (!isAuthorized(request)) { + return jsonError('Invalid or missing admin token', 401); + } + + try { + const result = await loadFullConfig(); + return NextResponse.json(result); + } catch (error) { + console.error('GET /api/config failed:', error); + return jsonError('Failed to load configuration', 500); + } +} + +export async function PUT(request: Request) { + if (!ADMIN_TOKEN) { + return jsonError('Server is not configured for admin access', 500); + } + + if (!isAuthorized(request)) { + return jsonError('Invalid or missing admin token', 401); + } + + let payload: any; + + try { + payload = await request.json(); + } catch (error) { + return jsonError('Request body must be valid JSON', 400); + } + + const appConfigResult = appConfigSchema.safeParse(payload.appConfig); + if (!appConfigResult.success) { + return jsonError(`App config validation failed: ${appConfigResult.error.message}`, 400); + } + + const servicesResult = servicesArraySchema.safeParse(payload.services); + if (!servicesResult.success) { + return jsonError(`Services validation failed: ${servicesResult.error.message}`, 400); + } + + try { + await fs.access(CONFIG_PATH); + await backupConfig(); + } catch (error) { + // ignore missing config file + } + + try { + const servicesPath = resolveProjectPath(appConfigResult.data.servicesFile); + await saveAppConfig(appConfigResult.data, CONFIG_PATH); + await saveServices(servicesResult.data, servicesPath); + return NextResponse.json({ success: true }); + } catch (error) { + console.error('PUT /api/config failed:', error); + return jsonError('Failed to save configuration', 500); + } +} diff --git a/src/app/api/status/route.ts b/src/app/api/status/route.ts index 3ab5e5f..df8c678 100644 --- a/src/app/api/status/route.ts +++ b/src/app/api/status/route.ts @@ -1,33 +1,46 @@ import { NextResponse } from 'next/server'; -import { services } from '@/src/data/services'; -import { ServiceCheckResult } from '@/src/types/service'; +import { loadFullConfig } from '@/src/lib/config'; +import type { Service } from '@/src/lib/config'; + +interface ServiceCheckResult { + id: string; + status: 'online' | 'warning' | 'offline'; + responseTimeMs: number; + httpStatus: number | null; + checkedAt: string; +} export const dynamic = 'force-dynamic'; -async function checkService(service: typeof services[0]): Promise { +async function checkService(service: Service): Promise { const startTime = Date.now(); + let timeoutId: ReturnType | undefined; try { + const defaultTimeout = 5000; + const defaultAcceptableCodes = [200]; + const timeout = service.healthcheck?.timeoutMs || defaultTimeout; + const acceptableCodes = service.healthcheck?.acceptableStatusCodes || defaultAcceptableCodes; + const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout + timeoutId = setTimeout(() => controller.abort(), timeout); const response = await fetch(service.monitorUrl, { - method: 'HEAD', // Use HEAD to minimize data transfer + method: 'HEAD', cache: 'no-store', signal: controller.signal, }); - clearTimeout(timeoutId); const responseTime = Date.now() - startTime; - let status: ServiceCheckResult['status']; + const isAcceptableStatus = acceptableCodes.includes(response.status); - if (response.ok) { + if (isAcceptableStatus) { status = responseTime <= 2000 ? 'online' : 'warning'; - } else if (response.status >= 400 && response.status < 500) { - status = 'warning'; - } else { + } else if (response.status >= 400 && response.status < 600) { status = 'offline'; + } else { + status = 'warning'; } return { @@ -38,23 +51,24 @@ async function checkService(service: typeof services[0]): Promise checkService(service)) - ); + const { services } = await loadFullConfig(); + const results = await Promise.all(services.map((service: Service) => checkService(service))); return NextResponse.json(results); } catch (error) { @@ -64,4 +78,4 @@ export async function GET() { { status: 500 } ); } -} \ No newline at end of file +} diff --git a/src/components/admin/AdminPage.tsx b/src/components/admin/AdminPage.tsx new file mode 100644 index 0000000..a659257 --- /dev/null +++ b/src/components/admin/AdminPage.tsx @@ -0,0 +1,459 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import type { AppConfig, Service } from '@/src/lib/config'; + +const defaultService: Service = { + id: '', + name: '', + description: '', + url: 'http://', + monitorUrl: 'http://', + category: '', + healthcheck: { + acceptableStatusCodes: [200], + timeoutMs: 5000, + }, +}; + +const themes = ['auto', 'light', 'dark'] as const; +const loggingLevels = ['error', 'warn', 'info', 'debug'] as const; + +export default function AdminPage() { + const [appConfig, setAppConfig] = useState(null); + const [services, setServices] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + const [adminToken, setAdminToken] = useState(''); + const [configLoaded, setConfigLoaded] = useState(false); + + const loadConfig = async () => { + if (!adminToken.trim()) { + setError('Admin-Token ist erforderlich, um die Konfiguration zu laden.'); + return; + } + + setLoading(true); + setError(null); + setMessage(null); + + try { + const response = await fetch('/api/config', { + headers: { + Authorization: `Bearer ${adminToken}`, + }, + }); + if (!response.ok) { + const result = await response.json().catch(() => null); + throw new Error(result?.error || `Failed to load config: ${response.statusText}`); + } + const data = await response.json(); + setAppConfig(data.appConfig); + setServices(data.services); + setConfigLoaded(true); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load config'); + } finally { + setLoading(false); + } + }; + + const updateAppConfig = (field: keyof AppConfig, value: string | number | string[]) => { + if (!appConfig) return; + setAppConfig({ ...appConfig, [field]: value } as AppConfig); + }; + + const updateService = (index: number, field: keyof Service, value: string | number | number[]) => { + setServices((current) => + current.map((service, idx) => { + if (idx !== index) return service; + return { + ...service, + [field]: value, + } as Service; + }) + ); + }; + + const updateServiceHealthcheck = ( + index: number, + field: 'timeoutMs' | 'acceptableStatusCodes', + value: string | number | number[] + ) => { + setServices((current) => + current.map((service, idx) => { + if (idx !== index) return service; + const healthcheck = service.healthcheck ?? { acceptableStatusCodes: [200], timeoutMs: 5000 }; + return { + ...service, + healthcheck: { + ...healthcheck, + [field]: value, + }, + }; + }) + ); + }; + + const addService = () => { + setServices((current) => [...current, defaultService]); + }; + + const removeService = (index: number) => { + setServices((current) => current.filter((_, idx) => idx !== index)); + }; + + const parseStatusCodes = (value: string): number[] => { + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => Number(item)); + }; + + const validateForm = () => { + if (!appConfig) { + setError('Konfiguration wurde nicht geladen.'); + return false; + } + + if (appConfig.refreshInterval <= 0) { + setError('Polling-Intervall muss größer als 0 sein.'); + return false; + } + + if (!adminToken) { + setError('Admin-Token ist erforderlich, um Änderungen zu speichern.'); + return false; + } + + const normalizedCategories = appConfig.categories.map((category) => category.trim()).filter(Boolean); + if (normalizedCategories.length === 0) { + setError('Mindestens eine gültige Kategorie ist erforderlich.'); + return false; + } + + if (new Set(normalizedCategories).size !== normalizedCategories.length) { + setError('Kategorien dürfen nicht doppelt vorkommen.'); + return false; + } + + const serviceIds = new Set(); + + for (const service of services) { + if (!service.id.trim() || !service.name.trim() || !service.url.trim() || !service.monitorUrl.trim() || !service.category.trim()) { + setError('Alle Dienste müssen ID, Name, URL, Monitor-URL und Kategorie enthalten.'); + return false; + } + + if (serviceIds.has(service.id.trim())) { + setError(`Die Service-ID "${service.id.trim()}" ist doppelt vergeben.`); + return false; + } + serviceIds.add(service.id.trim()); + + if (!normalizedCategories.includes(service.category.trim())) { + setError(`Die Kategorie "${service.category.trim()}" ist nicht in den globalen Kategorien enthalten.`); + return false; + } + + try { + new URL(service.url); + new URL(service.monitorUrl); + } catch { + setError('Jede Service-URL muss ein gültiges URL-Format haben.'); + return false; + } + + const acceptableCodes = service.healthcheck?.acceptableStatusCodes ?? [200]; + if (acceptableCodes.length === 0 || acceptableCodes.some((code) => !Number.isInteger(code) || code < 100 || code > 599)) { + setError('Akzeptierte Statuscodes müssen Ganzzahlen zwischen 100 und 599 sein.'); + return false; + } + + const timeoutMs = service.healthcheck?.timeoutMs ?? 5000; + if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) { + setError('Timeout muss eine positive ganze Zahl sein.'); + return false; + } + } + + return true; + }; + + const saveConfig = async () => { + setSaving(true); + setMessage(null); + setError(null); + + if (!appConfig) { + setError('Keine Konfiguration geladen.'); + setSaving(false); + return; + } + + if (!validateForm()) { + setSaving(false); + return; + } + + try { + const response = await fetch('/api/config', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify({ + appConfig: { + ...appConfig, + refreshInterval: Number(appConfig.refreshInterval), + }, + services, + }), + }); + + const result = await response.json(); + if (!response.ok) { + throw new Error(result?.error || 'Failed to save configuration'); + } + + setMessage('Konfiguration erfolgreich gespeichert.'); + await loadConfig(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Speichern fehlgeschlagen'); + } finally { + setSaving(false); + } + }; + + const serviceCodeStrings = useMemo( + () => + services.map((service) => + (service.healthcheck?.acceptableStatusCodes ?? [200]).join(', ') + ), + [services] + ); + + return ( +
+
+
+
+
+

Admin-Konfiguration

+

+ Hier kannst du globale Einstellungen und Services bearbeiten. +

+
+
+ + setAdminToken(event.target.value)} + className="w-full rounded-2xl border border-slate-300 bg-slate-50 px-4 py-2 text-sm dark:border-slate-700 dark:bg-slate-800" + placeholder="Bearer-Token eingeben" + /> + +
+
+
+ + {!configLoaded && !loading ? ( +
+ Gib dein Admin-Token ein, um die Konfiguration zu laden. +
+ ) : loading ? ( +
+ Lade Konfiguration… +
+ ) : ( + <> + {error && ( +
+ {error} +
+ )} + {message && ( +
+ {message} +
+ )} + +
+

Globale Einstellungen

+
+ + + +
+
+ +
+
+
+

Services

+

Bearbeite bestehende Dienste oder füge neue hinzu.

+
+ +
+
+ {services.map((service, index) => ( +
+
+

Dienst {index + 1}

+ +
+
+ + +