v4
This commit is contained in:
parent
764223db6c
commit
69c2057252
41 changed files with 2293 additions and 301 deletions
15
.env.example
15
.env.example
|
|
@ -4,5 +4,20 @@ PORT=3000
|
|||
# Hostname for the server
|
||||
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)
|
||||
# STATUS_POLL_INTERVAL=30000
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
"extends": "next/core-web-vitals",
|
||||
"rules": {
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn"
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"@next/next/no-html-link-for-pages": "off"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ WORKDIR /app
|
|||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Generate Prisma client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
|
|
@ -44,6 +47,9 @@ COPY --from=builder /app/public ./public
|
|||
RUN mkdir .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
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
|
|
|
|||
295
README.md
295
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# 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
|
||||
|
||||
|
|
@ -9,12 +9,26 @@ Ein modernes, responsives Dashboard für die Verwaltung von Homelab-Diensten, ge
|
|||
- Responsive Grid-Layout für alle Bildschirmgrößen
|
||||
- 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-Karten mit Name, Beschreibung und Status
|
||||
- Farbcodierter Status-Indikator (Online, Warnung, Offline)
|
||||
- Direkte Links zu Services
|
||||
- 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**
|
||||
- Optimiert für Smartphones, Tablets und Desktop
|
||||
- Touch-freundliche Buttons
|
||||
|
|
@ -26,91 +40,177 @@ Ein modernes, responsives Dashboard für die Verwaltung von Homelab-Diensten, ge
|
|||
- Node.js 18+ installiert
|
||||
- npm oder yarn
|
||||
|
||||
### Schritt 1: Dependencies installieren
|
||||
### Schritt 1: Abhängigkeiten installieren
|
||||
|
||||
```bash
|
||||
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
|
||||
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
|
||||
|
||||
```
|
||||
homelab-dashboard/
|
||||
├── app/
|
||||
│ ├── components/
|
||||
│ │ ├── Header.tsx # Header-Komponente
|
||||
│ │ └── ServiceCard.tsx # Service-Karten-Komponente
|
||||
│ ├── globals.css # Globale Tailwind Styles
|
||||
│ ├── api/
|
||||
│ │ ├── auth/[...nextauth]/ # NextAuth API Route
|
||||
│ │ ├── config/[[...path]]/ # Config Management API
|
||||
│ │ └── status/ # Service Status Check API
|
||||
│ ├── admin/ # Admin-Interface
|
||||
│ ├── login/ # Login-Seite
|
||||
│ ├── layout.tsx # Root Layout
|
||||
│ └── page.tsx # Startseite
|
||||
├── lib/
|
||||
│ └── data.ts # Mock-Daten und Service-Typen
|
||||
│ └── page.tsx # Dashboard
|
||||
├── auth.ts # NextAuth Konfiguration
|
||||
├── 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
|
||||
├── postcss.config.js # PostCSS Konfiguration
|
||||
├── next.config.js # Next.js Konfiguration
|
||||
├── tsconfig.json # TypeScript Konfiguration
|
||||
└── 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
|
||||
|
||||
- `npm run dev` - Entwicklungsserver starten
|
||||
- `npm run build` - Produktions-Build erstellen
|
||||
- `npm start` - Produktions-Server starten
|
||||
- `npm run lint` - Linter ausführen
|
||||
```bash
|
||||
npm run dev # Entwicklungsserver starten
|
||||
npm run build # Produktions-Build erstellen
|
||||
npm start # Produktions-Server starten
|
||||
npm run lint # ESLint ausführen
|
||||
```
|
||||
|
||||
## Status-Indikatoren
|
||||
## Umgebungsvariablen
|
||||
|
||||
| Status | Farbe | Beschreibung |
|
||||
|--------|-------|-------------|
|
||||
| Online | 🟢 Grün | Service läuft einwandfrei |
|
||||
| Variable | Beschreibung | Beispiel |
|
||||
|----------|-------------|----------|
|
||||
| `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 |
|
||||
| Offline | 🔴 Rot | Service ist nicht erreichbar |
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Lokaler Start ohne Docker
|
||||
### Vorbereitung
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
1. **Umgebungsvariablen setzen**:
|
||||
```bash
|
||||
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
|
||||
# Build und starte mit docker-compose
|
||||
|
|
@ -118,42 +218,87 @@ docker-compose up --build
|
|||
|
||||
# Oder manuell:
|
||||
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
|
||||
# Build für Produktion
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Run-Befehl
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
### Docker Compose Nutzung
|
||||
|
||||
```bash
|
||||
# Start im Hintergrund
|
||||
# Mit Docker Compose starten
|
||||
docker-compose up -d
|
||||
|
||||
# Stoppen
|
||||
# Container stoppen
|
||||
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`
|
||||
- **services.json**: `/mnt/user/appdata/homelab-dashboard/services.json` → `/app/src/data/services.json`
|
||||
3. **Port-Mapping**:
|
||||
- **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
|
||||
|
||||
|
|
|
|||
6
app/api/auth/[...nextauth]/route.ts
Normal file
6
app/api/auth/[...nextauth]/route.ts
Normal 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 };
|
||||
247
app/api/config/[[...path]]/route.ts
Normal file
247
app/api/config/[[...path]]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
31
app/api/config/test-service/route.ts
Normal file
31
app/api/config/test-service/route.ts
Normal 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
64
app/api/setup/route.ts
Normal 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
21
app/api/status/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { Metadata as NextMetadata } from 'next';
|
||||
import { Providers } from '@/src/app/providers';
|
||||
|
||||
export const metadata: NextMetadata = {
|
||||
manifest: '/manifest.json',
|
||||
|
|
@ -12,7 +13,7 @@ export default function RootLayout({
|
|||
return (
|
||||
<html lang="de">
|
||||
<body className="antialiased">
|
||||
{children}
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
21
app/login/page.tsx
Normal file
21
app/login/page.tsx
Normal 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
19
app/setup/page.tsx
Normal 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
100
auth.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -4,13 +4,21 @@ services:
|
|||
homelab-dashboard:
|
||||
build: .
|
||||
container_name: homelab-dashboard
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
- ./src/data/services.json:/app/src/data/services.json:ro
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- HOSTNAME=0.0.0.0
|
||||
- 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
264
package-lock.json
generated
|
|
@ -8,7 +8,10 @@
|
|||
"name": "homelab-dashboard",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.22.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"next": "^16.2.3",
|
||||
"next-auth": "^4.24.14",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"zod": "^4.3.6"
|
||||
|
|
@ -23,6 +26,7 @@
|
|||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "^15.0.3",
|
||||
"postcss": "^8.4.32",
|
||||
"prisma": "^5.22.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
|
|
@ -40,6 +44,15 @@
|
|||
"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": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
||||
|
|
@ -992,6 +1005,83 @@
|
|||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
|
|
@ -1994,6 +2084,15 @@
|
|||
"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": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
|
|
@ -2265,6 +2364,15 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
|
@ -3381,7 +3489,6 @@
|
|||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
|
@ -4257,6 +4364,15 @@
|
|||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
|
@ -4425,6 +4541,18 @@
|
|||
"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": {
|
||||
"version": "1.1.0",
|
||||
"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": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
|
|
@ -4672,6 +4832,12 @@
|
|||
"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": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
|
@ -4805,6 +4971,15 @@
|
|||
"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": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
|
|
@ -4815,6 +4990,30 @@
|
|||
"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": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
|
|
@ -5167,6 +5366,28 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
|
|
@ -5177,6 +5398,32 @@
|
|||
"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": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
|
|
@ -6383,6 +6630,15 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
|
@ -6505,6 +6761,12 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -6,10 +6,15 @@
|
|||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"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": {
|
||||
"@prisma/client": "^5.22.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"next": "^16.2.3",
|
||||
"next-auth": "^4.24.14",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"zod": "^4.3.6"
|
||||
|
|
@ -24,6 +29,7 @@
|
|||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "^15.0.3",
|
||||
"postcss": "^8.4.32",
|
||||
"prisma": "^5.22.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,6 @@ const config = {
|
|||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default config
|
||||
export default config;
|
||||
BIN
prisma/dev.db
Normal file
BIN
prisma/dev.db
Normal file
Binary file not shown.
16
prisma/schema.prisma
Normal file
16
prisma/schema.prisma
Normal 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
11
proxy.ts
Normal 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
1
public/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
31
scripts/generate-password-hash.js
Normal file
31
scripts/generate-password-hash.js
Normal 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();
|
||||
});
|
||||
|
|
@ -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
8
src/app/providers.tsx
Normal 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>;
|
||||
}
|
||||
94
src/components/LoginForm.tsx
Normal file
94
src/components/LoginForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/components/SetupForm.tsx
Normal file
127
src/components/SetupForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
'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 { ServiceCheckResult } from '@/src/types/service';
|
||||
import BackupSection from './BackupSection';
|
||||
import ImportExportSection from './ImportExportSection';
|
||||
|
||||
const defaultService: Service = {
|
||||
id: '',
|
||||
|
|
@ -20,31 +24,48 @@ const themes = ['auto', 'light', 'dark'] as const;
|
|||
const loggingLevels = ['error', 'warn', 'info', 'debug'] as const;
|
||||
|
||||
export default function AdminPage() {
|
||||
const { status } = useSession();
|
||||
const [appConfig, setAppConfig] = useState<AppConfig | null>(null);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [originalAppConfig, setOriginalAppConfig] = useState<AppConfig | null>(null);
|
||||
const [originalServices, setOriginalServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adminToken, setAdminToken] = useState('');
|
||||
const [configLoaded, setConfigLoaded] = useState(false);
|
||||
const [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 () => {
|
||||
if (!adminToken.trim()) {
|
||||
setError('Admin-Token ist erforderlich, um die Konfiguration zu laden.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
});
|
||||
const response = await fetch('/api/config');
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => null);
|
||||
throw new Error(result?.error || `Failed to load config: ${response.statusText}`);
|
||||
|
|
@ -52,6 +73,8 @@ export default function AdminPage() {
|
|||
const data = await response.json();
|
||||
setAppConfig(data.appConfig);
|
||||
setServices(data.services);
|
||||
setOriginalAppConfig(data.appConfig);
|
||||
setOriginalServices(data.services);
|
||||
setConfigLoaded(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load config');
|
||||
|
|
@ -113,6 +136,45 @@ export default function AdminPage() {
|
|||
.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 = () => {
|
||||
if (!appConfig) {
|
||||
setError('Konfiguration wurde nicht geladen.');
|
||||
|
|
@ -124,11 +186,6 @@ export default function AdminPage() {
|
|||
return false;
|
||||
}
|
||||
|
||||
if (!adminToken) {
|
||||
setError('Admin-Token ist erforderlich, um Änderungen zu speichern.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedCategories = appConfig.categories.map((category) => category.trim()).filter(Boolean);
|
||||
if (normalizedCategories.length === 0) {
|
||||
setError('Mindestens eine gültige Kategorie ist erforderlich.');
|
||||
|
|
@ -183,6 +240,13 @@ export default function AdminPage() {
|
|||
return true;
|
||||
};
|
||||
|
||||
const discardChanges = () => {
|
||||
setAppConfig(originalAppConfig);
|
||||
setServices(originalServices);
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const saveConfig = async () => {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
|
@ -204,7 +268,6 @@ export default function AdminPage() {
|
|||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
appConfig: {
|
||||
|
|
@ -237,6 +300,13 @@ export default function AdminPage() {
|
|||
[services]
|
||||
);
|
||||
|
||||
// Load config on mount
|
||||
useEffect(() => {
|
||||
if (status === 'authenticated' && !configLoaded) {
|
||||
loadConfig().catch((err) => console.error('Failed to load config:', err));
|
||||
}
|
||||
}, [status, configLoaded]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
|
||||
<div className="max-w-8xl mx-auto px-4 py-8 sm:px-6 lg:px-8">
|
||||
|
|
@ -248,36 +318,33 @@ export default function AdminPage() {
|
|||
Hier kannst du globale Einstellungen und Services bearbeiten.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">Admin-Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={adminToken}
|
||||
onChange={(event) => setAdminToken(event.target.value)}
|
||||
className="w-full rounded-2xl border border-slate-300 bg-slate-50 px-4 py-2 text-sm dark:border-slate-700 dark:bg-slate-800"
|
||||
placeholder="Bearer-Token eingeben"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadConfig()}
|
||||
disabled={loading}
|
||||
className="w-full rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!configLoaded && !loading ? (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white/90 p-8 text-center text-slate-500 shadow-xl dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-300">
|
||||
Gib dein Admin-Token ein, um die Konfiguration zu laden.
|
||||
</div>
|
||||
) : loading ? (
|
||||
{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">
|
||||
Lade Konfiguration…
|
||||
</div>
|
||||
) : (
|
||||
) : configLoaded ? (
|
||||
<>
|
||||
{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">
|
||||
|
|
@ -433,26 +500,115 @@ export default function AdminPage() {
|
|||
/>
|
||||
</label>
|
||||
</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>
|
||||
</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="text-sm text-slate-500 dark:text-slate-400">
|
||||
Änderungen werden auf die lokale Konfigurationsdatei geschrieben.
|
||||
<div className="flex-1">
|
||||
<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>
|
||||
) : (
|
||||
<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
|
||||
type="button"
|
||||
onClick={saveConfig}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center justify-center rounded-2xl bg-slate-900 px-6 py-3 text-sm font-semibold text-white transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:bg-slate-500"
|
||||
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 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||
>
|
||||
{saving ? 'Speichere…' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
211
src/components/admin/BackupSection.tsx
Normal file
211
src/components/admin/BackupSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
src/components/admin/ImportExportSection.tsx
Normal file
155
src/components/admin/ImportExportSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import 'server-only';
|
||||
import { config } from '@/src/lib/config';
|
||||
import { getConfigSync } from '@/src/lib/config';
|
||||
import { servicesArraySchema } from '@/src/lib/config/schema';
|
||||
import { resolveProjectPath } from '@/src/lib/config/load-config';
|
||||
import type { Service } from '@/src/lib/config';
|
||||
|
|
@ -13,6 +13,7 @@ function loadServicesSync(): Service[] {
|
|||
if (!cachedServices) {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const config = getConfigSync();
|
||||
const servicesPath = resolveProjectPath(config.servicesFile);
|
||||
const servicesData = fs.readFileSync(servicesPath, 'utf8');
|
||||
cachedServices = servicesArraySchema.parse(JSON.parse(servicesData));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import 'server-only';
|
||||
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
|
||||
export type { AppConfig };
|
||||
|
|
@ -13,9 +16,7 @@ function loadConfigSync(): AppConfig {
|
|||
if (!cachedConfig) {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const configPath = path.join(process.cwd(), 'config.json');
|
||||
const configData = fs.readFileSync(configPath, 'utf8');
|
||||
const configData = fs.readFileSync(CONFIG_FILE_PATH, 'utf8');
|
||||
cachedConfig = appConfigSchema.parse(JSON.parse(configData));
|
||||
} catch (error) {
|
||||
console.warn('Failed to load config synchronously, using defaults');
|
||||
|
|
@ -23,7 +24,7 @@ function loadConfigSync(): AppConfig {
|
|||
title: 'Homelab Dashboard',
|
||||
subtitle: 'Live-Status aller verwalteten Dienste',
|
||||
description: 'Live-Monitoring-Dashboard für dein Homelab',
|
||||
servicesFile: 'src/data/services.json',
|
||||
servicesFile: DEFAULT_SERVICES_FILE,
|
||||
refreshInterval: 30000,
|
||||
categories: ['Infrastruktur', 'Smart Home', 'Medien', 'Dokumente'],
|
||||
theme: 'auto',
|
||||
|
|
@ -38,9 +39,7 @@ function loadServicesSync(): Services {
|
|||
if (!cachedServices) {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const config = loadConfigSync();
|
||||
const servicesPath = resolveProjectPath(config.servicesFile);
|
||||
const servicesData = fs.readFileSync(servicesPath, 'utf8');
|
||||
const servicesData = fs.readFileSync(SERVICES_FILE_PATH, 'utf8');
|
||||
cachedServices = servicesArraySchema.parse(JSON.parse(servicesData));
|
||||
} catch (error) {
|
||||
console.warn('Failed to load services synchronously, using empty array');
|
||||
|
|
@ -50,11 +49,17 @@ function loadServicesSync(): Services {
|
|||
return cachedServices;
|
||||
}
|
||||
|
||||
// Export cached versions for backward compatibility
|
||||
export const config: AppConfig = loadConfigSync();
|
||||
export const services: Services = loadServicesSync();
|
||||
// Legacy accessors for backward compatibility
|
||||
export function getConfigSync(): AppConfig {
|
||||
return loadConfigSync();
|
||||
}
|
||||
|
||||
export function getServicesSync(): Services {
|
||||
return loadServicesSync();
|
||||
}
|
||||
|
||||
// Export new async functions
|
||||
export { loadAppConfig, loadServices, loadFullConfig } from './config/load-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';
|
||||
|
|
|
|||
180
src/lib/config/backup-config.ts
Normal file
180
src/lib/config/backup-config.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
47
src/lib/config/import-export.ts
Normal file
47
src/lib/config/import-export.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -2,13 +2,16 @@ import 'server-only';
|
|||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
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
|
||||
const defaultAppConfig: AppConfig = {
|
||||
title: 'Homelab Dashboard',
|
||||
subtitle: 'Live-Status aller verwalteten Dienste',
|
||||
description: 'Live-Monitoring-Dashboard für dein Homelab',
|
||||
servicesFile: 'src/data/services.json',
|
||||
servicesFile: DEFAULT_SERVICES_FILE,
|
||||
refreshInterval: 30000,
|
||||
categories: ['Infrastruktur', 'Smart Home', 'Medien', 'Dokumente'],
|
||||
theme: 'auto',
|
||||
|
|
@ -33,7 +36,8 @@ function resolveProjectPath(relativePath: string): string {
|
|||
* Lädt und validiert die App-Konfiguration
|
||||
*/
|
||||
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 {
|
||||
const configData = await fs.readFile(filePath, 'utf8');
|
||||
|
|
@ -44,8 +48,6 @@ export async function loadAppConfig(configPath?: string): Promise<AppConfig> {
|
|||
|
||||
return validatedConfig;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load app config from ${filePath}:`, error);
|
||||
|
||||
// Return defaults if file doesn't exist or is invalid
|
||||
if (error instanceof Error && error.message.includes('ENOENT')) {
|
||||
console.info('Using default app configuration');
|
||||
|
|
@ -58,6 +60,7 @@ export async function loadAppConfig(configPath?: string): Promise<AppConfig> {
|
|||
return defaultAppConfig;
|
||||
}
|
||||
|
||||
console.warn(`Failed to load app config from ${filePath}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -66,7 +69,8 @@ export async function loadAppConfig(configPath?: string): Promise<AppConfig> {
|
|||
* Lädt und validiert die Services-Konfiguration
|
||||
*/
|
||||
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 {
|
||||
const servicesData = await fs.readFile(filePath, 'utf8');
|
||||
|
|
@ -77,8 +81,6 @@ export async function loadServices(servicesPath?: string): Promise<Services> {
|
|||
|
||||
return validatedServices;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load services from ${filePath}:`, error);
|
||||
|
||||
// Return empty array if file doesn't exist
|
||||
if (error instanceof Error && error.message.includes('ENOENT')) {
|
||||
console.info('No services configuration found, using empty array');
|
||||
|
|
@ -91,6 +93,7 @@ export async function loadServices(servicesPath?: string): Promise<Services> {
|
|||
return defaultServices;
|
||||
}
|
||||
|
||||
console.warn(`Failed to load services from ${filePath}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
40
src/lib/config/paths.ts
Normal file
40
src/lib/config/paths.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -2,13 +2,15 @@ import 'server-only';
|
|||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './schema';
|
||||
import { resolveProjectPath } from './load-config';
|
||||
import { CONFIG_FILE_PATH, SERVICES_FILE_PATH, ensureDataDir } from './paths';
|
||||
import { createBackup } from './backup-config';
|
||||
|
||||
/**
|
||||
* Speichert die App-Konfiguration
|
||||
*/
|
||||
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 {
|
||||
// Validate before saving
|
||||
|
|
@ -32,7 +34,8 @@ export async function saveAppConfig(config: AppConfig, configPath?: string): Pro
|
|||
* Speichert die Services-Konfiguration
|
||||
*/
|
||||
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 {
|
||||
// Validate before saving
|
||||
|
|
@ -54,34 +57,8 @@ export async function saveServices(services: Services, servicesPath?: string): P
|
|||
|
||||
/**
|
||||
* Erstellt ein Backup der aktuellen Konfiguration
|
||||
* @deprecated Use createBackup from backup-config.ts instead
|
||||
*/
|
||||
export async function backupConfig(backupDir?: string): Promise<string> {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupPath = backupDir || path.join(process.cwd(), 'backups');
|
||||
const backupFile = path.join(backupPath, `config-backup-${timestamp}.json`);
|
||||
|
||||
try {
|
||||
// Load current config
|
||||
const { loadFullConfig } = await import('./load-config');
|
||||
const { appConfig, services } = await loadFullConfig();
|
||||
|
||||
// Create backup data
|
||||
const backupData = {
|
||||
timestamp,
|
||||
appConfig,
|
||||
services,
|
||||
};
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(backupPath, { recursive: true });
|
||||
|
||||
// Write backup
|
||||
const backupJson = JSON.stringify(backupData, null, 2);
|
||||
await fs.writeFile(backupFile, backupJson, 'utf8');
|
||||
|
||||
return backupFile;
|
||||
} catch (error) {
|
||||
console.error('Failed to create backup:', error);
|
||||
throw new Error(`Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
return createBackup(backupDir);
|
||||
}
|
||||
|
|
|
|||
12
src/lib/db/prisma.ts
Normal file
12
src/lib/db/prisma.ts
Normal 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
40
src/lib/db/user.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,18 +1,15 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { loadFullConfig } from '@/src/lib/config';
|
||||
import type { Service } from '@/src/lib/config';
|
||||
|
||||
interface ServiceCheckResult {
|
||||
export interface ServiceCheckResult {
|
||||
id: string;
|
||||
status: 'online' | 'warning' | 'offline';
|
||||
responseTimeMs: number;
|
||||
httpStatus: number | null;
|
||||
checkedAt: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
async function checkService(service: Service): Promise<ServiceCheckResult> {
|
||||
export async function checkService(service: Service): Promise<ServiceCheckResult> {
|
||||
const startTime = Date.now();
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
|
|
@ -51,12 +48,24 @@ async function checkService(service: Service): Promise<ServiceCheckResult> {
|
|||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
} 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 {
|
||||
id: service.id,
|
||||
status: 'offline',
|
||||
responseTimeMs: Date.now() - startTime,
|
||||
responseTimeMs: responseTime,
|
||||
httpStatus: null,
|
||||
checkedAt: new Date().toISOString(),
|
||||
message,
|
||||
};
|
||||
} finally {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,8 @@ export interface Service {
|
|||
export interface ServiceCheckResult {
|
||||
id: string;
|
||||
status: ServiceStatus;
|
||||
responseTimeMs: number | null;
|
||||
responseTimeMs: number;
|
||||
httpStatus: number | null;
|
||||
checkedAt: string;
|
||||
message?: string;
|
||||
}
|
||||
19
types/next-auth.d.ts
vendored
Normal file
19
types/next-auth.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue