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" + /> + + + + {isRefreshing ? ( + <> + + + + + Aktualisiere... + > + ) : ( + <> + + + + Jetzt aktualisieren + > + )} + + + + {/* Filters Row */} + + + {/* Category Filter */} + + Kategorie: + onCategoryChange(e.target.value)} + className="px-3 py-1 border border-slate-300 dark:border-slate-600 rounded-md bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + > + Alle + {categories.map((category) => ( + + {category} + + ))} + + + + {/* Status Filter */} + + Status: + onStatusChange(e.target.value as ServiceStatus | 'all')} + className="px-3 py-1 border border-slate-300 dark:border-slate-600 rounded-md bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + > + Alle + Online + Warnung + Offline + Unbekannt + + + + + {/* 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. + + + + Admin-Token + 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" + /> + void loadConfig()} + disabled={loading} + className="w-full rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700" + > + {loading ? 'Lade…' : configLoaded ? 'Neu laden' : 'Konfiguration laden'} + + + + + + {!configLoaded && !loading ? ( + + Gib dein Admin-Token ein, um die Konfiguration zu laden. + + ) : loading ? ( + + Lade Konfiguration… + + ) : ( + <> + {error && ( + + {error} + + )} + {message && ( + + {message} + + )} + + + Globale Einstellungen + + + Polling-Intervall (ms) + updateAppConfig('refreshInterval', Number(event.target.value))} + className="mt-2 w-full rounded-2xl border border-slate-300 bg-slate-50 px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-800" + /> + + + Theme + updateAppConfig('theme', event.target.value)} + className="mt-2 w-full rounded-2xl border border-slate-300 bg-slate-50 px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-800" + > + {themes.map((theme) => ( + {theme} + ))} + + + + Logging-Level + updateAppConfig('loggingLevel', event.target.value)} + className="mt-2 w-full rounded-2xl border border-slate-300 bg-slate-50 px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-800" + > + {loggingLevels.map((level) => ( + {level} + ))} + + + + + + + + + Services + Bearbeite bestehende Dienste oder füge neue hinzu. + + + Dienst hinzufügen + + + + {services.map((service, index) => ( + + + Dienst {index + 1} + removeService(index)} + className="rounded-2xl border border-rose-300 bg-rose-50 px-3 py-1.5 text-sm text-rose-700 transition hover:bg-rose-100 dark:border-rose-600 dark:bg-rose-950/40 dark:text-rose-200" + > + Entfernen + + + + + ID + updateService(index, 'id', event.target.value)} + className="mt-2 w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-900" + /> + + + Name + updateService(index, 'name', event.target.value)} + className="mt-2 w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-900" + /> + + + Beschreibung + updateService(index, 'description', event.target.value)} + rows={3} + className="mt-2 w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm outline-none dark:border-slate-700 dark:bg-slate-900" + /> + + + URL + updateService(index, 'url', event.target.value)} + className="mt-2 w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-900" + /> + + + Monitor-URL + updateService(index, 'monitorUrl', event.target.value)} + className="mt-2 w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-900" + /> + + + Kategorie + updateService(index, 'category', event.target.value)} + className="mt-2 w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-900" + /> + + + Timeout (ms) + updateServiceHealthcheck(index, 'timeoutMs', Number(event.target.value))} + className="mt-2 w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-900" + /> + + + Akzeptierte Statuscodes + updateServiceHealthcheck(index, 'acceptableStatusCodes', parseStatusCodes(event.target.value))} + className="mt-2 w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-900" + placeholder="200, 302" + /> + + + + ))} + + + + + + Änderungen werden auf die lokale Konfigurationsdatei geschrieben. + + + {saving ? 'Speichere…' : 'Speichern'} + + + > + )} + + + ); +} diff --git a/src/data/services.json b/src/data/services.json new file mode 100644 index 0000000..83acada --- /dev/null +++ b/src/data/services.json @@ -0,0 +1,146 @@ +[ + { + "id": "unraid", + "name": "Unraid", + "description": "NAS und Server Management Platform", + "url": "http://localhost", + "monitorUrl": "http://localhost", + "category": "Infrastruktur", + "healthcheck": { + "acceptableStatusCodes": [200, 302], + "timeoutMs": 10000 + } + }, + { + "id": "unifi", + "name": "UniFi", + "description": "Netzwerk-Management und WLAN-Controller", + "url": "http://localhost:8443", + "monitorUrl": "https://localhost:8443", + "category": "Infrastruktur", + "healthcheck": { + "acceptableStatusCodes": [200, 302, 403], + "timeoutMs": 8000 + } + }, + { + "id": "checkmk", + "name": "Checkmk", + "description": "Monitoring und Alerting System", + "url": "http://localhost/checkmk", + "monitorUrl": "http://localhost/checkmk", + "category": "Infrastruktur", + "healthcheck": { + "acceptableStatusCodes": [200, 302], + "timeoutMs": 5000 + } + }, + { + "id": "nginx-proxy", + "name": "Nginx Proxy Manager", + "description": "Reverse Proxy und SSL-Verwaltung", + "url": "http://localhost:81", + "monitorUrl": "http://localhost:81", + "category": "Infrastruktur", + "healthcheck": { + "acceptableStatusCodes": [200, 302], + "timeoutMs": 5000 + } + }, + { + "id": "home-assistant", + "name": "Home Assistant", + "description": "Smart Home Automation und Integration", + "url": "http://localhost:8123", + "monitorUrl": "http://localhost:8123", + "category": "Smart Home", + "healthcheck": { + "acceptableStatusCodes": [200, 302], + "timeoutMs": 5000 + } + }, + { + "id": "plex", + "name": "Plex", + "description": "Medienserver und Streaming-Dienst", + "url": "http://localhost:32400", + "monitorUrl": "http://localhost:32400/web", + "category": "Medien", + "healthcheck": { + "acceptableStatusCodes": [200, 302, 401], + "timeoutMs": 8000 + } + }, + { + "id": "jellyfin", + "name": "Jellyfin", + "description": "Open-Source Medienserver", + "url": "http://localhost:8096", + "monitorUrl": "http://localhost:8096", + "category": "Medien", + "healthcheck": { + "acceptableStatusCodes": [200, 302], + "timeoutMs": 5000 + } + }, + { + "id": "radarr", + "name": "Radarr", + "description": "Automatisches Filmmanagement", + "url": "http://localhost:7878", + "monitorUrl": "http://localhost:7878", + "category": "Medien", + "healthcheck": { + "acceptableStatusCodes": [200, 302], + "timeoutMs": 5000 + } + }, + { + "id": "sonarr", + "name": "Sonarr", + "description": "Automatisches Serienmanagement", + "url": "http://localhost:8989", + "monitorUrl": "http://localhost:8989", + "category": "Medien", + "healthcheck": { + "acceptableStatusCodes": [200, 302], + "timeoutMs": 5000 + } + }, + { + "id": "prowlarr", + "name": "Prowlarr", + "description": "Indexer und Proxy Manager", + "url": "http://localhost:9696", + "monitorUrl": "http://localhost:9696", + "category": "Medien", + "healthcheck": { + "acceptableStatusCodes": [200, 302], + "timeoutMs": 5000 + } + }, + { + "id": "seafile", + "name": "Seafile", + "description": "Dateifreigabe und Cloud Storage", + "url": "http://localhost:8000", + "monitorUrl": "http://localhost:8000", + "category": "Dokumente", + "healthcheck": { + "acceptableStatusCodes": [200, 302], + "timeoutMs": 5000 + } + }, + { + "id": "forgejo", + "name": "Forgejo", + "description": "Self-hosted Git und Code Repository", + "url": "http://localhost:3000", + "monitorUrl": "http://localhost:3000", + "category": "Dokumente", + "healthcheck": { + "acceptableStatusCodes": [200, 302], + "timeoutMs": 5000 + } + } +] \ No newline at end of file diff --git a/src/data/services.ts b/src/data/services.ts index e486742..d90769a 100644 --- a/src/data/services.ts +++ b/src/data/services.ts @@ -1,107 +1,27 @@ -import { Service } from '@/src/types/service'; +import 'server-only'; +import { config } from '@/src/lib/config'; +import { servicesArraySchema } from '@/src/lib/config/schema'; +import { resolveProjectPath } from '@/src/lib/config/load-config'; +import type { Service } from '@/src/lib/config'; -export const services: Service[] = [ - // Infrastruktur - { - id: 'unraid', - name: 'Unraid', - description: 'NAS und Server Management Platform', - url: 'http://localhost', - monitorUrl: 'http://localhost', - category: 'Infrastruktur', - }, - { - id: 'unifi', - name: 'UniFi', - description: 'Netzwerk-Management und WLAN-Controller', - url: 'http://localhost:8443', - monitorUrl: 'http://localhost:8443', - category: 'Infrastruktur', - }, - { - id: 'checkmk', - name: 'Checkmk', - description: 'Monitoring und Alerting System', - url: 'http://localhost/checkmk', - monitorUrl: 'http://localhost/checkmk', - category: 'Infrastruktur', - }, - { - id: 'nginx-proxy', - name: 'Nginx Proxy Manager', - description: 'Reverse Proxy und SSL-Verwaltung', - url: 'http://localhost:81', - monitorUrl: 'http://localhost:81', - category: 'Infrastruktur', - }, +// Services werden einmal beim Import geladen +// Note: This uses synchronous loading for backward compatibility +// Future versions should use the async loadServices function +let cachedServices: Service[] | null = null; - // Smart Home - { - id: 'home-assistant', - name: 'Home Assistant', - description: 'Smart Home Automation und Integration', - url: 'http://localhost:8123', - monitorUrl: 'http://localhost:8123', - category: 'Smart Home', - }, +function loadServicesSync(): Service[] { + if (!cachedServices) { + try { + const fs = require('fs'); + const servicesPath = resolveProjectPath(config.servicesFile); + const servicesData = fs.readFileSync(servicesPath, 'utf8'); + cachedServices = servicesArraySchema.parse(JSON.parse(servicesData)); + } catch (error) { + console.warn('Failed to load services synchronously, using empty array'); + cachedServices = []; + } + } + return cachedServices; +} - // Medien - { - id: 'plex', - name: 'Plex', - description: 'Medienserver und Streaming-Dienst', - url: 'http://localhost:32400', - monitorUrl: 'http://localhost:32400', - category: 'Medien', - }, - { - id: 'jellyfin', - name: 'Jellyfin', - description: 'Open-Source Medienserver', - url: 'http://localhost:8096', - monitorUrl: 'http://localhost:8096', - category: 'Medien', - }, - { - id: 'radarr', - name: 'Radarr', - description: 'Automatisches Filmmanagement', - url: 'http://localhost:7878', - monitorUrl: 'http://localhost:7878', - category: 'Medien', - }, - { - id: 'sonarr', - name: 'Sonarr', - description: 'Automatisches Serienmanagement', - url: 'http://localhost:8989', - monitorUrl: 'http://localhost:8989', - category: 'Medien', - }, - { - id: 'prowlarr', - name: 'Prowlarr', - description: 'Indexer und Proxy Manager', - url: 'http://localhost:9696', - monitorUrl: 'http://localhost:9696', - category: 'Medien', - }, - - // Dokumente - { - id: 'seafile', - name: 'Seafile', - description: 'Dateifreigabe und Cloud Storage', - url: 'http://localhost:8000', - monitorUrl: 'http://localhost:8000', - category: 'Dokumente', - }, - { - id: 'forgejo', - name: 'Forgejo', - description: 'Self-hosted Git und Code Repository', - url: 'http://localhost:3000', - monitorUrl: 'http://localhost:3000', - category: 'Dokumente', - }, -]; +export const services: Service[] = loadServicesSync(); diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..ce92dc9 --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,60 @@ +import 'server-only'; +import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './config/schema'; +import { resolveProjectPath } from './config/load-config'; + +// Legacy export for backward compatibility +export type { AppConfig }; + +// Load config synchronously for backward compatibility +let cachedConfig: AppConfig | null = null; +let cachedServices: Services | null = null; + +function loadConfigSync(): AppConfig { + if (!cachedConfig) { + try { + const fs = require('fs'); + const path = require('path'); + const configPath = path.join(process.cwd(), 'config.json'); + const configData = fs.readFileSync(configPath, 'utf8'); + cachedConfig = appConfigSchema.parse(JSON.parse(configData)); + } catch (error) { + console.warn('Failed to load config synchronously, using defaults'); + cachedConfig = { + 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', + }; + } + } + return cachedConfig; +} + +function loadServicesSync(): Services { + if (!cachedServices) { + try { + const fs = require('fs'); + const config = loadConfigSync(); + const servicesPath = resolveProjectPath(config.servicesFile); + const servicesData = fs.readFileSync(servicesPath, 'utf8'); + cachedServices = servicesArraySchema.parse(JSON.parse(servicesData)); + } catch (error) { + console.warn('Failed to load services synchronously, using empty array'); + cachedServices = []; + } + } + return cachedServices; +} + +// Export cached versions for backward compatibility +export const config: AppConfig = loadConfigSync(); +export const services: Services = loadServicesSync(); + +// Export new async functions +export { loadAppConfig, loadServices, loadFullConfig } from './config/load-config'; +export { saveAppConfig, saveServices, backupConfig } from './config/save-config'; +export type { Service, Healthcheck } from './config/schema'; diff --git a/src/lib/config/load-config.ts b/src/lib/config/load-config.ts new file mode 100644 index 0000000..9f0973d --- /dev/null +++ b/src/lib/config/load-config.ts @@ -0,0 +1,117 @@ +import 'server-only'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './schema'; + +// Default configurations +const defaultAppConfig: AppConfig = { + 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', +}; + +const defaultServices: Services = []; + +function resolveProjectPath(relativePath: string): string { + const normalizedPath = path.normalize(relativePath); + const resolvedPath = path.resolve(process.cwd(), normalizedPath); + const relativeToRoot = path.relative(process.cwd(), resolvedPath); + + if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) { + throw new Error(`Path must stay within the project directory: ${relativePath}`); + } + + return resolvedPath; +} + +/** + * Lädt und validiert die App-Konfiguration + */ +export async function loadAppConfig(configPath?: string): Promise { + const filePath = configPath || path.join(process.cwd(), 'config.json'); + + try { + const configData = await fs.readFile(filePath, 'utf8'); + const rawConfig = JSON.parse(configData); + + // Validate with zod + const validatedConfig = appConfigSchema.parse(rawConfig); + + return validatedConfig; + } catch (error) { + console.warn(`Failed to load app config from ${filePath}:`, error); + + // Return defaults if file doesn't exist or is invalid + if (error instanceof Error && error.message.includes('ENOENT')) { + console.info('Using default app configuration'); + return defaultAppConfig; + } + + // For validation errors, try to merge with defaults + if (error instanceof Error && error.name === 'ZodError') { + console.warn('Configuration validation failed, using defaults where possible'); + return defaultAppConfig; + } + + throw error; + } +} + +/** + * Lädt und validiert die Services-Konfiguration + */ +export async function loadServices(servicesPath?: string): Promise { + const filePath = servicesPath || resolveProjectPath('src/data/services.json'); + + try { + const servicesData = await fs.readFile(filePath, 'utf8'); + const rawServices = JSON.parse(servicesData); + + // Validate with zod + const validatedServices = servicesArraySchema.parse(rawServices); + + return validatedServices; + } catch (error) { + console.warn(`Failed to load services from ${filePath}:`, error); + + // Return empty array if file doesn't exist + if (error instanceof Error && error.message.includes('ENOENT')) { + console.info('No services configuration found, using empty array'); + return defaultServices; + } + + // For validation errors, return empty array + if (error instanceof Error && error.name === 'ZodError') { + console.warn('Services validation failed, using empty array'); + return defaultServices; + } + + throw error; + } +} + +/** + * Lädt die vollständige Konfiguration (App + Services) + */ +export async function loadFullConfig(configPath?: string): Promise<{ + appConfig: AppConfig; + services: Services; +}> { + const appConfig = await loadAppConfig(configPath); + + const servicesPath = resolveProjectPath(appConfig.servicesFile); + + const services = await loadServices(servicesPath); + + return { + appConfig, + services, + }; +} + +export { resolveProjectPath }; diff --git a/src/lib/config/save-config.ts b/src/lib/config/save-config.ts new file mode 100644 index 0000000..2f52570 --- /dev/null +++ b/src/lib/config/save-config.ts @@ -0,0 +1,87 @@ +import 'server-only'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './schema'; +import { resolveProjectPath } from './load-config'; + +/** + * Speichert die App-Konfiguration + */ +export async function saveAppConfig(config: AppConfig, configPath?: string): Promise { + const filePath = configPath || path.join(process.cwd(), 'config.json'); + + try { + // Validate before saving + appConfigSchema.parse(config); + + // Ensure directory exists + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); + + // Write with pretty formatting + const configData = JSON.stringify(config, null, 2); + await fs.writeFile(filePath, configData, 'utf8'); + + } catch (error) { + console.error(`Failed to save app config to ${filePath}:`, error); + throw new Error(`Failed to save configuration: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Speichert die Services-Konfiguration + */ +export async function saveServices(services: Services, servicesPath?: string): Promise { + const filePath = servicesPath || resolveProjectPath('src/data/services.json'); + + try { + // Validate before saving + servicesArraySchema.parse(services); + + // Ensure directory exists + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); + + // Write with pretty formatting + const servicesData = JSON.stringify(services, null, 2); + await fs.writeFile(filePath, servicesData, 'utf8'); + + } catch (error) { + console.error(`Failed to save services to ${filePath}:`, error); + throw new Error(`Failed to save services: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * Erstellt ein Backup der aktuellen Konfiguration + */ +export async function backupConfig(backupDir?: string): Promise { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupPath = backupDir || path.join(process.cwd(), 'backups'); + const backupFile = path.join(backupPath, `config-backup-${timestamp}.json`); + + try { + // Load current config + const { loadFullConfig } = await import('./load-config'); + const { appConfig, services } = await loadFullConfig(); + + // Create backup data + const backupData = { + timestamp, + appConfig, + services, + }; + + // Ensure directory exists + await fs.mkdir(backupPath, { recursive: true }); + + // Write backup + const backupJson = JSON.stringify(backupData, null, 2); + await fs.writeFile(backupFile, backupJson, 'utf8'); + + return backupFile; + } catch (error) { + console.error('Failed to create backup:', error); + throw new Error(`Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} diff --git a/src/lib/config/schema.ts b/src/lib/config/schema.ts new file mode 100644 index 0000000..1e7f4b2 --- /dev/null +++ b/src/lib/config/schema.ts @@ -0,0 +1,65 @@ +import { z } from 'zod'; + +const safeRelativePathSchema = z + .string() + .min(1) + .max(200) + .refine((value) => !value.startsWith('/') && !value.startsWith('\\'), { + message: 'Path must be relative to the project root', + }) + .refine((value) => !value.split(/[\\/]+/).includes('..'), { + message: 'Path must not contain parent directory segments', + }); + +// Healthcheck Schema +export const healthcheckSchema = z.object({ + acceptableStatusCodes: z.array(z.number().int().min(100).max(599)).min(1).default([200]), + timeoutMs: z.number().int().positive().default(5000), +}).strict(); + +// Service Schema +export const serviceSchema = z.object({ + id: z.string().min(1).max(50), + name: z.string().min(1).max(100), + description: z.string().min(1).max(500), + url: z.string().url(), + monitorUrl: z.string().url(), + category: z.string().min(1).max(50), + healthcheck: healthcheckSchema.optional(), +}).strict(); + +// App Config Schema +export const appConfigSchema = z.object({ + title: z.string().min(1).max(100), + subtitle: z.string().min(1).max(200), + description: z.string().min(1).max(500), + servicesFile: safeRelativePathSchema, + refreshInterval: z.number().int().min(1000).max(300000).default(30000), // 1s to 5min + categories: z.array(z.string().min(1).max(50)).min(1), + theme: z.enum(['light', 'dark', 'auto']).default('auto'), + loggingLevel: z.enum(['error', 'warn', 'info', 'debug']).default('info'), +}).strict(); + +// Services Array Schema +export const servicesArraySchema = z.array(serviceSchema).superRefine((services, ctx) => { + const ids = new Set(); + + services.forEach((service, index) => { + if (ids.has(service.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate service id: ${service.id}`, + path: [index, 'id'], + }); + return; + } + + ids.add(service.id); + }); +}); + +// Type exports +export type Service = z.infer; +export type Healthcheck = z.infer; +export type AppConfig = z.infer; +export type Services = z.infer; diff --git a/src/types/service.ts b/src/types/service.ts index f04e2d1..97b4f83 100644 --- a/src/types/service.ts +++ b/src/types/service.ts @@ -7,6 +7,10 @@ export interface Service { url: string; monitorUrl: string; category: string; + healthcheck?: { + acceptableStatusCodes?: number[]; + timeoutMs?: number; + }; } export interface ServiceCheckResult {
- Live-Status aller verwalteten Dienste + {config.subtitle}
+ Versuche andere Suchbegriffe oder Filtereinstellungen. +
+ Hier kannst du globale Einstellungen und Services bearbeiten. +
Bearbeite bestehende Dienste oder füge neue hinzu.