This commit is contained in:
Bilal Teke 2026-04-20 14:34:07 +02:00
parent 764223db6c
commit 69c2057252
41 changed files with 2293 additions and 301 deletions

View file

@ -4,5 +4,20 @@ PORT=3000
# Hostname for the server # Hostname for the server
HOSTNAME=0.0.0.0 HOSTNAME=0.0.0.0
# NextAuth Configuration
# Secret for signing tokens (generate with: openssl rand -base64 32)
NEXTAUTH_SECRET=your-generated-secret-key
# Data directory for persistent storage (config, backups, database)
DATA_DIR=./data
# Admin username for login
ADMIN_USERNAME=admin
# Admin password hash (generated with bcryptjs)
# To generate a hash, run: node -e "console.log(require('bcryptjs').hashSync('your-password', 10))"
# Example hash for password 'admin': $2a$10$your-bcrypt-hash-here
ADMIN_PASSWORD_HASH=$2a$10$your-bcrypt-hash-here
# Optional: Polling interval for status checks (in milliseconds) # Optional: Polling interval for status checks (in milliseconds)
# STATUS_POLL_INTERVAL=30000 # STATUS_POLL_INTERVAL=30000

View file

@ -2,6 +2,7 @@
"extends": "next/core-web-vitals", "extends": "next/core-web-vitals",
"rules": { "rules": {
"react-hooks/rules-of-hooks": "error", "react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn" "react-hooks/exhaustive-deps": "warn",
"@next/next/no-html-link-for-pages": "off"
} }
} }

View file

@ -20,6 +20,9 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
# Generate Prisma client
RUN npx prisma generate
# Next.js collects completely anonymous telemetry data about general usage. # Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry # Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build. # Uncomment the following line in case you want to disable telemetry during the build.
@ -44,6 +47,9 @@ COPY --from=builder /app/public ./public
RUN mkdir .next RUN mkdir .next
RUN chown nextjs:nodejs .next RUN chown nextjs:nodejs .next
# Create data directory for persistent storage
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
# Automatically leverage output traces to reduce image size # Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing # 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/standalone ./

295
README.md
View file

@ -1,6 +1,6 @@
# Homelab Dashboard # Homelab Dashboard
Ein modernes, responsives Dashboard für die Verwaltung von Homelab-Diensten, gebaut mit **Next.js 15**, **TypeScript** und **Tailwind CSS**. Ein modernes, responsives Dashboard für die Verwaltung von Homelab-Diensten, gebaut mit **Next.js 15**, **TypeScript**, **Tailwind CSS** und **NextAuth.js**.
## Features ## Features
@ -9,12 +9,26 @@ Ein modernes, responsives Dashboard für die Verwaltung von Homelab-Diensten, ge
- Responsive Grid-Layout für alle Bildschirmgrößen - Responsive Grid-Layout für alle Bildschirmgrößen
- Smooth Animations und Übergänge - Smooth Animations und Übergänge
🔐 **Sichere Admin-Authentifizierung**
- Session-basierte Authentifizierung mit NextAuth.js
- Benutzername/Passwort Login
- Sichere Passwort-Hashing mit bcryptjs
- Automatisches Logout bei Inaktivität
🎯 **Service-Management** 🎯 **Service-Management**
- Service-Karten mit Name, Beschreibung und Status - Service-Karten mit Name, Beschreibung und Status
- Farbcodierter Status-Indikator (Online, Warnung, Offline) - Farbcodierter Status-Indikator (Online, Warnung, Offline)
- Direkte Links zu Services - Direkte Links zu Services
- Pulsierender Status-Punkt für visuelle Rückmeldung - Pulsierender Status-Punkt für visuelle Rückmeldung
⚙️ **Admin-Funktionen**
- Globale Konfigurationsoptionen (Polling, Theme, Logging)
- Service-Editor mit Healthcheck-Konfiguration
- Server-seitige Test-Funktion für Dienste
- Automatische und manuelle Backups
- Konfiguration Import/Export
- Dirty-State-Erkennung mit Undo-Funktionalität
📱 **Mobile-First Design** 📱 **Mobile-First Design**
- Optimiert für Smartphones, Tablets und Desktop - Optimiert für Smartphones, Tablets und Desktop
- Touch-freundliche Buttons - Touch-freundliche Buttons
@ -26,91 +40,177 @@ Ein modernes, responsives Dashboard für die Verwaltung von Homelab-Diensten, ge
- Node.js 18+ installiert - Node.js 18+ installiert
- npm oder yarn - npm oder yarn
### Schritt 1: Dependencies installieren ### Schritt 1: Abhängigkeiten installieren
```bash ```bash
npm install npm install
``` ```
### Schritt 2: Entwicklungsserver starten ### Schritt 1.1: Prisma Client generieren
```bash
npm run prisma:generate
```
### Schritt 1.2: Datenbank-Schema anwenden
```bash
npm run prisma:db-push
```
### Schritt 2: Umgebungsvariablen konfigurieren
Kopiere `.env.example` zu `.env`:
```bash
cp .env.example .env
```
Bearbeite `.env` und setze die erforderlichen Variablen:
```env
PORT=3000
HOSTNAME=0.0.0.0
# NextAuth Secret (generieren mit: openssl rand -base64 32)
NEXTAUTH_SECRET=generate-with-openssl-rand-base64-32
# SQLite database URL für persistenten Benutzerzugriff
DATABASE_URL=file:./dev.db
# Admin-Anmeldedaten (optional, solange noch kein erster Benutzer angelegt wurde)
ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH=<siehe unten>
```
### Schritt 3: Admin-Passwort-Hash generieren
Um den Passwort-Hash zu generieren, führe aus:
```bash
node scripts/generate-password-hash.js
```
Das Script fordert dich auf, ein Passwort einzugeben und gibt einen bcryptjs-Hash aus.
Kopiere den Hash in deine `.env` Datei als `ADMIN_PASSWORD_HASH`.
### Schritt 4: Entwicklungsserver starten
```bash ```bash
npm run dev npm run dev
``` ```
Der Server läuft dann unter `http://localhost:3000` Der Server läuft unter `http://localhost:3000`
## Login & Admin-Zugang
1. Navigiere zu `http://localhost:3000/setup`, wenn noch kein Administrator angelegt wurde.
2. Erstelle den ersten Admin-Benutzer.
3. Danach melde dich unter `http://localhost:3000/login` an.
4. Nach erfolgreichem Login wird zu `/admin` weitergeleitet
## Admin-Interface
Im Admin-Bereich kannst du:
- **Globale Einstellungen** bearbeiten (Polling-Intervall, Theme, Logging-Level)
- **Services** verwalten (Hinzufügen, Bearbeiten, Löschen)
- **Healthchecks** konfigurieren (Timeout, akzeptierte Status-Codes)
- **Services testen** (direkt von der Admin-UI aus)
- **Backups** erstellen und wiederherstellen
- **Konfiguration** exportieren und importieren
## Projekt-Struktur ## Projekt-Struktur
``` ```
homelab-dashboard/ homelab-dashboard/
├── app/ ├── app/
│ ├── components/ │ ├── api/
│ │ ├── Header.tsx # Header-Komponente │ │ ├── auth/[...nextauth]/ # NextAuth API Route
│ │ └── ServiceCard.tsx # Service-Karten-Komponente │ │ ├── config/[[...path]]/ # Config Management API
│ ├── globals.css # Globale Tailwind Styles │ │ └── status/ # Service Status Check API
│ ├── admin/ # Admin-Interface
│ ├── login/ # Login-Seite
│ ├── layout.tsx # Root Layout │ ├── layout.tsx # Root Layout
│ └── page.tsx # Startseite │ └── page.tsx # Dashboard
├── lib/ ├── auth.ts # NextAuth Konfiguration
│ └── data.ts # Mock-Daten und Service-Typen ├── middleware.ts # Auth-Middleware
├── src/
│ ├── app/
│ │ ├── api/ # API-Routen
│ │ └── components/admin/ # Admin-Komponenten
│ ├── lib/
│ │ ├── config/ # Konfigurationsverwaltung
│ │ ├── monitor/ # Service-Monitoring
│ │ └── auth/ # Auth-Hilfsfunktionen
│ └── types/ # TypeScript Typen
├── scripts/
│ └── generate-password-hash.js # Passwort-Hash Generator
├── tailwind.config.ts # Tailwind Konfiguration ├── tailwind.config.ts # Tailwind Konfiguration
├── postcss.config.js # PostCSS Konfiguration
├── next.config.js # Next.js Konfiguration
├── tsconfig.json # TypeScript Konfiguration ├── tsconfig.json # TypeScript Konfiguration
└── package.json # Abhängigkeiten └── package.json # Abhängigkeiten
``` ```
## Dienste hinzufügen/bearbeiten
Bearbeite die Datei [src/data/services.ts](src/data/services.ts):
```typescript
export const services: Service[] = [
{
id: 'unique-id',
name: 'Dein Service',
description: 'Was macht dieser Service?',
category: 'Infrastruktur', // oder Smart Home, Medien, Dokumente
status: 'online', // online | warning | offline
url: 'http://localhost:8000',
icon: '🖥️', // Optional - ein Emoji für visuelles Feedback
},
// ... weitere Services
];
```
### Verfügbare Kategorien
- **Infrastruktur**: Server, Netzwerk, Monitoring
- **Smart Home**: Home Automation und IoT
- **Medien**: Medienserver und Downloading
- **Dokumente**: Dateispeicher und Code-Repositories
## Verfügbare Commands ## Verfügbare Commands
- `npm run dev` - Entwicklungsserver starten ```bash
- `npm run build` - Produktions-Build erstellen npm run dev # Entwicklungsserver starten
- `npm start` - Produktions-Server starten npm run build # Produktions-Build erstellen
- `npm run lint` - Linter ausführen npm start # Produktions-Server starten
npm run lint # ESLint ausführen
```
## Status-Indikatoren ## Umgebungsvariablen
| Status | Farbe | Beschreibung | | Variable | Beschreibung | Beispiel |
|--------|-------|-------------| |----------|-------------|----------|
| Online | 🟢 Grün | Service läuft einwandfrei | | `PORT` | Port für den Server | `3000` |
| `HOSTNAME` | Server-Hostname | `0.0.0.0` |
| `NEXTAUTH_SECRET` | Secret für JWT-Signing | `openssl rand -base64 32` |
| `DATABASE_URL` | SQLite-Verbindungs-URL für Prisma | `file:./dev.db` |
| `ADMIN_USERNAME` | Admin-Benutzername | `admin` |
| `ADMIN_PASSWORD_HASH` | Bcryptjs-Hash des Admin-Passworts | `$2a$10$...` |
## Sicherheit
- ✅ Session-basierte Authentifizierung (nicht Token-basiert)
- ✅ Sichere Passwort-Hashing mit bcryptjs
- ✅ Geschützte API-Routen (nur für authentifizierte Benutzer)
- ✅ CSRF-Schutz durch NextAuth
- ✅ Sichere HTTP-Only Cookies
- ✅ Keine Speicherung sensibler Daten im Frontend
### Für Produktionsumgebungen beachte:
1. **Starkes Passwort wählen** (mindestens 12 Zeichen, Umlaute, Sonderzeichen)
2. **NEXTAUTH_SECRET** mit `openssl rand -base64 32` generieren
3. **HTTPS aktivieren** in der Produktionsumgebung
4. **Admin-Seite mit zusätzlichem Firewall-Schutz** (Optional)
5. **Regelmäßige Backups** durchführen
## Konfigurationsdateien
### `config.json`
| Warnung | 🟡 Gelb | Service läuft, aber mit Performance-Problemen | | Warnung | 🟡 Gelb | Service läuft, aber mit Performance-Problemen |
| Offline | 🔴 Rot | Service ist nicht erreichbar | | Offline | 🔴 Rot | Service ist nicht erreichbar |
## Docker Deployment ## Docker Deployment
### Lokaler Start ohne Docker ### Vorbereitung
```bash 1. **Umgebungsvariablen setzen**:
npm install ```bash
npm run dev cp .env.example .env
``` # Bearbeite .env mit deinen Werten
```
### Lokaler Start mit Docker 2. **Datenverzeichnis erstellen** (optional, wird automatisch erstellt):
```bash
mkdir -p data
```
### Lokaler Docker-Test
```bash ```bash
# Build und starte mit docker-compose # Build und starte mit docker-compose
@ -118,42 +218,87 @@ docker-compose up --build
# Oder manuell: # Oder manuell:
docker build -t homelab-dashboard . docker build -t homelab-dashboard .
docker run -p 3000:3000 homelab-dashboard docker run -p 3000:3000 \
-e NEXTAUTH_SECRET=your-secret \
-e DATABASE_URL=file:/app/data/database.db \
-e DATA_DIR=/app/data \
-v $(pwd)/data:/app/data \
homelab-dashboard
``` ```
### Build-Befehl ### Produktions-Deployment
```bash ```bash
# Build für Produktion
npm run build npm run build
```
### Run-Befehl # Mit Docker Compose starten
```bash
npm start
```
### Docker Compose Nutzung
```bash
# Start im Hintergrund
docker-compose up -d docker-compose up -d
# Stoppen # Container stoppen
docker-compose down docker-compose down
# Logs anzeigen
docker-compose logs -f homelab-dashboard
``` ```
### Unraid Volume-Mappings ### Unraid-Installation
Für Unraid empfiehlt es sich, die Konfigurationsdateien als Volumes zu mounten: 1. **Docker-Tab in Unraid öffnen**
2. **Neuen Container hinzufügen**:
- **Name**: homelab-dashboard
- **Repository**: dein-registry/homelab-dashboard:latest (oder build lokal)
- **Docker Hub Search**: Suche nach deinem Image
- **config.json**: `/mnt/user/appdata/homelab-dashboard/config.json``/app/config.json` 3. **Port-Mapping**:
- **services.json**: `/mnt/user/appdata/homelab-dashboard/services.json``/app/src/data/services.json` - **Container Port**: 3000
- **Host Port**: 3000 (oder gewünschter Port)
Dadurch können Konfigurationen außerhalb des Containers verwaltet werden. 4. **Volume-Mappings** (für persistente Daten):
- **Container Path**: /app/data
- **Host Path**: /mnt/user/appdata/homelab-dashboard/data
5. **Environment Variables**:
- **NEXTAUTH_SECRET**: Dein generierter Secret (openssl rand -base64 32)
- **DATABASE_URL**: file:/app/data/database.db
- **DATA_DIR**: /app/data
- **NODE_ENV**: production
6. **Container starten**
### Persistente Daten
Unter `/app/data` werden gespeichert:
- `config.json` - App-Konfiguration
- `services.json` - Service-Konfiguration
- `backups/` - Backup-Dateien
- `database.db` - SQLite-Datenbank für Benutzer
### Docker Compose für Unraid-ähnliche Umgebungen
```yaml
version: '3.8'
services:
homelab-dashboard:
image: homelab-dashboard:latest
container_name: homelab-dashboard
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- HOSTNAME=0.0.0.0
- NEXTAUTH_SECRET=your-nextauth-secret
- DATABASE_URL=file:/app/data/database.db
- DATA_DIR=/app/data
volumes:
- /mnt/user/appdata/homelab-dashboard/data:/app/data
```
### Troubleshooting
- **Container startet nicht**: Logs prüfen mit `docker-compose logs`
- **Daten nicht persistent**: Volume-Mapping überprüfen
- **Authentifizierung fehlt**: NEXTAUTH_SECRET setzen
- **Datenbank-Fehler**: DATABASE_URL und DATA_DIR prüfen
## Konfiguration ## Konfiguration

View file

@ -0,0 +1,6 @@
import NextAuth from 'next-auth';
import { authOptions } from '@/auth';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View file

@ -0,0 +1,247 @@
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import { createBackup, listBackups, restoreBackup } from '@/src/lib/config/backup-config';
import { backupConfig, exportConfig, importConfig, loadFullConfig, saveAppConfig, saveServices } from '@/src/lib/config';
import { appConfigSchema, servicesArraySchema } from '@/src/lib/config/schema';
import { CONFIG_FILE_PATH } from '@/src/lib/config/paths';
import { resolveProjectPath } from '@/src/lib/config/load-config';
import { promises as fs } from 'fs';
import path from 'path';
const CONFIG_PATH = CONFIG_FILE_PATH;
function jsonError(message: string, status: number) {
return NextResponse.json({ error: message }, { status });
}
function normalizePath(pathname: string) {
return pathname.replace(/\/+$/, '');
}
async function requireAuth() {
const session = await auth();
if (!session) {
return null;
}
return session;
}
export async function GET(request: Request) {
const pathname = new URL(request.url).pathname;
const cleanPath = normalizePath(pathname);
if (cleanPath === '/api/config') {
const session = await requireAuth();
if (!session) {
return jsonError('Unauthorized', 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);
}
}
if (cleanPath === '/api/config/backups') {
const session = await requireAuth();
if (!session) {
return jsonError('Unauthorized', 401);
}
try {
const backups = await listBackups();
return NextResponse.json({ backups });
} catch (error) {
console.error('GET /api/config/backups failed:', error);
return jsonError('Failed to list backups', 500);
}
}
if (cleanPath === '/api/config/export') {
const session = await requireAuth();
if (!session) {
return jsonError('Unauthorized', 401);
}
try {
const payload = await exportConfig();
const body = JSON.stringify(payload, null, 2);
return new NextResponse(body, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Content-Disposition': `attachment; filename="homelab-config-export-${new Date().toISOString().replace(/[:.]/g, '-')}.json"`,
},
});
} catch (error) {
console.error('GET /api/config/export failed:', error);
return jsonError('Failed to export configuration', 500);
}
}
return jsonError('Not found', 404);
}
export async function POST(request: Request) {
const session = await requireAuth();
if (!session) {
return jsonError('Unauthorized', 401);
}
const pathname = new URL(request.url).pathname;
const cleanPath = normalizePath(pathname);
if (cleanPath === '/api/config/backup') {
try {
const backupFile = await createBackup();
return NextResponse.json({
success: true,
message: 'Backup created successfully',
backupFilename: path.basename(backupFile),
});
} catch (error) {
console.error('POST /api/config/backup failed:', error);
return jsonError(
error instanceof Error ? error.message : 'Failed to create backup',
500
);
}
}
if (cleanPath === '/api/config/import') {
let payload: unknown;
try {
const contentType = request.headers.get('content-type') || '';
if (contentType.includes('multipart/form-data')) {
const formData = await request.formData();
const file = formData.get('file');
if (!(file instanceof File)) {
return jsonError('No file uploaded', 400);
}
const fileText = await file.text();
payload = JSON.parse(fileText);
} else {
payload = await request.json();
}
} catch (error) {
return jsonError(
'Request body must be valid JSON or a valid JSON upload',
400
);
}
try {
await importConfig(payload);
return NextResponse.json({
success: true,
message: 'Configuration imported successfully',
});
} catch (error) {
console.error('POST /api/config/import failed:', error);
return jsonError(
error instanceof Error ? error.message : 'Failed to import configuration',
400
);
}
}
if (cleanPath === '/api/config/restore') {
let payload: any;
try {
payload = await request.json();
} catch (error) {
return jsonError('Request body must be valid JSON', 400);
}
const { backupFilename } = payload;
if (!backupFilename || typeof backupFilename !== 'string') {
return jsonError('backupFilename is required and must be a string', 400);
}
if (
!backupFilename.startsWith('config-backup-') ||
!backupFilename.endsWith('.json') ||
backupFilename.includes('/') ||
backupFilename.includes('\\')
) {
return jsonError('Invalid backup filename format', 400);
}
try {
await restoreBackup(backupFilename);
return NextResponse.json({
success: true,
message: 'Configuration restored successfully',
});
} catch (error) {
console.error('POST /api/config/restore failed:', error);
return jsonError(
error instanceof Error ? error.message : 'Failed to restore backup',
500
);
}
}
return jsonError('Not found', 404);
}
export async function PUT(request: Request) {
const session = await requireAuth();
if (!session) {
return jsonError('Unauthorized', 401);
}
const pathname = new URL(request.url).pathname;
const cleanPath = normalizePath(pathname);
if (cleanPath !== '/api/config') {
return jsonError('Not found', 404);
}
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

@ -0,0 +1,31 @@
import { auth } from '@/auth';
import { NextRequest, NextResponse } from 'next/server';
import { checkService, type ServiceCheckResult } from '@/src/lib/monitor/check-service';
import { serviceSchema } from '@/src/lib/config/schema';
function jsonError(message: string, status: number) {
return NextResponse.json({ error: message }, { status });
}
export async function POST(request: NextRequest) {
const session = await auth();
if (!session) {
return jsonError('Unauthorized', 401);
}
try {
const body = await request.json();
const service = serviceSchema.parse(body);
const result: ServiceCheckResult = await checkService(service);
return NextResponse.json(result);
} catch (error) {
console.error('Error testing service:', error);
if (error instanceof Error) {
return jsonError(`Failed to test service: ${error.message}`, 400);
}
return jsonError('Failed to test service', 500);
}
}

64
app/api/setup/route.ts Normal file
View file

@ -0,0 +1,64 @@
import { NextResponse } from 'next/server';
import { hash } from 'bcryptjs';
import { z } from 'zod';
import { createInitialAdmin, hasAnyUser } from '@/src/lib/db/user';
const setupSchema = z.object({
username: z
.string()
.trim()
.min(3, 'Der Benutzername muss mindestens 3 Zeichen lang sein.')
.max(50, 'Der Benutzername darf höchstens 50 Zeichen lang sein.')
.regex(/^[a-zA-Z0-9._-]+$/, 'Der Benutzername enthält ungültige Zeichen.'),
password: z
.string()
.min(8, 'Das Passwort muss mindestens 8 Zeichen lang sein.')
.max(128, 'Das Passwort darf höchstens 128 Zeichen lang sein.'),
});
export async function POST(request: Request) {
try {
if (await hasAnyUser()) {
return NextResponse.json({ message: 'Ein Administrator existiert bereits.' }, { status: 403 });
}
let body: unknown;
try {
body = await request.json();
} catch (error) {
return NextResponse.json(
{ message: 'Der Request-Body muss gültiges JSON sein.' },
{ status: 400 }
);
}
const result = setupSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ message: result.error.issues[0]?.message || 'Ungültige Setup-Daten.' },
{ status: 400 }
);
}
const passwordHash = await hash(result.data.password, 10);
try {
await createInitialAdmin(result.data.username, passwordHash);
return NextResponse.json({ message: 'Administrator wurde erfolgreich angelegt.' }, { status: 201 });
} catch (error) {
if (error instanceof Error && error.message === 'INITIAL_ADMIN_ALREADY_EXISTS') {
return NextResponse.json({ message: 'Ein Administrator existiert bereits.' }, { status: 403 });
}
throw error;
}
} catch (error) {
console.error('POST /api/setup failed:', error);
return NextResponse.json(
{ message: 'Setup konnte nicht abgeschlossen werden.' },
{ status: 500 }
);
}
}

