This commit is contained in:
Bilal Teke 2026-04-16 13:56:28 +02:00
parent b2d2b8b2f3
commit 764223db6c
25 changed files with 1643 additions and 142 deletions

17
.dockerignore Normal file
View file

@ -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*

8
.env.example Normal file
View file

@ -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

62
Dockerfile Normal file
View file

@ -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"]

View file

@ -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 |

5
app/admin/page.tsx Normal file
View file

@ -0,0 +1,5 @@
import AdminPage from '@/src/components/admin/AdminPage';
export default function Page() {
return <AdminPage />;
}

View file

@ -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<ServiceCheckResult[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
const fetchStatus = async () => {
// Filter states
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState('all');
const [selectedStatus, setSelectedStatus] = useState<ServiceStatus | 'all'>('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 (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
{/* Header */}
@ -47,18 +106,20 @@ export function DashboardClient() {
<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
{config.title}
</h1>
<div className="text-sm text-slate-400">
{loading ? (
'Lade Status...'
) : refreshing ? (
'Aktualisiere...'
) : (
'Automatische Aktualisierung: 30s'
`Automatische Aktualisierung: ${Math.round(config.refreshInterval / 1000)}s`
)}
</div>
</div>
<p className="text-slate-400 text-sm">
Live-Status aller verwalteten Dienste
{config.subtitle}
</p>
</div>
</div>
@ -89,9 +150,25 @@ export function DashboardClient() {
</div>
)}
{/* Filters */}
<DashboardFilters
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
selectedCategory={selectedCategory}
onCategoryChange={setSelectedCategory}
selectedStatus={selectedStatus}
onStatusChange={setSelectedStatus}
categories={categories}
onRefresh={handleManualRefresh}
isRefreshing={refreshing}
visibleCount={filteredServices.length}
totalCount={initialServices.length}
lastUpdated={lastUpdated}
/>
{/* 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) => (
{filteredServices.map((service) => (
<ServiceCard
key={service.id}
service={service}
@ -99,6 +176,31 @@ export function DashboardClient() {
/>
))}
</div>
{/* No results message */}
{filteredServices.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center py-12">
<svg
className="w-16 h-16 text-slate-400 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<h3 className="text-xl font-semibold text-slate-900 dark:text-white">
Keine Dienste gefunden
</h3>
<p className="text-slate-600 dark:text-slate-400 mt-2 text-center">
Versuche andere Suchbegriffe oder Filtereinstellungen.
</p>
</div>
)}
</main>
</div>
);

View file

@ -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 (
<div className="mb-8 space-y-4">
{/* Search and Refresh Row */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div className="flex-1 max-w-md">
<input
type="text"
placeholder="Suche nach Name oder Beschreibung..."
value={searchQuery}
onChange={(e) => 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"
/>
</div>
<button
onClick={onRefresh}
disabled={isRefreshing}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-lg transition-colors duration-200"
>
{isRefreshing ? (
<>
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Aktualisiere...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Jetzt aktualisieren
</>
)}
</button>
</div>
{/* Filters Row */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div className="flex flex-wrap gap-2">
{/* Category Filter */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Kategorie:</span>
<select
value={selectedCategory}
onChange={(e) => 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"
>
<option value="all">Alle</option>
{categories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
{/* Status Filter */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Status:</span>
<select
value={selectedStatus}
onChange={(e) => 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"
>
<option value="all">Alle</option>
<option value="online">Online</option>
<option value="warning">Warnung</option>
<option value="offline">Offline</option>
<option value="unknown">Unbekannt</option>
</select>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-sm text-slate-600 dark:text-slate-400">
<span>
{visibleCount} von {totalCount} Diensten
</span>
{lastUpdated && (
<span>
Letzte Aktualisierung: {formatLastUpdated(lastUpdated)}
</span>
)}
</div>
</div>
</div>
);
}

View file

@ -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';

View file

@ -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 (
<div
className={`inline-flex items-center gap-2 rounded-full ${sizeClasses} ${config.bgColor}`}
>
<span
className={`w-2 h-2 rounded-full ${config.dotColor} ${
status !== 'unknown' ? 'animate-pulse' : ''
}`}
/>
<span className={`font-medium ${config.textColor}`}>
{config.label}
</span>
</div>
);
}

View file

@ -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 <DashboardClient />;
export default async function Home() {
const { appConfig, services } = await loadFullConfig();
return <DashboardClient initialServices={services} config={appConfig} />;
}

15
config.json Normal file
View file

@ -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"
}

16
docker-compose.yml Normal file
View file

@ -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

View file

@ -1,4 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
const nextConfig = {
output: 'standalone',
}
module.exports = nextConfig

12
package-lock.json generated
View file

@ -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"
}
}
}
}

View file

@ -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",

View file

@ -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);
}
}

View file

