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 for the server
|
||||||
HOSTNAME=0.0.0.0
|
HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
# NextAuth Configuration
|
||||||
|
# Secret for signing tokens (generate with: openssl rand -base64 32)
|
||||||
|
NEXTAUTH_SECRET=your-generated-secret-key
|
||||||
|
|
||||||
|
# Data directory for persistent storage (config, backups, database)
|
||||||
|
DATA_DIR=./data
|
||||||
|
|
||||||
|
# Admin username for login
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
|
||||||
|
# Admin password hash (generated with bcryptjs)
|
||||||
|
# To generate a hash, run: node -e "console.log(require('bcryptjs').hashSync('your-password', 10))"
|
||||||
|
# Example hash for password 'admin': $2a$10$your-bcrypt-hash-here
|
||||||
|
ADMIN_PASSWORD_HASH=$2a$10$your-bcrypt-hash-here
|
||||||
|
|
||||||
# Optional: Polling interval for status checks (in milliseconds)
|
# Optional: Polling interval for status checks (in milliseconds)
|
||||||
# STATUS_POLL_INTERVAL=30000
|
# STATUS_POLL_INTERVAL=30000
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
"extends": "next/core-web-vitals",
|
"extends": "next/core-web-vitals",
|
||||||
"rules": {
|
"rules": {
|
||||||
"react-hooks/rules-of-hooks": "error",
|
"react-hooks/rules-of-hooks": "error",
|
||||||
"react-hooks/exhaustive-deps": "warn"
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
|
"@next/next/no-html-link-for-pages": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Generate Prisma client
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Next.js collects completely anonymous telemetry data about general usage.
|
# Next.js collects completely anonymous telemetry data about general usage.
|
||||||
# Learn more here: https://nextjs.org/telemetry
|
# Learn more here: https://nextjs.org/telemetry
|
||||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||||
|
|
@ -44,6 +47,9 @@ COPY --from=builder /app/public ./public
|
||||||
RUN mkdir .next
|
RUN mkdir .next
|
||||||
RUN chown nextjs:nodejs .next
|
RUN chown nextjs:nodejs .next
|
||||||
|
|
||||||
|
# Create data directory for persistent storage
|
||||||
|
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
||||||
|
|
||||||
# Automatically leverage output traces to reduce image size
|
# Automatically leverage output traces to reduce image size
|
||||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
|
|
||||||
303
README.md
303
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Homelab Dashboard
|
# Homelab Dashboard
|
||||||
|
|
||||||
Ein modernes, responsives Dashboard für die Verwaltung von Homelab-Diensten, gebaut mit **Next.js 15**, **TypeScript** und **Tailwind CSS**.
|
Ein modernes, responsives Dashboard für die Verwaltung von Homelab-Diensten, gebaut mit **Next.js 15**, **TypeScript**, **Tailwind CSS** und **NextAuth.js**.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|
@ -9,12 +9,26 @@ Ein modernes, responsives Dashboard für die Verwaltung von Homelab-Diensten, ge
|
||||||
- Responsive Grid-Layout für alle Bildschirmgrößen
|
- Responsive Grid-Layout für alle Bildschirmgrößen
|
||||||
- Smooth Animations und Übergänge
|
- Smooth Animations und Übergänge
|
||||||
|
|
||||||
|
🔐 **Sichere Admin-Authentifizierung**
|
||||||
|
- Session-basierte Authentifizierung mit NextAuth.js
|
||||||
|
- Benutzername/Passwort Login
|
||||||
|
- Sichere Passwort-Hashing mit bcryptjs
|
||||||
|
- Automatisches Logout bei Inaktivität
|
||||||
|
|
||||||
🎯 **Service-Management**
|
🎯 **Service-Management**
|
||||||
- Service-Karten mit Name, Beschreibung und Status
|
- Service-Karten mit Name, Beschreibung und Status
|
||||||
- Farbcodierter Status-Indikator (Online, Warnung, Offline)
|
- Farbcodierter Status-Indikator (Online, Warnung, Offline)
|
||||||
- Direkte Links zu Services
|
- Direkte Links zu Services
|
||||||
- Pulsierender Status-Punkt für visuelle Rückmeldung
|
- Pulsierender Status-Punkt für visuelle Rückmeldung
|
||||||
|
|
||||||
|
⚙️ **Admin-Funktionen**
|
||||||
|
- Globale Konfigurationsoptionen (Polling, Theme, Logging)
|
||||||
|
- Service-Editor mit Healthcheck-Konfiguration
|
||||||
|
- Server-seitige Test-Funktion für Dienste
|
||||||
|
- Automatische und manuelle Backups
|
||||||
|
- Konfiguration Import/Export
|
||||||
|
- Dirty-State-Erkennung mit Undo-Funktionalität
|
||||||
|
|
||||||
📱 **Mobile-First Design**
|
📱 **Mobile-First Design**
|
||||||
- Optimiert für Smartphones, Tablets und Desktop
|
- Optimiert für Smartphones, Tablets und Desktop
|
||||||
- Touch-freundliche Buttons
|
- Touch-freundliche Buttons
|
||||||
|
|
@ -26,91 +40,177 @@ Ein modernes, responsives Dashboard für die Verwaltung von Homelab-Diensten, ge
|
||||||
- Node.js 18+ installiert
|
- Node.js 18+ installiert
|
||||||
- npm oder yarn
|
- npm oder yarn
|
||||||
|
|
||||||
### Schritt 1: Dependencies installieren
|
### Schritt 1: Abhängigkeiten installieren
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Schritt 2: Entwicklungsserver starten
|
### Schritt 1.1: Prisma Client generieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run prisma:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 1.2: Datenbank-Schema anwenden
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run prisma:db-push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 2: Umgebungsvariablen konfigurieren
|
||||||
|
|
||||||
|
Kopiere `.env.example` zu `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Bearbeite `.env` und setze die erforderlichen Variablen:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PORT=3000
|
||||||
|
HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
# NextAuth Secret (generieren mit: openssl rand -base64 32)
|
||||||
|
NEXTAUTH_SECRET=generate-with-openssl-rand-base64-32
|
||||||
|
|
||||||
|
# SQLite database URL für persistenten Benutzerzugriff
|
||||||
|
DATABASE_URL=file:./dev.db
|
||||||
|
|
||||||
|
# Admin-Anmeldedaten (optional, solange noch kein erster Benutzer angelegt wurde)
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD_HASH=<siehe unten>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 3: Admin-Passwort-Hash generieren
|
||||||
|
|
||||||
|
Um den Passwort-Hash zu generieren, führe aus:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/generate-password-hash.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Script fordert dich auf, ein Passwort einzugeben und gibt einen bcryptjs-Hash aus.
|
||||||
|
|
||||||
|
Kopiere den Hash in deine `.env` Datei als `ADMIN_PASSWORD_HASH`.
|
||||||
|
|
||||||
|
### Schritt 4: Entwicklungsserver starten
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Server läuft dann unter `http://localhost:3000`
|
Der Server läuft unter `http://localhost:3000`
|
||||||
|
|
||||||
|
## Login & Admin-Zugang
|
||||||
|
|
||||||
|
1. Navigiere zu `http://localhost:3000/setup`, wenn noch kein Administrator angelegt wurde.
|
||||||
|
2. Erstelle den ersten Admin-Benutzer.
|
||||||
|
3. Danach melde dich unter `http://localhost:3000/login` an.
|
||||||
|
4. Nach erfolgreichem Login wird zu `/admin` weitergeleitet
|
||||||
|
|
||||||
|
## Admin-Interface
|
||||||
|
|
||||||
|
Im Admin-Bereich kannst du:
|
||||||
|
|
||||||
|
- **Globale Einstellungen** bearbeiten (Polling-Intervall, Theme, Logging-Level)
|
||||||
|
- **Services** verwalten (Hinzufügen, Bearbeiten, Löschen)
|
||||||
|
- **Healthchecks** konfigurieren (Timeout, akzeptierte Status-Codes)
|
||||||
|
- **Services testen** (direkt von der Admin-UI aus)
|
||||||
|
- **Backups** erstellen und wiederherstellen
|
||||||
|
- **Konfiguration** exportieren und importieren
|
||||||
|
|
||||||
## Projekt-Struktur
|
## Projekt-Struktur
|
||||||
|
|
||||||
```
|
```
|
||||||
homelab-dashboard/
|
homelab-dashboard/
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── components/
|
│ ├── api/
|
||||||
│ │ ├── Header.tsx # Header-Komponente
|
│ │ ├── auth/[...nextauth]/ # NextAuth API Route
|
||||||
│ │ └── ServiceCard.tsx # Service-Karten-Komponente
|
│ │ ├── config/[[...path]]/ # Config Management API
|
||||||
│ ├── globals.css # Globale Tailwind Styles
|
│ │ └── status/ # Service Status Check API
|
||||||
│ ├── layout.tsx # Root Layout
|
│ ├── admin/ # Admin-Interface
|
||||||
│ └── page.tsx # Startseite
|
│ ├── login/ # Login-Seite
|
||||||
├── lib/
|
│ ├── layout.tsx # Root Layout
|
||||||
│ └── data.ts # Mock-Daten und Service-Typen
|
│ └── page.tsx # Dashboard
|
||||||
├── tailwind.config.ts # Tailwind Konfiguration
|
├── auth.ts # NextAuth Konfiguration
|
||||||
├── postcss.config.js # PostCSS Konfiguration
|
├── middleware.ts # Auth-Middleware
|
||||||
├── next.config.js # Next.js Konfiguration
|
├── src/
|
||||||
├── tsconfig.json # TypeScript Konfiguration
|
│ ├── app/
|
||||||
└── package.json # Abhängigkeiten
|
│ │ ├── 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
|
||||||
|
├── 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
|
## Verfügbare Commands
|
||||||
|
|
||||||
- `npm run dev` - Entwicklungsserver starten
|
```bash
|
||||||
- `npm run build` - Produktions-Build erstellen
|
npm run dev # Entwicklungsserver starten
|
||||||
- `npm start` - Produktions-Server starten
|
npm run build # Produktions-Build erstellen
|
||||||
- `npm run lint` - Linter ausführen
|
npm start # Produktions-Server starten
|
||||||
|
npm run lint # ESLint ausführen
|
||||||
|
```
|
||||||
|
|
||||||
## Status-Indikatoren
|
## Umgebungsvariablen
|
||||||
|
|
||||||
| Status | Farbe | Beschreibung |
|
| Variable | Beschreibung | Beispiel |
|
||||||
|--------|-------|-------------|
|
|----------|-------------|----------|
|
||||||
| Online | 🟢 Grün | Service läuft einwandfrei |
|
| `PORT` | Port für den Server | `3000` |
|
||||||
|
| `HOSTNAME` | Server-Hostname | `0.0.0.0` |
|
||||||
|
| `NEXTAUTH_SECRET` | Secret für JWT-Signing | `openssl rand -base64 32` |
|
||||||
|
| `DATABASE_URL` | SQLite-Verbindungs-URL für Prisma | `file:./dev.db` |
|
||||||
|
| `ADMIN_USERNAME` | Admin-Benutzername | `admin` |
|
||||||
|
| `ADMIN_PASSWORD_HASH` | Bcryptjs-Hash des Admin-Passworts | `$2a$10$...` |
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- ✅ Session-basierte Authentifizierung (nicht Token-basiert)
|
||||||
|
- ✅ Sichere Passwort-Hashing mit bcryptjs
|
||||||
|
- ✅ Geschützte API-Routen (nur für authentifizierte Benutzer)
|
||||||
|
- ✅ CSRF-Schutz durch NextAuth
|
||||||
|
- ✅ Sichere HTTP-Only Cookies
|
||||||
|
- ✅ Keine Speicherung sensibler Daten im Frontend
|
||||||
|
|
||||||
|
### Für Produktionsumgebungen beachte:
|
||||||
|
|
||||||
|
1. **Starkes Passwort wählen** (mindestens 12 Zeichen, Umlaute, Sonderzeichen)
|
||||||
|
2. **NEXTAUTH_SECRET** mit `openssl rand -base64 32` generieren
|
||||||
|
3. **HTTPS aktivieren** in der Produktionsumgebung
|
||||||
|
4. **Admin-Seite mit zusätzlichem Firewall-Schutz** (Optional)
|
||||||
|
5. **Regelmäßige Backups** durchführen
|
||||||
|
|
||||||
|
## Konfigurationsdateien
|
||||||
|
|
||||||
|
### `config.json`
|
||||||
| Warnung | 🟡 Gelb | Service läuft, aber mit Performance-Problemen |
|
| Warnung | 🟡 Gelb | Service läuft, aber mit Performance-Problemen |
|
||||||
| Offline | 🔴 Rot | Service ist nicht erreichbar |
|
| Offline | 🔴 Rot | Service ist nicht erreichbar |
|
||||||
|
|
||||||
## Docker Deployment
|
## Docker Deployment
|
||||||
|
|
||||||
### Lokaler Start ohne Docker
|
### Vorbereitung
|
||||||
|
|
||||||
```bash
|
1. **Umgebungsvariablen setzen**:
|
||||||
npm install
|
```bash
|
||||||
npm run dev
|
cp .env.example .env
|
||||||
```
|
# Bearbeite .env mit deinen Werten
|
||||||
|
```
|
||||||
|
|
||||||
### Lokaler Start mit Docker
|
2. **Datenverzeichnis erstellen** (optional, wird automatisch erstellt):
|
||||||
|
```bash
|
||||||
|
mkdir -p data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lokaler Docker-Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build und starte mit docker-compose
|
# Build und starte mit docker-compose
|
||||||
|
|
@ -118,42 +218,87 @@ docker-compose up --build
|
||||||
|
|
||||||
# Oder manuell:
|
# Oder manuell:
|
||||||
docker build -t homelab-dashboard .
|
docker build -t homelab-dashboard .
|
||||||
docker run -p 3000:3000 homelab-dashboard
|
docker run -p 3000:3000 \
|
||||||
|
-e NEXTAUTH_SECRET=your-secret \
|
||||||
|
-e DATABASE_URL=file:/app/data/database.db \
|
||||||
|
-e DATA_DIR=/app/data \
|
||||||
|
-v $(pwd)/data:/app/data \
|
||||||
|
homelab-dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build-Befehl
|
### Produktions-Deployment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Build für Produktion
|
||||||
npm run build
|
npm run build
|
||||||
```
|
|
||||||
|
|
||||||
### Run-Befehl
|
# Mit Docker Compose starten
|
||||||
|
|
||||||
```bash
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker Compose Nutzung
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start im Hintergrund
|
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|
||||||
# Stoppen
|
# Container stoppen
|
||||||
docker-compose down
|
docker-compose down
|
||||||
|
|
||||||
# Logs anzeigen
|
|
||||||
docker-compose logs -f homelab-dashboard
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Unraid Volume-Mappings
|
### Unraid-Installation
|
||||||
|
|
||||||
Für Unraid empfiehlt es sich, die Konfigurationsdateien als Volumes zu mounten:
|
1. **Docker-Tab in Unraid öffnen**
|
||||||
|
2. **Neuen Container hinzufügen**:
|
||||||
|
- **Name**: homelab-dashboard
|
||||||
|
- **Repository**: dein-registry/homelab-dashboard:latest (oder build lokal)
|
||||||
|
- **Docker Hub Search**: Suche nach deinem Image
|
||||||
|
|
||||||
- **config.json**: `/mnt/user/appdata/homelab-dashboard/config.json` → `/app/config.json`
|
3. **Port-Mapping**:
|
||||||
- **services.json**: `/mnt/user/appdata/homelab-dashboard/services.json` → `/app/src/data/services.json`
|
- **Container Port**: 3000
|
||||||
|
- **Host Port**: 3000 (oder gewünschter Port)
|
||||||
|
|
||||||
Dadurch können Konfigurationen außerhalb des Containers verwaltet werden.
|
4. **Volume-Mappings** (für persistente Daten):
|
||||||
|
- **Container Path**: /app/data
|
||||||
|
- **Host Path**: /mnt/user/appdata/homelab-dashboard/data
|
||||||
|
|
||||||
|
5. **Environment Variables**:
|
||||||
|
- **NEXTAUTH_SECRET**: Dein generierter Secret (openssl rand -base64 32)
|
||||||
|
- **DATABASE_URL**: file:/app/data/database.db
|
||||||
|
- **DATA_DIR**: /app/data
|
||||||
|
- **NODE_ENV**: production
|
||||||
|
|
||||||
|
6. **Container starten**
|
||||||
|
|
||||||
|
### Persistente Daten
|
||||||
|
|
||||||
|
Unter `/app/data` werden gespeichert:
|
||||||
|
- `config.json` - App-Konfiguration
|
||||||
|
- `services.json` - Service-Konfiguration
|
||||||
|
- `backups/` - Backup-Dateien
|
||||||
|
- `database.db` - SQLite-Datenbank für Benutzer
|
||||||
|
|
||||||
|
### Docker Compose für Unraid-ähnliche Umgebungen
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
homelab-dashboard:
|
||||||
|
image: homelab-dashboard:latest
|
||||||
|
container_name: homelab-dashboard
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=3000
|
||||||
|
- HOSTNAME=0.0.0.0
|
||||||
|
- NEXTAUTH_SECRET=your-nextauth-secret
|
||||||
|
- DATABASE_URL=file:/app/data/database.db
|
||||||
|
- DATA_DIR=/app/data
|
||||||
|
volumes:
|
||||||
|
- /mnt/user/appdata/homelab-dashboard/data:/app/data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
- **Container startet nicht**: Logs prüfen mit `docker-compose logs`
|
||||||
|
- **Daten nicht persistent**: Volume-Mapping überprüfen
|
||||||
|
- **Authentifizierung fehlt**: NEXTAUTH_SECRET setzen
|
||||||
|
- **Datenbank-Fehler**: DATABASE_URL und DATA_DIR prüfen
|
||||||
|
|
||||||
## Konfiguration
|
## Konfiguration
|
||||||
|
|
||||||
|
|
|
||||||
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 { Metadata as NextMetadata } from 'next';
|
||||||
|
import { Providers } from '@/src/app/providers';
|
||||||
|
|
||||||
export const metadata: NextMetadata = {
|
export const metadata: NextMetadata = {
|
||||||
manifest: '/manifest.json',
|
manifest: '/manifest.json',
|
||||||
|
|
@ -12,7 +13,7 @@ export default function RootLayout({
|
||||||
return (
|
return (
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
{children}
|
<Providers>{children}</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
21
app/login/page.tsx
Normal file
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:
|
homelab-dashboard:
|
||||||
build: .
|
build: .
|
||||||
container_name: homelab-dashboard
|
container_name: homelab-dashboard
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- ./config.json:/app/config.json:ro
|
|
||||||
- ./src/data/services.json:/app/src/data/services.json:ro
|
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
- HOSTNAME=0.0.0.0
|
- HOSTNAME=0.0.0.0
|
||||||
|
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||||
|
- DATABASE_URL=file:/app/data/database.db
|
||||||
|
- DATA_DIR=/app/data
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
networks:
|
||||||
|
- homelab
|
||||||
|
|
||||||
|
networks:
|
||||||
|
homelab:
|
||||||
|
driver: bridge
|
||||||
264
package-lock.json
generated
264
package-lock.json
generated
|
|
@ -8,7 +8,10 @@
|
||||||
"name": "homelab-dashboard",
|
"name": "homelab-dashboard",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"next": "^16.2.3",
|
"next": "^16.2.3",
|
||||||
|
"next-auth": "^4.24.14",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
|
|
@ -23,6 +26,7 @@
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-next": "^15.0.3",
|
"eslint-config-next": "^15.0.3",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
|
|
@ -40,6 +44,15 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||||
|
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.9.2",
|
"version": "1.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
||||||
|
|
@ -992,6 +1005,83 @@
|
||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@panva/hkdf": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/client": {
|
||||||
|
"version": "5.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
|
||||||
|
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.13"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"prisma": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"prisma": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/debug": {
|
||||||
|
"version": "5.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
|
||||||
|
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/engines": {
|
||||||
|
"version": "5.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
|
||||||
|
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
|
||||||
|
"devOptional": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/debug": "5.22.0",
|
||||||
|
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
|
||||||
|
"@prisma/fetch-engine": "5.22.0",
|
||||||
|
"@prisma/get-platform": "5.22.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/engines-version": {
|
||||||
|
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
|
||||||
|
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/fetch-engine": {
|
||||||
|
"version": "5.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
|
||||||
|
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/debug": "5.22.0",
|
||||||
|
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
|
||||||
|
"@prisma/get-platform": "5.22.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/get-platform": {
|
||||||
|
"version": "5.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
|
||||||
|
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/debug": "5.22.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
|
|
@ -1994,6 +2084,15 @@
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"bin": {
|
||||||
|
"bcrypt": "bin/bcrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
|
|
@ -2265,6 +2364,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
|
@ -3381,7 +3489,6 @@
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|
@ -4257,6 +4364,15 @@
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "4.15.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||||
|
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|
@ -4425,6 +4541,18 @@
|
||||||
"loose-envify": "cli.js"
|
"loose-envify": "cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lru-cache": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|
@ -4598,6 +4726,38 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-auth": {
|
||||||
|
"version": "4.24.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.14.tgz",
|
||||||
|
"integrity": "sha512-YRz6xFDXKUwiXSMMChbrBEWyFktZ1qZXEgeSHQQ3nsy08B4c/xLk6REeutRsIFwkjY/1+ShHnu07DN3JeJguig==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@panva/hkdf": "^1.0.2",
|
||||||
|
"cookie": "^0.7.0",
|
||||||
|
"jose": "^4.15.5",
|
||||||
|
"oauth": "^0.9.15",
|
||||||
|
"openid-client": "^5.4.0",
|
||||||
|
"preact": "^10.6.3",
|
||||||
|
"preact-render-to-string": "^5.1.19",
|
||||||
|
"uuid": "^8.3.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@auth/core": "0.34.3",
|
||||||
|
"next": "^12.2.5 || ^13 || ^14 || ^15 || ^16",
|
||||||
|
"nodemailer": "^7.0.7",
|
||||||
|
"react": "^17.0.2 || ^18 || ^19",
|
||||||
|
"react-dom": "^17.0.2 || ^18 || ^19"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@auth/core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"nodemailer": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/next/node_modules/postcss": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
|
|
@ -4672,6 +4832,12 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oauth": {
|
||||||
|
"version": "0.9.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
|
||||||
|
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
|
@ -4805,6 +4971,15 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oidc-token-hash": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^10.13.0 || >=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/once": {
|
"node_modules/once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
|
|
@ -4815,6 +4990,30 @@
|
||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openid-client": {
|
||||||
|
"version": "5.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||||
|
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^4.15.9",
|
||||||
|
"lru-cache": "^6.0.0",
|
||||||
|
"object-hash": "^2.2.0",
|
||||||
|
"oidc-token-hash": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client/node_modules/object-hash": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
|
|
@ -5167,6 +5366,28 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.29.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz",
|
||||||
|
"integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/preact-render-to-string": {
|
||||||
|
"version": "5.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz",
|
||||||
|
"integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pretty-format": "^3.8.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
|
|
@ -5177,6 +5398,32 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pretty-format": {
|
||||||
|
"version": "3.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
|
||||||
|
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/prisma": {
|
||||||
|
"version": "5.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
|
||||||
|
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
|
||||||
|
"devOptional": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/engines": "5.22.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"prisma": "build/index.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.13"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
|
|
@ -6383,6 +6630,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "8.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
|
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|
@ -6505,6 +6761,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/yallist": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,15 @@
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint app src lib --ext .ts,.tsx"
|
"lint": "eslint app src lib --ext .ts,.tsx",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:db-push": "prisma db push"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"next": "^16.2.3",
|
"next": "^16.2.3",
|
||||||
|
"next-auth": "^4.24.14",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
|
|
@ -24,6 +29,7 @@
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-next": "^15.0.3",
|
"eslint-config-next": "^15.0.3",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,6 @@ const config = {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useSession, signOut } from 'next-auth/react';
|
||||||
import type { AppConfig, Service } from '@/src/lib/config';
|
import type { AppConfig, Service } from '@/src/lib/config';
|
||||||
|
import type { ServiceCheckResult } from '@/src/types/service';
|
||||||
|
import BackupSection from './BackupSection';
|
||||||
|
import ImportExportSection from './ImportExportSection';
|
||||||
|
|
||||||
const defaultService: Service = {
|
const defaultService: Service = {
|
||||||
id: '',
|
id: '',
|
||||||
|
|
@ -20,31 +24,48 @@ const themes = ['auto', 'light', 'dark'] as const;
|
||||||
const loggingLevels = ['error', 'warn', 'info', 'debug'] as const;
|
const loggingLevels = ['error', 'warn', 'info', 'debug'] as const;
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
|
const { status } = useSession();
|
||||||
const [appConfig, setAppConfig] = useState<AppConfig | null>(null);
|
const [appConfig, setAppConfig] = useState<AppConfig | null>(null);
|
||||||
const [services, setServices] = useState<Service[]>([]);
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
|
const [originalAppConfig, setOriginalAppConfig] = useState<AppConfig | null>(null);
|
||||||
|
const [originalServices, setOriginalServices] = useState<Service[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [adminToken, setAdminToken] = useState('');
|
|
||||||
const [configLoaded, setConfigLoaded] = useState(false);
|
const [configLoaded, setConfigLoaded] = useState(false);
|
||||||
|
const [testResults, setTestResults] = useState<Record<string, ServiceCheckResult | null>>({});
|
||||||
|
const [testingServices, setTestingServices] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const configsAreEqual = (config1: AppConfig | null, services1: Service[], config2: AppConfig | null, services2: Service[]): boolean => {
|
||||||
|
if (!config1 || !config2) return config1 === config2;
|
||||||
|
return JSON.stringify({ config: config1, services: services1 }) === JSON.stringify({ config: config2, services: services2 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDirty = useMemo(() => {
|
||||||
|
if (!configLoaded) return false;
|
||||||
|
return !configsAreEqual(appConfig, services, originalAppConfig, originalServices);
|
||||||
|
}, [appConfig, services, originalAppConfig, originalServices, configLoaded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDirty) return;
|
||||||
|
|
||||||
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
}, [isDirty]);
|
||||||
|
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
if (!adminToken.trim()) {
|
|
||||||
setError('Admin-Token ist erforderlich, um die Konfiguration zu laden.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/config', {
|
const response = await fetch('/api/config');
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${adminToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const result = await response.json().catch(() => null);
|
const result = await response.json().catch(() => null);
|
||||||
throw new Error(result?.error || `Failed to load config: ${response.statusText}`);
|
throw new Error(result?.error || `Failed to load config: ${response.statusText}`);
|
||||||
|
|
@ -52,6 +73,8 @@ export default function AdminPage() {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setAppConfig(data.appConfig);
|
setAppConfig(data.appConfig);
|
||||||
setServices(data.services);
|
setServices(data.services);
|
||||||
|
setOriginalAppConfig(data.appConfig);
|
||||||
|
setOriginalServices(data.services);
|
||||||
setConfigLoaded(true);
|
setConfigLoaded(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load config');
|
setError(err instanceof Error ? err.message : 'Failed to load config');
|
||||||
|
|
@ -113,6 +136,45 @@ export default function AdminPage() {
|
||||||
.map((item) => Number(item));
|
.map((item) => Number(item));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const testService = async (service: Service) => {
|
||||||
|
setTestingServices((prev) => new Set(prev).add(service.id));
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/config/test-service', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(service),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const result = await response.json().catch(() => null);
|
||||||
|
throw new Error(result?.error || `Failed to test service: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ServiceCheckResult = await response.json();
|
||||||
|
setTestResults((prev) => ({ ...prev, [service.id]: result }));
|
||||||
|
} catch (err) {
|
||||||
|
const errorResult: ServiceCheckResult = {
|
||||||
|
id: service.id,
|
||||||
|
status: 'offline',
|
||||||
|
responseTimeMs: 0,
|
||||||
|
httpStatus: null,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
message: err instanceof Error ? err.message : 'Test fehlgeschlagen',
|
||||||
|
};
|
||||||
|
setTestResults((prev) => ({ ...prev, [service.id]: errorResult }));
|
||||||
|
} finally {
|
||||||
|
setTestingServices((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(service.id);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
if (!appConfig) {
|
if (!appConfig) {
|
||||||
setError('Konfiguration wurde nicht geladen.');
|
setError('Konfiguration wurde nicht geladen.');
|
||||||
|
|
@ -124,11 +186,6 @@ export default function AdminPage() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!adminToken) {
|
|
||||||
setError('Admin-Token ist erforderlich, um Änderungen zu speichern.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedCategories = appConfig.categories.map((category) => category.trim()).filter(Boolean);
|
const normalizedCategories = appConfig.categories.map((category) => category.trim()).filter(Boolean);
|
||||||
if (normalizedCategories.length === 0) {
|
if (normalizedCategories.length === 0) {
|
||||||
setError('Mindestens eine gültige Kategorie ist erforderlich.');
|
setError('Mindestens eine gültige Kategorie ist erforderlich.');
|
||||||
|
|
@ -183,6 +240,13 @@ export default function AdminPage() {
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const discardChanges = () => {
|
||||||
|
setAppConfig(originalAppConfig);
|
||||||
|
setServices(originalServices);
|
||||||
|
setMessage(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
const saveConfig = async () => {
|
const saveConfig = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
@ -204,7 +268,6 @@ export default function AdminPage() {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${adminToken}`,
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
appConfig: {
|
appConfig: {
|
||||||
|
|
@ -237,6 +300,13 @@ export default function AdminPage() {
|
||||||
[services]
|
[services]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Load config on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'authenticated' && !configLoaded) {
|
||||||
|
loadConfig().catch((err) => console.error('Failed to load config:', err));
|
||||||
|
}
|
||||||
|
}, [status, configLoaded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
|
<div className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
|
||||||
<div className="max-w-8xl mx-auto px-4 py-8 sm:px-6 lg:px-8">
|
<div className="max-w-8xl mx-auto px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
|
@ -248,36 +318,33 @@ export default function AdminPage() {
|
||||||
Hier kannst du globale Einstellungen und Services bearbeiten.
|
Hier kannst du globale Einstellungen und Services bearbeiten.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="flex gap-2">
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">Admin-Token</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={adminToken}
|
|
||||||
onChange={(event) => setAdminToken(event.target.value)}
|
|
||||||
className="w-full rounded-2xl border border-slate-300 bg-slate-50 px-4 py-2 text-sm dark:border-slate-700 dark:bg-slate-800"
|
|
||||||
placeholder="Bearer-Token eingeben"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void loadConfig()}
|
onClick={() => void loadConfig()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700"
|
className="rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700"
|
||||||
>
|
>
|
||||||
{loading ? 'Lade…' : configLoaded ? 'Neu laden' : 'Konfiguration laden'}
|
{loading ? 'Lade…' : 'Neu laden'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
signOut({ redirect: true });
|
||||||
|
}}
|
||||||
|
className="rounded-2xl border border-rose-300 bg-rose-50 px-4 py-2 text-sm font-medium text-rose-700 transition hover:bg-rose-100 dark:border-rose-600 dark:bg-rose-950/40 dark:text-rose-200"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!configLoaded && !loading ? (
|
{loading && !configLoaded ? (
|
||||||
<div className="rounded-3xl border border-slate-200 bg-white/90 p-8 text-center text-slate-500 shadow-xl dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-300">
|
|
||||||
Gib dein Admin-Token ein, um die Konfiguration zu laden.
|
|
||||||
</div>
|
|
||||||
) : loading ? (
|
|
||||||
<div className="rounded-3xl border border-slate-200 bg-white/90 p-8 text-center text-slate-500 shadow-xl dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-300">
|
<div className="rounded-3xl border border-slate-200 bg-white/90 p-8 text-center text-slate-500 shadow-xl dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-300">
|
||||||
Lade Konfiguration…
|
Lade Konfiguration…
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : configLoaded ? (
|
||||||
<>
|
<>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-6 rounded-3xl border border-rose-200 bg-rose-50 px-5 py-4 text-sm text-rose-800 shadow-sm dark:border-rose-700/40 dark:bg-rose-950/30 dark:text-rose-200">
|
<div className="mb-6 rounded-3xl border border-rose-200 bg-rose-50 px-5 py-4 text-sm text-rose-800 shadow-sm dark:border-rose-700/40 dark:bg-rose-950/30 dark:text-rose-200">
|
||||||
|
|
@ -433,26 +500,115 @@ export default function AdminPage() {
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Test Section */}
|
||||||
|
<div className="mt-4 rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-600 dark:bg-slate-800">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300">Healthcheck testen</h4>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Testet die Monitor-URL mit den konfigurierten Parametern
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void testService(service)}
|
||||||
|
disabled={testingServices.has(service.id)}
|
||||||
|
className="rounded-2xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:bg-slate-500 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||||
|
>
|
||||||
|
{testingServices.has(service.id) ? 'Teste…' : 'Testen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testResults[service.id] && (
|
||||||
|
<div className="mt-3 rounded-xl border bg-white p-3 dark:border-slate-600 dark:bg-slate-900">
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-slate-700 dark:text-slate-300">Status:</span>
|
||||||
|
<span
|
||||||
|
className={`ml-2 inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
|
||||||
|
testResults[service.id]!.status === 'online'
|
||||||
|
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200'
|
||||||
|
: testResults[service.id]!.status === 'warning'
|
||||||
|
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-200'
|
||||||
|
: 'bg-rose-100 text-rose-800 dark:bg-rose-900/30 dark:text-rose-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{testResults[service.id]!.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-slate-700 dark:text-slate-300">HTTP-Status:</span>
|
||||||
|
<span className="ml-2 text-slate-900 dark:text-slate-100">
|
||||||
|
{testResults[service.id]!.httpStatus ?? 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-slate-700 dark:text-slate-300">Antwortzeit:</span>
|
||||||
|
<span className="ml-2 text-slate-900 dark:text-slate-100">
|
||||||
|
{testResults[service.id]!.responseTimeMs}ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-slate-700 dark:text-slate-300">Geprüft:</span>
|
||||||
|
<span className="ml-2 text-slate-900 dark:text-slate-100">
|
||||||
|
{new Date(testResults[service.id]!.checkedAt).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{testResults[service.id]!.message && (
|
||||||
|
<div className="mt-2 text-xs text-rose-600 dark:text-rose-400">
|
||||||
|
{testResults[service.id]!.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<ImportExportSection onConfigChanged={loadConfig} />
|
||||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
<BackupSection onConfigChanged={loadConfig} />
|
||||||
Änderungen werden auf die lokale Konfigurationsdatei geschrieben.
|
|
||||||
|
<div className={`rounded-3xl border p-6 shadow-xl ${isDirty ? 'border-orange-300 bg-orange-50/90 dark:border-orange-700/40 dark:bg-orange-950/30' : 'border-slate-200 bg-white/90 dark:border-slate-700 dark:bg-slate-900/90'}`}>
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex-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 || !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>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={saveConfig}
|
|
||||||
disabled={saving}
|
|
||||||
className="inline-flex items-center justify-center rounded-2xl bg-slate-900 px-6 py-3 text-sm font-semibold text-white transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:bg-slate-500"
|
|
||||||
>
|
|
||||||
{saving ? 'Speichere…' : 'Speichern'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</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 'server-only';
|
||||||
import { config } from '@/src/lib/config';
|
import { getConfigSync } from '@/src/lib/config';
|
||||||
import { servicesArraySchema } from '@/src/lib/config/schema';
|
import { servicesArraySchema } from '@/src/lib/config/schema';
|
||||||
import { resolveProjectPath } from '@/src/lib/config/load-config';
|
import { resolveProjectPath } from '@/src/lib/config/load-config';
|
||||||
import type { Service } from '@/src/lib/config';
|
import type { Service } from '@/src/lib/config';
|
||||||
|
|
@ -13,6 +13,7 @@ function loadServicesSync(): Service[] {
|
||||||
if (!cachedServices) {
|
if (!cachedServices) {
|
||||||
try {
|
try {
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const config = getConfigSync();
|
||||||
const servicesPath = resolveProjectPath(config.servicesFile);
|
const servicesPath = resolveProjectPath(config.servicesFile);
|
||||||
const servicesData = fs.readFileSync(servicesPath, 'utf8');
|
const servicesData = fs.readFileSync(servicesPath, 'utf8');
|
||||||
cachedServices = servicesArraySchema.parse(JSON.parse(servicesData));
|
cachedServices = servicesArraySchema.parse(JSON.parse(servicesData));
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import 'server-only';
|
import 'server-only';
|
||||||
import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './config/schema';
|
import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './config/schema';
|
||||||
import { resolveProjectPath } from './config/load-config';
|
import { CONFIG_FILE_PATH, SERVICES_FILE_PATH } from './config/paths';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const DEFAULT_SERVICES_FILE = path.relative(process.cwd(), SERVICES_FILE_PATH);
|
||||||
|
|
||||||
// Legacy export for backward compatibility
|
// Legacy export for backward compatibility
|
||||||
export type { AppConfig };
|
export type { AppConfig };
|
||||||
|
|
@ -13,9 +16,7 @@ function loadConfigSync(): AppConfig {
|
||||||
if (!cachedConfig) {
|
if (!cachedConfig) {
|
||||||
try {
|
try {
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const configData = fs.readFileSync(CONFIG_FILE_PATH, 'utf8');
|
||||||
const configPath = path.join(process.cwd(), 'config.json');
|
|
||||||
const configData = fs.readFileSync(configPath, 'utf8');
|
|
||||||
cachedConfig = appConfigSchema.parse(JSON.parse(configData));
|
cachedConfig = appConfigSchema.parse(JSON.parse(configData));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to load config synchronously, using defaults');
|
console.warn('Failed to load config synchronously, using defaults');
|
||||||
|
|
@ -23,7 +24,7 @@ function loadConfigSync(): AppConfig {
|
||||||
title: 'Homelab Dashboard',
|
title: 'Homelab Dashboard',
|
||||||
subtitle: 'Live-Status aller verwalteten Dienste',
|
subtitle: 'Live-Status aller verwalteten Dienste',
|
||||||
description: 'Live-Monitoring-Dashboard für dein Homelab',
|
description: 'Live-Monitoring-Dashboard für dein Homelab',
|
||||||
servicesFile: 'src/data/services.json',
|
servicesFile: DEFAULT_SERVICES_FILE,
|
||||||
refreshInterval: 30000,
|
refreshInterval: 30000,
|
||||||
categories: ['Infrastruktur', 'Smart Home', 'Medien', 'Dokumente'],
|
categories: ['Infrastruktur', 'Smart Home', 'Medien', 'Dokumente'],
|
||||||
theme: 'auto',
|
theme: 'auto',
|
||||||
|
|
@ -38,9 +39,7 @@ function loadServicesSync(): Services {
|
||||||
if (!cachedServices) {
|
if (!cachedServices) {
|
||||||
try {
|
try {
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const config = loadConfigSync();
|
const servicesData = fs.readFileSync(SERVICES_FILE_PATH, 'utf8');
|
||||||
const servicesPath = resolveProjectPath(config.servicesFile);
|
|
||||||
const servicesData = fs.readFileSync(servicesPath, 'utf8');
|
|
||||||
cachedServices = servicesArraySchema.parse(JSON.parse(servicesData));
|
cachedServices = servicesArraySchema.parse(JSON.parse(servicesData));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to load services synchronously, using empty array');
|
console.warn('Failed to load services synchronously, using empty array');
|
||||||
|
|
@ -50,11 +49,17 @@ function loadServicesSync(): Services {
|
||||||
return cachedServices;
|
return cachedServices;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export cached versions for backward compatibility
|
// Legacy accessors for backward compatibility
|
||||||
export const config: AppConfig = loadConfigSync();
|
export function getConfigSync(): AppConfig {
|
||||||
export const services: Services = loadServicesSync();
|
return loadConfigSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServicesSync(): Services {
|
||||||
|
return loadServicesSync();
|
||||||
|
}
|
||||||
|
|
||||||
// Export new async functions
|
// Export new async functions
|
||||||
export { loadAppConfig, loadServices, loadFullConfig } from './config/load-config';
|
export { loadAppConfig, loadServices, loadFullConfig } from './config/load-config';
|
||||||
export { saveAppConfig, saveServices, backupConfig } from './config/save-config';
|
export { saveAppConfig, saveServices, backupConfig } from './config/save-config';
|
||||||
|
export { exportConfig, importConfig, type ConfigExportPayload } from './config/import-export';
|
||||||
export type { Service, Healthcheck } from './config/schema';
|
export type { Service, Healthcheck } from './config/schema';
|
||||||
|
|
|
||||||
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 { promises as fs } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './schema';
|
import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './schema';
|
||||||
|
import { CONFIG_FILE_PATH, SERVICES_FILE_PATH, ensureDataDir } from './paths';
|
||||||
|
|
||||||
|
const DEFAULT_SERVICES_FILE = path.relative(process.cwd(), SERVICES_FILE_PATH);
|
||||||
|
|
||||||
// Default configurations
|
// Default configurations
|
||||||
const defaultAppConfig: AppConfig = {
|
const defaultAppConfig: AppConfig = {
|
||||||
title: 'Homelab Dashboard',
|
title: 'Homelab Dashboard',
|
||||||
subtitle: 'Live-Status aller verwalteten Dienste',
|
subtitle: 'Live-Status aller verwalteten Dienste',
|
||||||
description: 'Live-Monitoring-Dashboard für dein Homelab',
|
description: 'Live-Monitoring-Dashboard für dein Homelab',
|
||||||
servicesFile: 'src/data/services.json',
|
servicesFile: DEFAULT_SERVICES_FILE,
|
||||||
refreshInterval: 30000,
|
refreshInterval: 30000,
|
||||||
categories: ['Infrastruktur', 'Smart Home', 'Medien', 'Dokumente'],
|
categories: ['Infrastruktur', 'Smart Home', 'Medien', 'Dokumente'],
|
||||||
theme: 'auto',
|
theme: 'auto',
|
||||||
|
|
@ -33,7 +36,8 @@ function resolveProjectPath(relativePath: string): string {
|
||||||
* Lädt und validiert die App-Konfiguration
|
* Lädt und validiert die App-Konfiguration
|
||||||
*/
|
*/
|
||||||
export async function loadAppConfig(configPath?: string): Promise<AppConfig> {
|
export async function loadAppConfig(configPath?: string): Promise<AppConfig> {
|
||||||
const filePath = configPath || path.join(process.cwd(), 'config.json');
|
await ensureDataDir();
|
||||||
|
const filePath = configPath || CONFIG_FILE_PATH;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const configData = await fs.readFile(filePath, 'utf8');
|
const configData = await fs.readFile(filePath, 'utf8');
|
||||||
|
|
@ -44,8 +48,6 @@ export async function loadAppConfig(configPath?: string): Promise<AppConfig> {
|
||||||
|
|
||||||
return validatedConfig;
|
return validatedConfig;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to load app config from ${filePath}:`, error);
|
|
||||||
|
|
||||||
// Return defaults if file doesn't exist or is invalid
|
// Return defaults if file doesn't exist or is invalid
|
||||||
if (error instanceof Error && error.message.includes('ENOENT')) {
|
if (error instanceof Error && error.message.includes('ENOENT')) {
|
||||||
console.info('Using default app configuration');
|
console.info('Using default app configuration');
|
||||||
|
|
@ -58,6 +60,7 @@ export async function loadAppConfig(configPath?: string): Promise<AppConfig> {
|
||||||
return defaultAppConfig;
|
return defaultAppConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.warn(`Failed to load app config from ${filePath}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -66,7 +69,8 @@ export async function loadAppConfig(configPath?: string): Promise<AppConfig> {
|
||||||
* Lädt und validiert die Services-Konfiguration
|
* Lädt und validiert die Services-Konfiguration
|
||||||
*/
|
*/
|
||||||
export async function loadServices(servicesPath?: string): Promise<Services> {
|
export async function loadServices(servicesPath?: string): Promise<Services> {
|
||||||
const filePath = servicesPath || resolveProjectPath('src/data/services.json');
|
await ensureDataDir();
|
||||||
|
const filePath = servicesPath || SERVICES_FILE_PATH;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const servicesData = await fs.readFile(filePath, 'utf8');
|
const servicesData = await fs.readFile(filePath, 'utf8');
|
||||||
|
|
@ -77,8 +81,6 @@ export async function loadServices(servicesPath?: string): Promise<Services> {
|
||||||
|
|
||||||
return validatedServices;
|
return validatedServices;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to load services from ${filePath}:`, error);
|
|
||||||
|
|
||||||
// Return empty array if file doesn't exist
|
// Return empty array if file doesn't exist
|
||||||
if (error instanceof Error && error.message.includes('ENOENT')) {
|
if (error instanceof Error && error.message.includes('ENOENT')) {
|
||||||
console.info('No services configuration found, using empty array');
|
console.info('No services configuration found, using empty array');
|
||||||
|
|
@ -91,6 +93,7 @@ export async function loadServices(servicesPath?: string): Promise<Services> {
|
||||||
return defaultServices;
|
return defaultServices;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.warn(`Failed to load services from ${filePath}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
40
src/lib/config/paths.ts
Normal file
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 { promises as fs } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './schema';
|
import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './schema';
|
||||||
import { resolveProjectPath } from './load-config';
|
import { CONFIG_FILE_PATH, SERVICES_FILE_PATH, ensureDataDir } from './paths';
|
||||||
|
import { createBackup } from './backup-config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Speichert die App-Konfiguration
|
* Speichert die App-Konfiguration
|
||||||
*/
|
*/
|
||||||
export async function saveAppConfig(config: AppConfig, configPath?: string): Promise<void> {
|
export async function saveAppConfig(config: AppConfig, configPath?: string): Promise<void> {
|
||||||
const filePath = configPath || path.join(process.cwd(), 'config.json');
|
await ensureDataDir();
|
||||||
|
const filePath = configPath || CONFIG_FILE_PATH;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate before saving
|
// Validate before saving
|
||||||
|
|
@ -32,7 +34,8 @@ export async function saveAppConfig(config: AppConfig, configPath?: string): Pro
|
||||||
* Speichert die Services-Konfiguration
|
* Speichert die Services-Konfiguration
|
||||||
*/
|
*/
|
||||||
export async function saveServices(services: Services, servicesPath?: string): Promise<void> {
|
export async function saveServices(services: Services, servicesPath?: string): Promise<void> {
|
||||||
const filePath = servicesPath || resolveProjectPath('src/data/services.json');
|
await ensureDataDir();
|
||||||
|
const filePath = servicesPath || SERVICES_FILE_PATH;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate before saving
|
// Validate before saving
|
||||||
|
|
@ -54,34 +57,8 @@ export async function saveServices(services: Services, servicesPath?: string): P
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt ein Backup der aktuellen Konfiguration
|
* Erstellt ein Backup der aktuellen Konfiguration
|
||||||
|
* @deprecated Use createBackup from backup-config.ts instead
|
||||||
*/
|
*/
|
||||||
export async function backupConfig(backupDir?: string): Promise<string> {
|
export async function backupConfig(backupDir?: string): Promise<string> {
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
return createBackup(backupDir);
|
||||||
const backupPath = backupDir || path.join(process.cwd(), 'backups');
|
|
||||||
const backupFile = path.join(backupPath, `config-backup-${timestamp}.json`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Load current config
|
|
||||||
const { loadFullConfig } = await import('./load-config');
|
|
||||||
const { appConfig, services } = await loadFullConfig();
|
|
||||||
|
|
||||||
// Create backup data
|
|
||||||
const backupData = {
|
|
||||||
timestamp,
|
|
||||||
appConfig,
|
|
||||||
services,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ensure directory exists
|
|
||||||
await fs.mkdir(backupPath, { recursive: true });
|
|
||||||
|
|
||||||
// Write backup
|
|
||||||
const backupJson = JSON.stringify(backupData, null, 2);
|
|
||||||
await fs.writeFile(backupFile, backupJson, 'utf8');
|
|
||||||
|
|
||||||
return backupFile;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create backup:', error);
|
|
||||||
throw new Error(`Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
src/lib/db/prisma.ts
Normal file
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';
|
import type { Service } from '@/src/lib/config';
|
||||||
|
|
||||||
interface ServiceCheckResult {
|
export interface ServiceCheckResult {
|
||||||
id: string;
|
id: string;
|
||||||
status: 'online' | 'warning' | 'offline';
|
status: 'online' | 'warning' | 'offline';
|
||||||
responseTimeMs: number;
|
responseTimeMs: number;
|
||||||
httpStatus: number | null;
|
httpStatus: number | null;
|
||||||
checkedAt: string;
|
checkedAt: string;
|
||||||
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export async function checkService(service: Service): Promise<ServiceCheckResult> {
|
||||||
|
|
||||||
async function checkService(service: Service): Promise<ServiceCheckResult> {
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
|
@ -51,31 +48,28 @@ async function checkService(service: Service): Promise<ServiceCheckResult> {
|
||||||
checkedAt: new Date().toISOString(),
|
checkedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
let message = 'Unknown error';
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
message = `Timeout after ${service.healthcheck?.timeoutMs || 5000}ms`;
|
||||||
|
} else {
|
||||||
|
message = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: service.id,
|
id: service.id,
|
||||||
status: 'offline',
|
status: 'offline',
|
||||||
responseTimeMs: Date.now() - startTime,
|
responseTimeMs: responseTime,
|
||||||
httpStatus: null,
|
httpStatus: null,
|
||||||
checkedAt: new Date().toISOString(),
|
checkedAt: new Date().toISOString(),
|
||||||
|
message,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (timeoutId) {
|
if (timeoutId) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
export interface ServiceCheckResult {
|
||||||
id: string;
|
id: string;
|
||||||
status: ServiceStatus;
|
status: ServiceStatus;
|
||||||
responseTimeMs: number | null;
|
responseTimeMs: number;
|
||||||
httpStatus: number | null;
|
httpStatus: number | null;
|
||||||
checkedAt: string;
|
checkedAt: string;
|
||||||
|
message?: string;
|
||||||
}
|
}
|
||||||
19
types/next-auth.d.ts
vendored
Normal file
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