21
app/api/status/route.ts Normal file
View file

@ -0,0 +1,21 @@
import { NextResponse } from 'next/server';
import { loadFullConfig } from '@/src/lib/config';
import { checkService } from '@/src/lib/monitor/check-service';
import type { Service } from '@/src/lib/config';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
const { services } = await loadFullConfig();
const results = await Promise.all(services.map((service: Service) => checkService(service)));
return NextResponse.json(results);
} catch (error) {
console.error('Error checking services:', error);
return NextResponse.json(
{ error: 'Failed to check services' },
{ status: 500 }
);
}
}

View file

@ -1,4 +1,5 @@
import { Metadata as NextMetadata } from 'next'; import { Metadata as NextMetadata } from 'next';
import { Providers } from '@/src/app/providers';
export const metadata: NextMetadata = { export const metadata: NextMetadata = {
manifest: '/manifest.json', manifest: '/manifest.json',
@ -12,7 +13,7 @@ export default function RootLayout({
return ( return (
<html lang="de"> <html lang="de">
<body className="antialiased"> <body className="antialiased">
{children} <Providers>{children}</Providers>
</body> </body>
</html> </html>
); );

21
app/login/page.tsx Normal file
View file

@ -0,0 +1,21 @@
import { redirect } from 'next/navigation';
import { hasAnyUser } from '@/src/lib/db/user';
import LoginForm from '@/src/components/LoginForm';
export const dynamic = 'force-dynamic';
export default async function LoginPage() {
let hasUser = false;
try {
hasUser = await hasAnyUser();
} catch (error) {
console.warn('Failed to determine user setup status on /login:', error);
}
if (!hasUser && !process.env.ADMIN_PASSWORD_HASH) {
redirect('/setup');
}
return <LoginForm />;
}

19
app/setup/page.tsx Normal file
View file

@ -0,0 +1,19 @@
import { redirect } from 'next/navigation';
import { hasAnyUser } from '@/src/lib/db/user';
import SetupForm from '@/src/components/SetupForm';
export const dynamic = 'force-dynamic';
export default async function SetupPage() {
try {
const hasUser = await hasAnyUser();
if (hasUser) {
redirect('/login');
}
} catch (error) {
console.warn('Failed to determine user setup status on /setup:', error);
}
return <SetupForm />;
}

100
auth.ts Normal file
View file

@ -0,0 +1,100 @@
import { compare } from 'bcryptjs';
import { getServerSession, type NextAuthOptions } from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { getUserByUsername, hasAnyUser } from '@/src/lib/db/user';
export const authOptions: NextAuthOptions = {
providers: [
Credentials({
credentials: {
username: { label: 'Benutzername', type: 'text' },
password: { label: 'Passwort', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.username || !credentials?.password) {
return null;
}
const username = credentials.username.trim();
const password = credentials.password;
if (process.env.DATABASE_URL) {
try {
const dbUser = await getUserByUsername(username);
if (dbUser) {
const isPasswordValid = await compare(password, dbUser.passwordHash);
if (!isPasswordValid) {
return null;
}
return {
id: String(dbUser.id),
name: username,
email: `${username}@homelab.local`,
username,
};
}
if (await hasAnyUser()) {
return null;
}
} catch (error) {
console.error('Database authentication error:', error);
return null;
}
}
const adminUsername = process.env.ADMIN_USERNAME || 'admin';
const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH;
if (!adminPasswordHash || username !== adminUsername) {
return null;
}
try {
const isPasswordValid = await compare(password, adminPasswordHash);
if (!isPasswordValid) {
return null;
}
return {
id: '1',
name: 'Admin',
email: 'admin@homelab.local',
username: adminUsername,
};
} catch (error) {
console.error('Password comparison error:', error);
return null;
}
},
}),
],
pages: {
signIn: '/login',
},
callbacks: {
async jwt({ token, user }) {
if (user && user.username) {
token.username = user.username;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.username = token.username;
}
return session;
},
},
session: {
strategy: 'jwt',
maxAge: 24 * 60 * 60,
},
secret: process.env.NEXTAUTH_SECRET,
};
export function auth() {
return getServerSession(authOptions);
}

View file

@ -4,13 +4,21 @@ services:
homelab-dashboard: homelab-dashboard:
build: . build: .
container_name: homelab-dashboard container_name: homelab-dashboard
restart: unless-stopped
ports: ports:
- "3000:3000" - "3000:3000"
restart: unless-stopped
volumes:
- ./config.json:/app/config.json:ro
- ./src/data/services.json:/app/src/data/services.json:ro
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT=3000 - PORT=3000
- HOSTNAME=0.0.0.0 - HOSTNAME=0.0.0.0
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- DATABASE_URL=file:/app/data/database.db
- DATA_DIR=/app/data
volumes:
- ./data:/app/data
networks:
- homelab
networks:
homelab:
driver: bridge

264
package-lock.json generated
View file

@ -8,7 +8,10 @@
"name": "homelab-dashboard", "name": "homelab-dashboard",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@prisma/client": "^5.22.0",
"bcryptjs": "^3.0.3",
"next": "^16.2.3", "next": "^16.2.3",
"next-auth": "^4.24.14",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"zod": "^4.3.6" "zod": "^4.3.6"
@ -23,6 +26,7 @@
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-config-next": "^15.0.3", "eslint-config-next": "^15.0.3",
"postcss": "^8.4.32", "postcss": "^8.4.32",
"prisma": "^5.22.0",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
@ -40,6 +44,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.9.2", "version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
@ -992,6 +1005,83 @@
"node": ">=12.4.0" "node": ">=12.4.0"
} }
}, },
"node_modules/@panva/hkdf": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@prisma/client": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
"node": ">=16.13"
},
"peerDependencies": {
"prisma": "*"
},
"peerDependenciesMeta": {
"prisma": {
"optional": true
}
}
},
"node_modules/@prisma/debug": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/fetch-engine": "5.22.0",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/get-platform": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0"
}
},
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@ -1994,6 +2084,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -2265,6 +2364,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -3381,7 +3489,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@ -4257,6 +4364,15 @@
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
}, },
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -4425,6 +4541,18 @@
"loose-envify": "cli.js" "loose-envify": "cli.js"
} }
}, },
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -4598,6 +4726,38 @@
} }
} }
}, },
"node_modules/next-auth": {
"version": "4.24.14",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.14.tgz",
"integrity": "sha512-YRz6xFDXKUwiXSMMChbrBEWyFktZ1qZXEgeSHQQ3nsy08B4c/xLk6REeutRsIFwkjY/1+ShHnu07DN3JeJguig==",
"license": "ISC",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@panva/hkdf": "^1.0.2",
"cookie": "^0.7.0",
"jose": "^4.15.5",
"oauth": "^0.9.15",
"openid-client": "^5.4.0",
"preact": "^10.6.3",
"preact-render-to-string": "^5.1.19",
"uuid": "^8.3.2"
},
"peerDependencies": {
"@auth/core": "0.34.3",
"next": "^12.2.5 || ^13 || ^14 || ^15 || ^16",
"nodemailer": "^7.0.7",
"react": "^17.0.2 || ^18 || ^19",
"react-dom": "^17.0.2 || ^18 || ^19"
},
"peerDependenciesMeta": {
"@auth/core": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/next/node_modules/postcss": { "node_modules/next/node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@ -4672,6 +4832,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/oauth": {
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
"license": "MIT"
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -4805,6 +4971,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/oidc-token-hash": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
"license": "MIT",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/once": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -4815,6 +4990,30 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -5167,6 +5366,28 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/preact": {
"version": "10.29.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz",
"integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-render-to-string": {
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz",
"integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
"license": "MIT",
"dependencies": {
"pretty-format": "^3.8.0"
},
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -5177,6 +5398,32 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/pretty-format": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
"license": "MIT"
},
"node_modules/prisma": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/engines": "5.22.0"
},
"bin": {
"prisma": "build/index.js"
},
"engines": {
"node": ">=16.13"
},
"optionalDependencies": {
"fsevents": "2.3.3"
}
},
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@ -6383,6 +6630,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -6505,6 +6761,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View file