@ -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<ServiceCheckResult> {
async function checkService(service: Service): Promise<ServiceCheckResult> {
const startTime = Date.now();
let timeoutId: ReturnType<typeof setTimeout> | 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<ServiceCheckRe
checkedAt: new Date().toISOString(),
};
} catch (error) {
const responseTime = Date.now() - startTime;
return {
id: service.id,
status: 'offline',
responseTimeMs: responseTime,
responseTimeMs: Date.now() - startTime,
httpStatus: null,
checkedAt: new Date().toISOString(),
};
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
export async function GET() {
try {
const results = await Promise.all(
services.map(service => checkService(service))
);
const { services } = await loadFullConfig();
const results = await Promise.all(services.map((service: Service) => checkService(service)));
return NextResponse.json(results);
} catch (error) {

View file

@ -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<AppConfig | null>(null);
const [services, setServices] = useState<Service[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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<string>();
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 (
<div className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
<div className="max-w-8xl mx-auto px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8 rounded-3xl border border-slate-200 bg-white/90 p-6 shadow-xl shadow-slate-200/50 dark:border-slate-700 dark:bg-slate-900/90 dark:shadow-slate-950/20">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-3xl font-semibold">Admin-Konfiguration</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Hier kannst du globale Einstellungen und Services bearbeiten.
</p>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">Admin-Token</label>
<input
type="password"
value={adminToken}
onChange={(event) => 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"
/>
<button
type="button"
onClick={() => 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'}
</button>
</div>
</div>
</div>
{!configLoaded && !loading ? (
<div className="rounded-3xl border border-slate-200 bg-white/90 p-8 text-center text-slate-500 shadow-xl dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-300">
Gib dein Admin-Token ein, um die Konfiguration zu laden.
</div>
) : loading ? (
<div className="rounded-3xl border border-slate-200 bg-white/90 p-8 text-center text-slate-500 shadow-xl dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-300">
Lade Konfiguration
</div>
) : (
<>
{error && (
<div className="mb-6 rounded-3xl border border-rose-200 bg-rose-50 px-5 py-4 text-sm text-rose-800 shadow-sm dark:border-rose-700/40 dark:bg-rose-950/30 dark:text-rose-200">
{error}
</div>
)}
{message && (
<div className="mb-6 rounded-3xl border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-900 shadow-sm dark:border-emerald-700/40 dark:bg-emerald-950/30 dark:text-emerald-200">
{message}
</div>
)}
<section className="mb-8 rounded-3xl border border-slate-200 bg-white/90 p-6 shadow-xl shadow-slate-200/50 dark:border-slate-700 dark:bg-slate-900/90">
<h2 className="mb-4 text-xl font-semibold">Globale Einstellungen</h2>
<div className="grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Polling-Intervall (ms)</span>
<input
type="number"
min={1000}
value={appConfig?.refreshInterval ?? 30000}
onChange={(event) => 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"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Theme</span>
<select
value={appConfig?.theme ?? 'auto'}
onChange={(event) => 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) => (
<option key={theme} value={theme}>{theme}</option>
))}
</select>
</label>
<label className="block sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Logging-Level</span>
<select
value={appConfig?.loggingLevel ?? 'info'}
onChange={(event) => 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) => (
<option key={level} value={level}>{level}</option>
))}
</select>
</label>
</div>
</section>
<section className="mb-8 rounded-3xl border border-slate-200 bg-white/90 p-6 shadow-xl shadow-slate-200/50 dark:border-slate-700 dark:bg-slate-900/90">
<div className="mb-5 flex items-center justify-between gap-4">
<div>
<h2 className="text-xl font-semibold">Services</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">Bearbeite bestehende Dienste oder füge neue hinzu.</p>
</div>
<button
type="button"
onClick={addService}
className="rounded-2xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700"
>
Dienst hinzufügen
</button>
</div>
<div className="space-y-6">
{services.map((service, index) => (
<div key={`${service.id}-${index}`} className="rounded-3xl border border-slate-200 bg-slate-50 p-5 dark:border-slate-700 dark:bg-slate-950">
<div className="mb-4 flex items-center justify-between gap-4">
<h3 className="text-lg font-semibold">Dienst {index + 1}</h3>
<button
type="button"
onClick={() => 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
</button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">ID</span>
<input
type="text"
value={service.id}
onChange={(event) => 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"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Name</span>
<input
type="text"
value={service.name}
onChange={(event) => 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"
/>
</label>
<label className="block sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Beschreibung</span>
<textarea
value={service.description}
onChange={(event) => 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"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">URL</span>
<input
type="text"
value={service.url}
onChange={(event) => 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"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Monitor-URL</span>
<input
type="text"
value={service.monitorUrl}
onChange={(event) => 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"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Kategorie</span>
<input
type="text"
value={service.category}
onChange={(event) => 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"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Timeout (ms)</span>
<input
type="number"
min={100}
value={service.healthcheck?.timeoutMs ?? 5000}
onChange={(event) => 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"
/>
</label>
<label className="block sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Akzeptierte Statuscodes</span>
<input
type="text"
value={serviceCodeStrings[index]}
onChange={(event) => 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"
/>
</label>
</div>
</div>
))}
</div>
</section>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-slate-500 dark:text-slate-400">
Änderungen werden auf die lokale Konfigurationsdatei geschrieben.
</div>
<button
type="button"
onClick={saveConfig}
disabled={saving}
className="inline-flex items-center justify-center rounded-2xl bg-slate-900 px-6 py-3 text-sm font-semibold text-white transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:bg-slate-500"
>
{saving ? 'Speichere…' : 'Speichern'}
</button>
</div>
</>
)}
</div>
</div>
);
}

146
src/data/services.json Normal file
View file

@ -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
}
}
]

View file

@ -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();

60
src/lib/config.ts Normal file
View file

@ -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';

View file

@ -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<AppConfig> {
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<Services> {
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 };

View file

@ -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<void> {
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<void> {
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<string> {
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'}`);
}
}

65
src/lib/config/schema.ts Normal file
View file

@ -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<string>();
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<typeof serviceSchema>;
export type Healthcheck = z.infer<typeof healthcheckSchema>;
export type AppConfig = z.infer<typeof appConfigSchema>;
export type Services = z.infer<typeof servicesArraySchema>;

View file

@ -7,6 +7,10 @@ export interface Service {
url: string;
monitorUrl: string;
category: string;
healthcheck?: {
acceptableStatusCodes?: number[];
timeoutMs?: number;
};
}
export interface ServiceCheckResult {