@ -6,10 +6,15 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint app src lib --ext .ts,.tsx" "lint": "eslint app src lib --ext .ts,.tsx",
"prisma:generate": "prisma generate",
"prisma:db-push": "prisma db push"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^5.22.0",
"bcryptjs": "^3.0.3",
"next": "^16.2.3", "next": "^16.2.3",
"next-auth": "^4.24.14",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"zod": "^4.3.6" "zod": "^4.3.6"
@ -24,6 +29,7 @@
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-config-next": "^15.0.3", "eslint-config-next": "^15.0.3",
"postcss": "^8.4.32", "postcss": "^8.4.32",
"prisma": "^5.22.0",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }

View file

@ -4,6 +4,6 @@ const config = {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
} };
export default config export default config;

BIN
prisma/dev.db Normal file

Binary file not shown.

16
prisma/schema.prisma Normal file
View file

@ -0,0 +1,16 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
username String @unique
passwordHash String
role String
createdAt DateTime @default(now())
}

11
proxy.ts Normal file
View file

@ -0,0 +1,11 @@
import { withAuth } from 'next-auth/middleware';
export default withAuth({
pages: {
signIn: '/login',
},
});
export const config = {
matcher: ['/admin/:path*'],
};

1
public/.gitkeep Normal file
View file

@ -0,0 +1 @@

View file

@ -0,0 +1,31 @@
#!/usr/bin/env node
/**
* Generate a bcryptjs hash for a password.
* Used to create ADMIN_PASSWORD_HASH for environment configuration.
*
* Usage: node scripts/generate-password-hash.js
*/
const bcryptjs = require('bcryptjs');
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.question('Enter the password to hash: ', (password) => {
if (!password) {
console.error('Error: Password cannot be empty');
rl.close();
process.exit(1);
}
const hash = bcryptjs.hashSync(password, 10);
console.log('\n✓ Password hash generated successfully:\n');
console.log(hash);
console.log('\nAdd this value to your .env file as ADMIN_PASSWORD_HASH\n');
rl.close();
});

View file

@ -1,90 +0,0 @@
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);
}
}

8
src/app/providers.tsx Normal file
View file

@ -0,0 +1,8 @@
'use client';
import { SessionProvider } from 'next-auth/react';
import type { ReactNode } from 'react';
export function Providers({ children }: { children: ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

View file

@ -0,0 +1,94 @@
'use client';
import { signIn } from 'next-auth/react';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function LoginForm() {
const router = useRouter();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(null);
setIsLoading(true);
try {
const result = await signIn('credentials', {
username,
password,
redirect: false,
});
if (!result?.ok) {
setError('Benutzername oder Passwort ist falsch.');
setPassword('');
} else {
router.push('/admin');
}
} catch (err) {
setError('Ein Fehler ist aufgetreten. Bitte versuche es später erneut.');
console.error('Login error:', err);
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-950">
<div className="w-full max-w-md px-4">
<div className="rounded-3xl border border-slate-200 bg-white/90 p-8 shadow-xl shadow-slate-200/50 dark:border-slate-700 dark:bg-slate-900/90 dark:shadow-slate-950/20">
<div className="mb-8">
<h1 className="text-3xl font-semibold text-slate-900 dark:text-slate-100">Homelab Admin</h1>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">Melde dich an, um die Konfiguration zu verwalten.</p>
</div>
{error && (
<div className="mb-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-800 dark:border-rose-700/40 dark:bg-rose-950/30 dark:text-rose-200">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<label className="block">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Benutzername</span>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isLoading}
autoComplete="username"
className="mt-2 w-full rounded-2xl border border-slate-300 bg-slate-50 px-4 py-3 text-sm transition placeholder:text-slate-400 focus:border-slate-400 focus:bg-white focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-700 dark:bg-slate-800 dark:placeholder:text-slate-500 dark:focus:border-slate-600 dark:focus:bg-slate-700"
placeholder="admin"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Passwort</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
autoComplete="current-password"
className="mt-2 w-full rounded-2xl border border-slate-300 bg-slate-50 px-4 py-3 text-sm transition placeholder:text-slate-400 focus:border-slate-400 focus:bg-white focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-700 dark:bg-slate-800 dark:placeholder:text-slate-500 dark:focus:border-slate-600 dark:focus:bg-slate-700"
placeholder="••••••••"
/>
</label>
<button
type="submit"
disabled={isLoading || !username || !password}
className="mt-6 w-full rounded-2xl bg-slate-900 px-4 py-3 text-sm font-semibold text-white transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:bg-slate-500 dark:bg-slate-700 dark:hover:bg-slate-600"
>
{isLoading ? 'Melde dich an...' : 'Anmelden'}
</button>
</form>
<p className="mt-6 text-center text-xs text-slate-500 dark:text-slate-400">Internes Admin-Interface für Homelab-Dashboard</p>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,127 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function SetupForm() {
const router = useRouter();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
const normalizedUsername = username.trim();
if (normalizedUsername.length < 3) {
setError('Der Benutzername muss mindestens 3 Zeichen lang sein.');
return;
}
if (password.length < 8) {
setError('Das Passwort muss mindestens 8 Zeichen lang sein.');
return;
}
if (password !== confirmPassword) {
setError('Die Passwörter stimmen nicht überein.');
return;
}
setIsLoading(true);
try {
const response = await fetch('/api/setup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username: normalizedUsername, password }),
});
if (!response.ok) {
const data = await response.json().catch(() => null);
setError(data?.message || 'Setup konnte nicht abgeschlossen werden.');
return;
}
router.push('/login');
} catch (err) {
setError('Ein unerwarteter Fehler ist aufgetreten.');
console.error(err);
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-950">
<div className="w-full max-w-xl px-4">
<div className="rounded-3xl border border-slate-200 bg-white/90 p-8 shadow-xl shadow-slate-200/50 dark:border-slate-700 dark:bg-slate-900/90 dark:shadow-slate-950/20">
<div className="mb-8 text-center">
<h1 className="text-3xl font-semibold text-slate-900 dark:text-slate-100">Erstes Setup</h1>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">Erstelle den ersten Administrator für dein Homelab-Dashboard.</p>
</div>
{error && (
<div className="mb-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-800 dark:border-rose-700/40 dark:bg-rose-950/30 dark:text-rose-200">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<label className="block">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Admin-Benutzername</span>
<input
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
disabled={isLoading}
autoComplete="username"
minLength={3}
className="mt-2 w-full rounded-2xl border border-slate-300 bg-slate-50 px-4 py-3 text-sm transition placeholder:text-slate-400 focus:border-slate-400 focus:bg-white focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-700 dark:bg-slate-800 dark:placeholder:text-slate-500 dark:focus:border-slate-600 dark:focus:bg-slate-700"
placeholder="admin"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Passwort</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={isLoading}
autoComplete="new-password"
minLength={8}
className="mt-2 w-full rounded-2xl border border-slate-300 bg-slate-50 px-4 py-3 text-sm transition placeholder:text-slate-400 focus:border-slate-400 focus:bg-white focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-700 dark:bg-slate-800 dark:placeholder:text-slate-500 dark:focus:border-slate-600 dark:focus:bg-slate-700"
placeholder="••••••••"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Passwort bestätigen</span>
<input
type="password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
disabled={isLoading}
autoComplete="new-password"
minLength={8}
className="mt-2 w-full rounded-2xl border border-slate-300 bg-slate-50 px-4 py-3 text-sm transition placeholder:text-slate-400 focus:border-slate-400 focus:bg-white focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-700 dark:bg-slate-800 dark:placeholder:text-slate-500 dark:focus:border-slate-600 dark:focus:bg-slate-700"
placeholder="••••••••"
/>
</label>
<button
type="submit"
disabled={isLoading || !username || !password || !confirmPassword}
className="mt-6 w-full rounded-2xl bg-slate-900 px-4 py-3 text-sm font-semibold text-white transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:bg-slate-500 dark:bg-slate-700 dark:hover:bg-slate-600"
>
{isLoading ? 'Einrichten...' : 'Administrator erstellen'}
</button>
</form>
</div>
</div>
</div>
);
}

View file

@ -1,7 +1,11 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useSession, signOut } from 'next-auth/react';
import type { AppConfig, Service } from '@/src/lib/config'; import type { AppConfig, Service } from '@/src/lib/config';
import type { ServiceCheckResult } from '@/src/types/service';
import BackupSection from './BackupSection';
import ImportExportSection from './ImportExportSection';
const defaultService: Service = { const defaultService: Service = {
id: '', id: '',
@ -20,31 +24,48 @@ const themes = ['auto', 'light', 'dark'] as const;
const loggingLevels = ['error', 'warn', 'info', 'debug'] as const; const loggingLevels = ['error', 'warn', 'info', 'debug'] as const;
export default function AdminPage() { export default function AdminPage() {
const { status } = useSession();
const [appConfig, setAppConfig] = useState<AppConfig | null>(null); const [appConfig, setAppConfig] = useState<AppConfig | null>(null);
const [services, setServices] = useState<Service[]>([]); const [services, setServices] = useState<Service[]>([]);
const [originalAppConfig, setOriginalAppConfig] = useState<AppConfig | null>(null);
const [originalServices, setOriginalServices] = useState<Service[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [adminToken, setAdminToken] = useState('');
const [configLoaded, setConfigLoaded] = useState(false); const [configLoaded, setConfigLoaded] = useState(false);
const [testResults, setTestResults] = useState<Record<string, ServiceCheckResult | null>>({});
const [testingServices, setTestingServices] = useState<Set<string>>(new Set());
const configsAreEqual = (config1: AppConfig | null, services1: Service[], config2: AppConfig | null, services2: Service[]): boolean => {
if (!config1 || !config2) return config1 === config2;
return JSON.stringify({ config: config1, services: services1 }) === JSON.stringify({ config: config2, services: services2 });
};
const isDirty = useMemo(() => {
if (!configLoaded) return false;
return !configsAreEqual(appConfig, services, originalAppConfig, originalServices);
}, [appConfig, services, originalAppConfig, originalServices, configLoaded]);
useEffect(() => {
if (!isDirty) return;
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [isDirty]);
const loadConfig = async () => { const loadConfig = async () => {
if (!adminToken.trim()) {
setError('Admin-Token ist erforderlich, um die Konfiguration zu laden.');
return;
}
setLoading(true); setLoading(true);
setError(null); setError(null);
setMessage(null); setMessage(null);
try { try {
const response = await fetch('/api/config', { const response = await fetch('/api/config');
headers: {
Authorization: `Bearer ${adminToken}`,
},
});
if (!response.ok) { if (!response.ok) {
const result = await response.json().catch(() => null); const result = await response.json().catch(() => null);
throw new Error(result?.error || `Failed to load config: ${response.statusText}`); throw new Error(result?.error || `Failed to load config: ${response.statusText}`);
@ -52,6 +73,8 @@ export default function AdminPage() {
const data = await response.json(); const data = await response.json();
setAppConfig(data.appConfig); setAppConfig(data.appConfig);
setServices(data.services); setServices(data.services);
setOriginalAppConfig(data.appConfig);
setOriginalServices(data.services);
setConfigLoaded(true); setConfigLoaded(true);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load config'); setError(err instanceof Error ? err.message : 'Failed to load config');
@ -113,6 +136,45 @@ export default function AdminPage() {
.map((item) => Number(item)); .map((item) => Number(item));
}; };
const testService = async (service: Service) => {
setTestingServices((prev) => new Set(prev).add(service.id));
setError(null);
try {
const response = await fetch('/api/config/test-service', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(service),
});
if (!response.ok) {
const result = await response.json().catch(() => null);
throw new Error(result?.error || `Failed to test service: ${response.statusText}`);
}
const result: ServiceCheckResult = await response.json();
setTestResults((prev) => ({ ...prev, [service.id]: result }));
} catch (err) {
const errorResult: ServiceCheckResult = {
id: service.id,
status: 'offline',
responseTimeMs: 0,
httpStatus: null,
checkedAt: new Date().toISOString(),
message: err instanceof Error ? err.message : 'Test fehlgeschlagen',
};
setTestResults((prev) => ({ ...prev, [service.id]: errorResult }));
} finally {
setTestingServices((prev) => {
const newSet = new Set(prev);
newSet.delete(service.id);
return newSet;
});
}
};
const validateForm = () => { const validateForm = () => {
if (!appConfig) { if (!appConfig) {
setError('Konfiguration wurde nicht geladen.'); setError('Konfiguration wurde nicht geladen.');
@ -124,11 +186,6 @@ export default function AdminPage() {
return false; 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); const normalizedCategories = appConfig.categories.map((category) => category.trim()).filter(Boolean);
if (normalizedCategories.length === 0) { if (normalizedCategories.length === 0) {
setError('Mindestens eine gültige Kategorie ist erforderlich.'); setError('Mindestens eine gültige Kategorie ist erforderlich.');
@ -183,6 +240,13 @@ export default function AdminPage() {
return true; return true;
}; };
const discardChanges = () => {
setAppConfig(originalAppConfig);
setServices(originalServices);
setMessage(null);
setError(null);
};
const saveConfig = async () => { const saveConfig = async () => {
setSaving(true); setSaving(true);
setMessage(null); setMessage(null);
@ -204,7 +268,6 @@ export default function AdminPage() {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${adminToken}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
appConfig: { appConfig: {
@ -237,6 +300,13 @@ export default function AdminPage() {
[services] [services]
); );
// Load config on mount
useEffect(() => {
if (status === 'authenticated' && !configLoaded) {
loadConfig().catch((err) => console.error('Failed to load config:', err));
}
}, [status, configLoaded]);
return ( return (
<div className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100"> <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="max-w-8xl mx-auto px-4 py-8 sm:px-6 lg:px-8">
@ -248,36 +318,33 @@ export default function AdminPage() {
Hier kannst du globale Einstellungen und Services bearbeiten. Hier kannst du globale Einstellungen und Services bearbeiten.
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="flex gap-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 <button
type="button" type="button"
onClick={() => void loadConfig()} onClick={() => void loadConfig()}
disabled={loading} 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" className="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'} {loading ? 'Lade…' : 'Neu laden'}
</button>
<button
type="button"
onClick={() => {
signOut({ redirect: true });
}}
className="rounded-2xl border border-rose-300 bg-rose-50 px-4 py-2 text-sm font-medium text-rose-700 transition hover:bg-rose-100 dark:border-rose-600 dark:bg-rose-950/40 dark:text-rose-200"
>
Logout
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{!configLoaded && !loading ? ( {loading && !configLoaded ? (
<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"> <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 Lade Konfiguration
</div> </div>
) : ( ) : configLoaded ? (
<> <>
{error && ( {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"> <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">
@ -433,26 +500,115 @@ export default function AdminPage() {
/> />
</label> </label>
</div> </div>
{/* Test Section */}
<div className="mt-4 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-600 dark:bg-slate-800">
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300">Healthcheck testen</h4>
<p className="text-xs text-slate-500 dark:text-slate-400">
Testet die Monitor-URL mit den konfigurierten Parametern
</p>
</div>
<button
type="button"
onClick={() => void testService(service)}
disabled={testingServices.has(service.id)}
className="rounded-2xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:bg-slate-500 dark:bg-slate-700 dark:hover:bg-slate-600"
>
{testingServices.has(service.id) ? 'Teste…' : 'Testen'}
</button>
</div>
{testResults[service.id] && (
<div className="mt-3 rounded-xl border bg-white p-3 dark:border-slate-600 dark:bg-slate-900">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="font-medium text-slate-700 dark:text-slate-300">Status:</span>
<span
className={`ml-2 inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
testResults[service.id]!.status === 'online'
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200'
: testResults[service.id]!.status === 'warning'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-200'
: 'bg-rose-100 text-rose-800 dark:bg-rose-900/30 dark:text-rose-200'
}`}
>
{testResults[service.id]!.status}
</span>
</div>
<div>
<span className="font-medium text-slate-700 dark:text-slate-300">HTTP-Status:</span>
<span className="ml-2 text-slate-900 dark:text-slate-100">
{testResults[service.id]!.httpStatus ?? 'N/A'}
</span>
</div>
<div>
<span className="font-medium text-slate-700 dark:text-slate-300">Antwortzeit:</span>
<span className="ml-2 text-slate-900 dark:text-slate-100">
{testResults[service.id]!.responseTimeMs}ms
</span>
</div>
<div>
<span className="font-medium text-slate-700 dark:text-slate-300">Geprüft:</span>
<span className="ml-2 text-slate-900 dark:text-slate-100">
{new Date(testResults[service.id]!.checkedAt).toLocaleTimeString()}
</span>
</div>
</div>
{testResults[service.id]!.message && (
<div className="mt-2 text-xs text-rose-600 dark:text-rose-400">
{testResults[service.id]!.message}
</div>
)}
</div>
)}
</div>
</div> </div>
))} ))}
</div> </div>
</section> </section>
<ImportExportSection onConfigChanged={loadConfig} />
<BackupSection onConfigChanged={loadConfig} />
<div className={`rounded-3xl border p-6 shadow-xl ${isDirty ? 'border-orange-300 bg-orange-50/90 dark:border-orange-700/40 dark:bg-orange-950/30' : 'border-slate-200 bg-white/90 dark:border-slate-700 dark:bg-slate-900/90'}`}>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <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"> <div className="flex-1">
Änderungen werden auf die lokale Konfigurationsdatei geschrieben. <div className="text-sm">
{isDirty ? (
<div className="flex items-center gap-2">
<span className="inline-block h-2 w-2 rounded-full bg-orange-500"></span>
<span className="font-medium text-orange-900 dark:text-orange-100">Ungespeicherte Änderungen</span>
</div> </div>
) : (
<span className="text-slate-500 dark:text-slate-400">Änderungen werden auf die lokale Konfigurationsdatei geschrieben.</span>
)}
</div>
</div>
<div className="flex gap-3">
{isDirty && (
<button
type="button"
onClick={discardChanges}
className="inline-flex items-center justify-center rounded-2xl border border-orange-300 bg-orange-50 px-6 py-3 text-sm font-semibold text-orange-700 transition hover:bg-orange-100 dark:border-orange-600 dark:bg-orange-950/40 dark:text-orange-200"
>
Änderungen verwerfen
</button>
)}
<button <button
type="button" type="button"
onClick={saveConfig} onClick={saveConfig}
disabled={saving} disabled={saving || !isDirty}
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" 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 dark:bg-slate-700 dark:hover:bg-slate-600"
> >
{saving ? 'Speichere…' : 'Speichern'} {saving ? 'Speichere…' : 'Speichern'}
</button> </button>
</div> </div>
</div>
</div>
</> </>
)} ) : null}
</div> </div>
</div> </div>
); );

View file

@ -0,0 +1,211 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
interface BackupInfo {
filename: string;
timestamp: string;
size: number;
createdAt: Date;
}
interface BackupSectionProps {
onConfigChanged: () => void;
}
export default function BackupSection({ onConfigChanged }: BackupSectionProps) {
const [backups, setBackups] = useState<BackupInfo[]>([]);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [restoring, setRestoring] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const loadBackups = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/config/backups');
if (!response.ok) {
const result = await response.json().catch(() => null);
throw new Error(result?.error || `Failed to load backups: ${response.statusText}`);
}
const data = await response.json();
setBackups(data.backups.map((backup: any) => ({
...backup,
createdAt: new Date(backup.createdAt),
})));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load backups');
} finally {
setLoading(false);
}
}, []);
const createBackup = async () => {
setCreating(true);
setMessage(null);
setError(null);
try {
const response = await fetch('/api/config/backup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const result = await response.json().catch(() => null);
throw new Error(result?.error || `Failed to create backup: ${response.statusText}`);
}
const result = await response.json();
setMessage(result.message || 'Backup erfolgreich erstellt.');
await loadBackups();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create backup');
} finally {
setCreating(false);
}
};
const restoreBackup = async (backupFilename: string) => {
if (!confirm(`Möchten Sie wirklich die Konfiguration aus dem Backup "${backupFilename}" wiederherstellen? Die aktuelle Konfiguration wird automatisch gesichert.`)) {
return;
}
setRestoring(backupFilename);
setMessage(null);
setError(null);
try {
const response = await fetch('/api/config/restore', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ backupFilename }),
});
if (!response.ok) {
const result = await response.json().catch(() => null);
throw new Error(result?.error || `Failed to restore backup: ${response.statusText}`);
}
const result = await response.json();
setMessage(result.message || 'Konfiguration erfolgreich wiederhergestellt.');
onConfigChanged();
await loadBackups();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to restore backup');
} finally {
setRestoring(null);
}
};
const formatDate = (date: Date) => {
return date.toLocaleString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
useEffect(() => {
loadBackups();
}, [loadBackups]);
return (
<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">Backups</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
Verwalte Sicherungen deiner Konfiguration.
</p>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={loadBackups}
disabled={loading}
className="rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:hover:bg-slate-700"
>
{loading ? 'Lade…' : 'Aktualisieren'}
</button>
<button
type="button"
onClick={createBackup}
disabled={creating}
className="rounded-2xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:bg-slate-500"
>
{creating ? 'Erstelle…' : 'Backup erstellen'}
</button>
</div>
</div>
{error && (
<div className="mb-4 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-800 dark:border-rose-700/40 dark:bg-rose-950/30 dark:text-rose-200">
{error}
</div>
)}
{message && (
<div className="mb-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-700/40 dark:bg-emerald-950/30 dark:text-emerald-200">
{message}
</div>
)}
<div className="space-y-3">
{backups.length === 0 ? (
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-8 text-center text-slate-500 dark:border-slate-700 dark:bg-slate-800/50 dark:text-slate-400">
{loading ? 'Lade Backups…' : 'Keine Backups vorhanden.'}
</div>
) : (
backups.map((backup) => (
<div
key={backup.filename}
className="flex items-center justify-between rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800/50"
>
<div className="flex-1">
<div className="font-medium text-slate-900 dark:text-slate-100">
{backup.filename}
</div>
<div className="text-sm text-slate-500 dark:text-slate-400">
{formatDate(backup.createdAt)} {formatFileSize(backup.size)}
</div>
</div>
<button
type="button"
onClick={() => restoreBackup(backup.filename)}
disabled={restoring === backup.filename}
className="rounded-2xl border border-amber-300 bg-amber-50 px-3 py-1.5 text-sm text-amber-700 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-amber-600 dark:bg-amber-950/40 dark:text-amber-200"
>
{restoring === backup.filename ? 'Stelle wieder her…' : 'Wiederherstellen'}
</button>
</div>
))
)}
</div>
<div className="mt-4 text-xs text-slate-500 dark:text-slate-400">
Backups werden automatisch vor jeder Konfigurationsänderung erstellt. Die letzten 20 Backups werden behalten.
</div>
</section>
);
}

View file

@ -0,0 +1,155 @@
'use client';
import { type ChangeEvent, useState } from 'react';
interface ImportExportSectionProps {
onConfigChanged: () => void;
}
export default function ImportExportSection({ onConfigChanged }: ImportExportSectionProps) {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [fileName, setFileName] = useState('');
const [importing, setImporting] = useState(false);
const [exporting, setExporting] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] || null;
setSelectedFile(file);
setFileName(file?.name ?? '');
setMessage(null);
setError(null);
};
const exportConfig = async () => {
setExporting(true);
setMessage(null);
setError(null);
try {
const response = await fetch('/api/config/export');
if (!response.ok) {
const result = await response.json().catch(() => null);
throw new Error(result?.error || `Export fehlgeschlagen: ${response.statusText}`);
}
const blob = await response.blob();
const downloadUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = downloadUrl;
anchor.download = `homelab-config-export-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(downloadUrl);
setMessage('Konfiguration erfolgreich exportiert.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Export fehlgeschlagen');
} finally {
setExporting(false);
}
};
const importConfig = async () => {
if (!selectedFile) {
setError('Bitte wähle eine JSON-Datei zum Import aus.');
return;
}
if (!confirm('Die aktuelle Konfiguration wird überschrieben. Möchtest du fortfahren?')) {
return;
}
setImporting(true);
setMessage(null);
setError(null);
try {
const fileText = await selectedFile.text();
const payload = JSON.parse(fileText);
const response = await fetch('/api/config/import', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result?.error || 'Import fehlgeschlagen');
}
setMessage(result.message || 'Konfiguration erfolgreich importiert.');
setSelectedFile(null);
setFileName('');
onConfigChanged();
} catch (err) {
setError(err instanceof Error ? err.message : 'Import fehlgeschlagen');
} finally {
setImporting(false);
}
};
return (
<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 flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-xl font-semibold">Import / Export</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
Exportiere die aktuelle Konfiguration oder importiere eine gültige JSON-Datei.
</p>
</div>
<button
type="button"
onClick={exportConfig}
disabled={exporting}
className="inline-flex items-center justify-center rounded-2xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:bg-slate-500"
>
{exporting ? 'Exportiere…' : 'Exportieren'}
</button>
</div>
{error && (
<div className="mb-4 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-800 dark:border-rose-700/40 dark:bg-rose-950/30 dark:text-rose-200">
{error}
</div>
)}
{message && (
<div className="mb-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-700/40 dark:bg-emerald-950/30 dark:text-emerald-200">
{message}
</div>
)}
<div className="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
<label className="block">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Import-Datei</span>
<input
type="file"
accept="application/json"
onChange={handleFileChange}
className="mt-2 w-full rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
/>
{fileName && <p className="mt-2 text-xs text-slate-500 dark:text-slate-400">Ausgewählt: {fileName}</p>}
</label>
<button
type="button"
onClick={importConfig}
disabled={importing || !selectedFile}
className="inline-flex items-center justify-center rounded-2xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:bg-emerald-300"
>
{importing ? 'Importiere…' : 'Importieren'}
</button>
</div>
<p className="mt-4 text-xs text-slate-500 dark:text-slate-400">
Der Import validiert die Datei serverseitig. Vorherige Konfiguration wird automatisch gesichert.
</p>
</section>
);
}

View file

@ -1,5 +1,5 @@
import 'server-only'; import 'server-only';
import { config } from '@/src/lib/config'; import { getConfigSync } from '@/src/lib/config';
import { servicesArraySchema } from '@/src/lib/config/schema'; import { servicesArraySchema } from '@/src/lib/config/schema';
import { resolveProjectPath } from '@/src/lib/config/load-config'; import { resolveProjectPath } from '@/src/lib/config/load-config';
import type { Service } from '@/src/lib/config'; import type { Service } from '@/src/lib/config';
@ -13,6 +13,7 @@ function loadServicesSync(): Service[] {
if (!cachedServices) { if (!cachedServices) {
try { try {
const fs = require('fs'); const fs = require('fs');
const config = getConfigSync();
const servicesPath = resolveProjectPath(config.servicesFile); const servicesPath = resolveProjectPath(config.servicesFile);
const servicesData = fs.readFileSync(servicesPath, 'utf8'); const servicesData = fs.readFileSync(servicesPath, 'utf8');
cachedServices = servicesArraySchema.parse(JSON.parse(servicesData)); cachedServices = servicesArraySchema.parse(JSON.parse(servicesData));

View file

@ -1,6 +1,9 @@
import 'server-only'; import 'server-only';
import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './config/schema'; import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './config/schema';
import { resolveProjectPath } from './config/load-config'; import { CONFIG_FILE_PATH, SERVICES_FILE_PATH } from './config/paths';
import path from 'path';
const DEFAULT_SERVICES_FILE = path.relative(process.cwd(), SERVICES_FILE_PATH);
// Legacy export for backward compatibility // Legacy export for backward compatibility
export type { AppConfig }; export type { AppConfig };
@ -13,9 +16,7 @@ function loadConfigSync(): AppConfig {
if (!cachedConfig) { if (!cachedConfig) {
try { try {
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const configData = fs.readFileSync(CONFIG_FILE_PATH, 'utf8');
const configPath = path.join(process.cwd(), 'config.json');
const configData = fs.readFileSync(configPath, 'utf8');
cachedConfig = appConfigSchema.parse(JSON.parse(configData)); cachedConfig = appConfigSchema.parse(JSON.parse(configData));
} catch (error) { } catch (error) {
console.warn('Failed to load config synchronously, using defaults'); console.warn('Failed to load config synchronously, using defaults');
@ -23,7 +24,7 @@ function loadConfigSync(): AppConfig {
title: 'Homelab Dashboard', title: 'Homelab Dashboard',
subtitle: 'Live-Status aller verwalteten Dienste', subtitle: 'Live-Status aller verwalteten Dienste',
description: 'Live-Monitoring-Dashboard für dein Homelab', description: 'Live-Monitoring-Dashboard für dein Homelab',
servicesFile: 'src/data/services.json', servicesFile: DEFAULT_SERVICES_FILE,
refreshInterval: 30000, refreshInterval: 30000,
categories: ['Infrastruktur', 'Smart Home', 'Medien', 'Dokumente'], categories: ['Infrastruktur', 'Smart Home', 'Medien', 'Dokumente'],
theme: 'auto', theme: 'auto',
@ -38,9 +39,7 @@ function loadServicesSync(): Services {
if (!cachedServices) { if (!cachedServices) {
try { try {
const fs = require('fs'); const fs = require('fs');
const config = loadConfigSync(); const servicesData = fs.readFileSync(SERVICES_FILE_PATH, 'utf8');
const servicesPath = resolveProjectPath(config.servicesFile);
const servicesData = fs.readFileSync(servicesPath, 'utf8');
cachedServices = servicesArraySchema.parse(JSON.parse(servicesData)); cachedServices = servicesArraySchema.parse(JSON.parse(servicesData));
} catch (error) { } catch (error) {
console.warn('Failed to load services synchronously, using empty array'); console.warn('Failed to load services synchronously, using empty array');
@ -50,11 +49,17 @@ function loadServicesSync(): Services {
return cachedServices; return cachedServices;
} }
// Export cached versions for backward compatibility // Legacy accessors for backward compatibility
export const config: AppConfig = loadConfigSync(); export function getConfigSync(): AppConfig {
export const services: Services = loadServicesSync(); return loadConfigSync();
}
export function getServicesSync(): Services {
return loadServicesSync();
}
// Export new async functions // Export new async functions
export { loadAppConfig, loadServices, loadFullConfig } from './config/load-config'; export { loadAppConfig, loadServices, loadFullConfig } from './config/load-config';
export { saveAppConfig, saveServices, backupConfig } from './config/save-config'; export { saveAppConfig, saveServices, backupConfig } from './config/save-config';
export { exportConfig, importConfig, type ConfigExportPayload } from './config/import-export';
export type { Service, Healthcheck } from './config/schema'; export type { Service, Healthcheck } from './config/schema';

View file

@ -0,0 +1,180 @@
import 'server-only';
import { promises as fs } from 'fs';
import path from 'path';
import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './schema';
import { loadFullConfig, resolveProjectPath } from './load-config';
import { BACKUP_DIR_PATH, CONFIG_FILE_PATH, ensureDataDir } from './paths';
export interface BackupInfo {
filename: string;
timestamp: string;
size: number;
createdAt: Date;
}
export interface BackupData {
timestamp: string;
appConfig: AppConfig;
services: Services;
}
async function backupCurrentConfigIfPossible(backupDir?: string): Promise<void> {
try {
await createBackup(backupDir);
} catch (error) {
console.warn('Skipping pre-restore backup because the current configuration could not be backed up:', error);
}
}
/**
* Erstellt ein Backup der aktuellen Konfiguration
*/
export async function createBackup(backupDir?: string): Promise<string> {
await ensureDataDir();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = backupDir || BACKUP_DIR_PATH;
const backupFile = path.join(backupPath, `config-backup-${timestamp}.json`);
try {
// Load current config
const { appConfig, services } = await loadFullConfig();
// Create backup data
const backupData: 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');
// Clean up old backups (keep last 20)
await cleanupOldBackups(backupPath);
return backupFile;
} catch (error) {
console.error('Failed to create backup:', error);
throw new Error(`Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Lädt eine Liste aller verfügbaren Backups
*/
export async function listBackups(backupDir?: string): Promise<BackupInfo[]> {
await ensureDataDir();
const backupPath = backupDir || BACKUP_DIR_PATH;
try {
// Ensure directory exists
await fs.mkdir(backupPath, { recursive: true });
const files = await fs.readdir(backupPath);
const backupFiles = files.filter(file => file.startsWith('config-backup-') && file.endsWith('.json'));
const backups: BackupInfo[] = [];
for (const file of backupFiles) {
try {
const filePath = path.join(backupPath, file);
const stats = await fs.stat(filePath);
// Extract timestamp from filename
const timestampMatch = file.match(/config-backup-(.+)\.json/);
if (!timestampMatch) continue;
const timestamp = timestampMatch[1];
const createdAt = stats.mtime;
backups.push({
filename: file,
timestamp,
size: stats.size,
createdAt,
});
} catch (error) {
console.warn(`Failed to process backup file ${file}:`, error);
}
}
// Sort by creation date (newest first)
return backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
} catch (error) {
console.error('Failed to list backups:', error);
throw new Error(`Failed to list backups: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Stellt ein Backup als aktuelle Konfiguration wieder her
*/
export async function restoreBackup(backupFilename: string, backupDir?: string): Promise<void> {
await ensureDataDir();
const backupPath = backupDir || BACKUP_DIR_PATH;
if (backupFilename.includes('/') || backupFilename.includes('\\')) {
throw new Error('Invalid backup filename');
}
const backupFile = path.join(backupPath, backupFilename);
try {
// Read and parse backup file
const backupContent = await fs.readFile(backupFile, 'utf8');
const backupData: BackupData = JSON.parse(backupContent);
// Validate backup data
const appConfigResult = appConfigSchema.safeParse(backupData.appConfig);
if (!appConfigResult.success) {
throw new Error(`Invalid app config in backup: ${appConfigResult.error.message}`);
}
const servicesResult = servicesArraySchema.safeParse(backupData.services);
if (!servicesResult.success) {
throw new Error(`Invalid services in backup: ${servicesResult.error.message}`);
}
await backupCurrentConfigIfPossible(backupPath);
// Save restored config
const { saveAppConfig, saveServices } = await import('./save-config');
const configPath = CONFIG_FILE_PATH;
const servicesPath = resolveProjectPath(appConfigResult.data.servicesFile);
await saveAppConfig(appConfigResult.data, configPath);
await saveServices(servicesResult.data, servicesPath);
} catch (error) {
console.error('Failed to restore backup:', error);
throw new Error(`Restore failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Bereinigt alte Backups (behält die letzten 20)
*/
async function cleanupOldBackups(backupDir: string): Promise<void> {
try {
const backups = await listBackups(backupDir);
if (backups.length <= 20) return;
const toDelete = backups.slice(20); // Keep first 20 (newest)
for (const backup of toDelete) {
try {
const filePath = path.join(backupDir, backup.filename);
await fs.unlink(filePath);
console.info(`Deleted old backup: ${backup.filename}`);
} catch (error) {
console.warn(`Failed to delete old backup ${backup.filename}:`, error);
}
}
} catch (error) {
console.warn('Failed to cleanup old backups:', error);
}
}

View file

@ -0,0 +1,47 @@
import 'server-only';
import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './schema';
import { createBackup } from './backup-config';
import { loadFullConfig } from './load-config';
import { saveAppConfig, saveServices } from './save-config';
import { CONFIG_FILE_PATH } from './paths';
import { resolveProjectPath } from './load-config';
export interface ConfigExportPayload {
appConfig: AppConfig;
services: Services;
}
async function backupCurrentConfigIfPossible(): Promise<void> {
try {
await createBackup();
} catch (error) {
console.warn('Skipping pre-write backup because the current configuration could not be backed up:', error);
}
}
export async function exportConfig(): Promise<ConfigExportPayload> {
const { appConfig, services } = await loadFullConfig();
return { appConfig, services };
}
export async function importConfig(payload: unknown): Promise<void> {
const data = payload as { appConfig: unknown; services: unknown };
const appConfigResult = appConfigSchema.safeParse(data.appConfig);
if (!appConfigResult.success) {
throw new Error(`App config validation failed: ${appConfigResult.error.message}`);
}
const servicesResult = servicesArraySchema.safeParse(data.services);
if (!servicesResult.success) {
throw new Error(`Services validation failed: ${servicesResult.error.message}`);
}
const configPath = CONFIG_FILE_PATH;
const servicesPath = resolveProjectPath(appConfigResult.data.servicesFile);
await backupCurrentConfigIfPossible();
await saveAppConfig(appConfigResult.data, configPath);
await saveServices(servicesResult.data, servicesPath);
}

View file

@ -2,13 +2,16 @@ import 'server-only';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './schema'; import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './schema';
import { CONFIG_FILE_PATH, SERVICES_FILE_PATH, ensureDataDir } from './paths';
const DEFAULT_SERVICES_FILE = path.relative(process.cwd(), SERVICES_FILE_PATH);
// Default configurations // Default configurations
const defaultAppConfig: AppConfig = { const defaultAppConfig: AppConfig = {
title: 'Homelab Dashboard', title: 'Homelab Dashboard',
subtitle: 'Live-Status aller verwalteten Dienste', subtitle: 'Live-Status aller verwalteten Dienste',
description: 'Live-Monitoring-Dashboard für dein Homelab', description: 'Live-Monitoring-Dashboard für dein Homelab',
servicesFile: 'src/data/services.json', servicesFile: DEFAULT_SERVICES_FILE,
refreshInterval: 30000, refreshInterval: 30000,
categories: ['Infrastruktur', 'Smart Home', 'Medien', 'Dokumente'], categories: ['Infrastruktur', 'Smart Home', 'Medien', 'Dokumente'],
theme: 'auto', theme: 'auto',
@ -33,7 +36,8 @@ function resolveProjectPath(relativePath: string): string {
* Lädt und validiert die App-Konfiguration * Lädt und validiert die App-Konfiguration
*/ */
export async function loadAppConfig(configPath?: string): Promise<AppConfig> { export async function loadAppConfig(configPath?: string): Promise<AppConfig> {
const filePath = configPath || path.join(process.cwd(), 'config.json'); await ensureDataDir();
const filePath = configPath || CONFIG_FILE_PATH;
try { try {
const configData = await fs.readFile(filePath, 'utf8'); const configData = await fs.readFile(filePath, 'utf8');
@ -44,8 +48,6 @@ export async function loadAppConfig(configPath?: string): Promise<AppConfig> {
return validatedConfig; return validatedConfig;
} catch (error) { } catch (error) {
console.warn(`Failed to load app config from ${filePath}:`, error);
// Return defaults if file doesn't exist or is invalid // Return defaults if file doesn't exist or is invalid
if (error instanceof Error && error.message.includes('ENOENT')) { if (error instanceof Error && error.message.includes('ENOENT')) {
console.info('Using default app configuration'); console.info('Using default app configuration');
@ -58,6 +60,7 @@ export async function loadAppConfig(configPath?: string): Promise<AppConfig> {
return defaultAppConfig; return defaultAppConfig;
} }
console.warn(`Failed to load app config from ${filePath}:`, error);
throw error; throw error;
} }
} }
@ -66,7 +69,8 @@ export async function loadAppConfig(configPath?: string): Promise<AppConfig> {
* Lädt und validiert die Services-Konfiguration * Lädt und validiert die Services-Konfiguration
*/ */
export async function loadServices(servicesPath?: string): Promise<Services> { export async function loadServices(servicesPath?: string): Promise<Services> {
const filePath = servicesPath || resolveProjectPath('src/data/services.json'); await ensureDataDir();
const filePath = servicesPath || SERVICES_FILE_PATH;
try { try {
const servicesData = await fs.readFile(filePath, 'utf8'); const servicesData = await fs.readFile(filePath, 'utf8');
@ -77,8 +81,6 @@ export async function loadServices(servicesPath?: string): Promise<Services> {
return validatedServices; return validatedServices;
} catch (error) { } catch (error) {
console.warn(`Failed to load services from ${filePath}:`, error);
// Return empty array if file doesn't exist // Return empty array if file doesn't exist
if (error instanceof Error && error.message.includes('ENOENT')) { if (error instanceof Error && error.message.includes('ENOENT')) {
console.info('No services configuration found, using empty array'); console.info('No services configuration found, using empty array');
@ -91,6 +93,7 @@ export async function loadServices(servicesPath?: string): Promise<Services> {
return defaultServices; return defaultServices;
} }
console.warn(`Failed to load services from ${filePath}:`, error);
throw error; throw error;
} }
} }

40
src/lib/config/paths.ts Normal file
View file

@ -0,0 +1,40 @@
import 'server-only';
import path from 'path';
/**
* Zentrales Datenverzeichnis für persistente Daten
* Standard: /app/data in Docker, oder ./data lokal
*/
export const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data');
/**
* Pfad zur App-Konfigurationsdatei
*/
export const CONFIG_FILE_PATH = path.join(DATA_DIR, 'config.json');
/**
* Pfad zur Services-Konfigurationsdatei
*/
export const SERVICES_FILE_PATH = path.join(DATA_DIR, 'services.json');
/**
* Pfad zum Backup-Verzeichnis
*/
export const BACKUP_DIR_PATH = path.join(DATA_DIR, 'backups');
/**
* Pfad zur SQLite-Datenbank
*/
export const DATABASE_FILE_PATH = path.join(DATA_DIR, 'database.db');
/**
* Stellt sicher, dass das Datenverzeichnis existiert
*/
export async function ensureDataDir(): Promise<void> {
const fs = await import('fs/promises');
try {
await fs.access(DATA_DIR);
} catch {
await fs.mkdir(DATA_DIR, { recursive: true });
}
}

View file

@ -2,13 +2,15 @@ import 'server-only';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './schema'; import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './schema';
import { resolveProjectPath } from './load-config'; import { CONFIG_FILE_PATH, SERVICES_FILE_PATH, ensureDataDir } from './paths';
import { createBackup } from './backup-config';
/** /**
* Speichert die App-Konfiguration * Speichert die App-Konfiguration
*/ */
export async function saveAppConfig(config: AppConfig, configPath?: string): Promise<void> { export async function saveAppConfig(config: AppConfig, configPath?: string): Promise<void> {
const filePath = configPath || path.join(process.cwd(), 'config.json'); await ensureDataDir();
const filePath = configPath || CONFIG_FILE_PATH;
try { try {
// Validate before saving // Validate before saving
@ -32,7 +34,8 @@ export async function saveAppConfig(config: AppConfig, configPath?: string): Pro
* Speichert die Services-Konfiguration * Speichert die Services-Konfiguration
*/ */
export async function saveServices(services: Services, servicesPath?: string): Promise<void> { export async function saveServices(services: Services, servicesPath?: string): Promise<void> {
const filePath = servicesPath || resolveProjectPath('src/data/services.json'); await ensureDataDir();
const filePath = servicesPath || SERVICES_FILE_PATH;
try { try {
// Validate before saving // Validate before saving
@ -54,34 +57,8 @@ export async function saveServices(services: Services, servicesPath?: string): P
/** /**
* Erstellt ein Backup der aktuellen Konfiguration * Erstellt ein Backup der aktuellen Konfiguration
* @deprecated Use createBackup from backup-config.ts instead
*/ */
export async function backupConfig(backupDir?: string): Promise<string> { export async function backupConfig(backupDir?: string): Promise<string> {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); return createBackup(backupDir);
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'}`);
}
} }

12
src/lib/db/prisma.ts Normal file
View file

@ -0,0 +1,12 @@
import { PrismaClient } from '@prisma/client';
declare global {
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined;
}
export const prisma = globalThis.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalThis.prisma = prisma;
}

40
src/lib/db/user.ts Normal file
View file

@ -0,0 +1,40 @@
import { prisma } from './prisma';
export async function getUserByUsername(username: string) {
return prisma.user.findUnique({
where: { username },
});
}
export async function createUser(username: string, passwordHash: string, role = 'admin') {
return prisma.user.create({
data: {
username,
passwordHash,
role,
},
});
}
export async function hasAnyUser() {
const count = await prisma.user.count();
return count > 0;
}
export async function createInitialAdmin(username: string, passwordHash: string) {
return prisma.$transaction(async (tx: any) => {
const existingUsers = await tx.user.count();
if (existingUsers > 0) {
throw new Error('INITIAL_ADMIN_ALREADY_EXISTS');
}
return tx.user.create({
data: {
username,
passwordHash,
role: 'admin',
},
});
});
}

View file

@ -1,18 +1,15 @@
import { NextResponse } from 'next/server';
import { loadFullConfig } from '@/src/lib/config';
import type { Service } from '@/src/lib/config'; import type { Service } from '@/src/lib/config';
interface ServiceCheckResult { export interface ServiceCheckResult {
id: string; id: string;
status: 'online' | 'warning' | 'offline'; status: 'online' | 'warning' | 'offline';
responseTimeMs: number; responseTimeMs: number;
httpStatus: number | null; httpStatus: number | null;
checkedAt: string; checkedAt: string;
message?: string;
} }
export const dynamic = 'force-dynamic'; export async function checkService(service: Service): Promise<ServiceCheckResult> {
async function checkService(service: Service): Promise<ServiceCheckResult> {
const startTime = Date.now(); const startTime = Date.now();
let timeoutId: ReturnType<typeof setTimeout> | undefined; let timeoutId: ReturnType<typeof setTimeout> | undefined;
@ -51,12 +48,24 @@ async function checkService(service: Service): Promise<ServiceCheckResult> {
checkedAt: new Date().toISOString(), checkedAt: new Date().toISOString(),
}; };
} catch (error) { } catch (error) {
const responseTime = Date.now() - startTime;
let message = 'Unknown error';
if (error instanceof Error) {
if (error.name === 'AbortError') {
message = `Timeout after ${service.healthcheck?.timeoutMs || 5000}ms`;
} else {
message = error.message;
}
}
return { return {
id: service.id, id: service.id,
status: 'offline', status: 'offline',
responseTimeMs: Date.now() - startTime, responseTimeMs: responseTime,
httpStatus: null, httpStatus: null,
checkedAt: new Date().toISOString(), checkedAt: new Date().toISOString(),
message,
}; };
} finally { } finally {
if (timeoutId) { if (timeoutId) {
@ -64,18 +73,3 @@ async function checkService(service: Service): Promise<ServiceCheckResult> {
} }
} }
} }
export async function GET() {
try {
const { services } = await loadFullConfig();
const results = await Promise.all(services.map((service: Service) => checkService(service)));
return NextResponse.json(results);
} catch (error) {
console.error('Error checking services:', error);
return NextResponse.json(
{ error: 'Failed to check services' },
{ status: 500 }
);
}
}

View file

@ -16,7 +16,8 @@ export interface Service {
export interface ServiceCheckResult { export interface ServiceCheckResult {
id: string; id: string;
status: ServiceStatus; status: ServiceStatus;
responseTimeMs: number | null; responseTimeMs: number;
httpStatus: number | null; httpStatus: number | null;
checkedAt: string; checkedAt: string;
message?: string;
} }

19
types/next-auth.d.ts vendored Normal file
View file

@ -0,0 +1,19 @@
import type { DefaultSession, DefaultUser } from 'next-auth';
declare module 'next-auth' {
interface User extends DefaultUser {
username?: string;
}
interface Session {
user: {
username?: string;
} & DefaultSession['user'];
}
}
declare module 'next-auth/jwt' {
interface JWT {
username?: string;
}
}