v3.2
This commit is contained in:
parent
b2d2b8b2f3
commit
764223db6c
25 changed files with 1643 additions and 142 deletions
17
.dockerignore
Normal file
17
.dockerignore
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
Dockerfile
|
||||
.dockerignore
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.next
|
||||
.vercel
|
||||
*.log
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
8
.env.example
Normal file
8
.env.example
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Port on which the app runs
|
||||
PORT=3000
|
||||
|
||||
# Hostname for the server
|
||||
HOSTNAME=0.0.0.0
|
||||
|
||||
# Optional: Polling interval for status checks (in milliseconds)
|
||||
# STATUS_POLL_INTERVAL=30000
|
||||
62
Dockerfile
Normal file
62
Dockerfile
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Multi-stage build for Next.js standalone output
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN \
|
||||
if [ -f package-lock.json ]; then npm ci; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Set the correct permission for prerender cache
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
# set hostname to localhost
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
# server.js is created by next build from the standalone output
|
||||
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
||||
CMD ["node", "server.js"]
|
||||
93
README.md
93
README.md
|
|
@ -98,6 +98,99 @@ export const services: Service[] = [
|
|||
| Status | Farbe | Beschreibung |
|
||||
|--------|-------|-------------|
|
||||
| Online | 🟢 Grün | Service läuft einwandfrei |
|
||||
| Warnung | 🟡 Gelb | Service läuft, aber mit Performance-Problemen |
|
||||
| Offline | 🔴 Rot | Service ist nicht erreichbar |
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Lokaler Start ohne Docker
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Lokaler Start mit Docker
|
||||
|
||||
```bash
|
||||
# Build und starte mit docker-compose
|
||||
docker-compose up --build
|
||||
|
||||
# Oder manuell:
|
||||
docker build -t homelab-dashboard .
|
||||
docker run -p 3000:3000 homelab-dashboard
|
||||
```
|
||||
|
||||
### Build-Befehl
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Run-Befehl
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
### Docker Compose Nutzung
|
||||
|
||||
```bash
|
||||
# Start im Hintergrund
|
||||
docker-compose up -d
|
||||
|
||||
# Stoppen
|
||||
docker-compose down
|
||||
|
||||
# Logs anzeigen
|
||||
docker-compose logs -f homelab-dashboard
|
||||
```
|
||||
|
||||
### Unraid Volume-Mappings
|
||||
|
||||
Für Unraid empfiehlt es sich, die Konfigurationsdateien als Volumes zu mounten:
|
||||
|
||||
- **config.json**: `/mnt/user/appdata/homelab-dashboard/config.json` → `/app/config.json`
|
||||
- **services.json**: `/mnt/user/appdata/homelab-dashboard/services.json` → `/app/src/data/services.json`
|
||||
|
||||
Dadurch können Konfigurationen außerhalb des Containers verwaltet werden.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### Services bearbeiten
|
||||
|
||||
Bearbeite `src/data/services.json` für JSON-basierte Konfiguration oder `src/data/services.ts` für TypeScript-basierte Konfiguration.
|
||||
|
||||
### App-Konfiguration
|
||||
|
||||
Bearbeite `config.json` im Projektroot, um Titel, Beschreibung und andere Einstellungen anzupassen:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Mein Homelab Dashboard",
|
||||
"subtitle": "Live-Status aller verwalteten Dienste",
|
||||
"description": "Live-Monitoring-Dashboard für mein Homelab",
|
||||
"servicesFile": "src/data/services.json",
|
||||
"refreshInterval": 30000,
|
||||
"categories": [
|
||||
"Infrastruktur",
|
||||
"Smart Home",
|
||||
"Medien",
|
||||
"Dokumente"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Kopiere `.env.example` zu `.env` und passe die Werte an:
|
||||
|
||||
- `PORT`: Port für den Server (Standard: 3000)
|
||||
- `HOSTNAME`: Hostname binding (Standard: 0.0.0.0)
|
||||
|
||||
## Ports
|
||||
|
||||
- **3000**: Haupt-HTTP-Port der Anwendung
|
||||
| Warning | 🟡 Orange | Service hat Probleme, läuft aber |
|
||||
| Offline | 🔴 Rot | Service ist nicht erreichbar |
|
||||
|
||||
|
|
|
|||
5
app/admin/page.tsx
Normal file
5
app/admin/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import AdminPage from '@/src/components/admin/AdminPage';
|
||||
|
||||
export default function Page() {
|
||||
return <AdminPage />;
|
||||
}
|
||||
|
|
@ -1,44 +1,103 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ServiceCheckResult } from '@/src/types/service';
|
||||
import { services } from '@/src/data/services';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { ServiceStatus } from '@/src/types/service';
|
||||
import { AppConfig, Service } from '@/src/lib/config';
|
||||
import { ServiceCard } from './ServiceCard';
|
||||
import { DashboardFilters } from './dashboard-filters';
|
||||
|
||||
export function DashboardClient() {
|
||||
interface ServiceCheckResult {
|
||||
id: string;
|
||||
status: 'online' | 'warning' | 'offline';
|
||||
responseTimeMs: number;
|
||||
httpStatus: number | null;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
interface DashboardClientProps {
|
||||
initialServices: Service[];
|
||||
config: AppConfig;
|
||||
}
|
||||
|
||||
export function DashboardClient({ initialServices, config }: DashboardClientProps) {
|
||||
const [results, setResults] = useState<ServiceCheckResult[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
// Filter states
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [selectedStatus, setSelectedStatus] = useState<ServiceStatus | 'all'>('all');
|
||||
|
||||
const fetchStatus = async (isManualRefresh = false) => {
|
||||
try {
|
||||
setError(null);
|
||||
if (isManualRefresh) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const response = await fetch('/api/status');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch status');
|
||||
}
|
||||
const data: ServiceCheckResult[] = await response.json();
|
||||
setResults(data);
|
||||
setLastUpdated(new Date().toISOString());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
const interval = setInterval(fetchStatus, 30000);
|
||||
const interval = setInterval(() => fetchStatus(), config.refreshInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [config.refreshInterval]);
|
||||
|
||||
// Get unique categories from config
|
||||
const categories = useMemo(() => {
|
||||
return config.categories;
|
||||
}, [config.categories]);
|
||||
|
||||
// Filter services based on search, category, and status
|
||||
const filteredServices = useMemo(() => {
|
||||
return initialServices.filter((service) => {
|
||||
const result = results.find(r => r.id === service.id);
|
||||
|
||||
// Search filter (case-insensitive)
|
||||
const matchesSearch =
|
||||
searchQuery === '' ||
|
||||
service.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
service.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
// Category filter
|
||||
const matchesCategory =
|
||||
selectedCategory === 'all' || service.category === selectedCategory;
|
||||
|
||||
// Status filter
|
||||
const matchesStatus =
|
||||
selectedStatus === 'all' || (result && result.status === selectedStatus);
|
||||
|
||||
return matchesSearch && matchesCategory && matchesStatus;
|
||||
});
|
||||
}, [initialServices, results, searchQuery, selectedCategory, selectedStatus]);
|
||||
|
||||
const getResultForService = (serviceId: string): ServiceCheckResult | undefined => {
|
||||
return results.find(result => result.id === serviceId);
|
||||
};
|
||||
|
||||
const handleManualRefresh = () => {
|
||||
fetchStatus(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||
{/* Header */}
|
||||
|
|
@ -47,18 +106,20 @@ export function DashboardClient() {
|
|||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-white tracking-tight">
|
||||
Homelab Dashboard
|
||||
{config.title}
|
||||
</h1>
|
||||
<div className="text-sm text-slate-400">
|
||||
{loading ? (
|
||||
'Lade Status...'
|
||||
) : refreshing ? (
|
||||
'Aktualisiere...'
|
||||
) : (
|
||||
'Automatische Aktualisierung: 30s'
|
||||
`Automatische Aktualisierung: ${Math.round(config.refreshInterval / 1000)}s`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Live-Status aller verwalteten Dienste
|
||||
{config.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -89,9 +150,25 @@ export function DashboardClient() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<DashboardFilters
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
selectedCategory={selectedCategory}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
selectedStatus={selectedStatus}
|
||||
onStatusChange={setSelectedStatus}
|
||||
categories={categories}
|
||||
onRefresh={handleManualRefresh}
|
||||
isRefreshing={refreshing}
|
||||
visibleCount={filteredServices.length}
|
||||
totalCount={initialServices.length}
|
||||
lastUpdated={lastUpdated}
|
||||
/>
|
||||
|
||||
{/* Services Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{services.map((service) => (
|
||||
{filteredServices.map((service) => (
|
||||
<ServiceCard
|
||||
key={service.id}
|
||||
service={service}
|
||||
|
|
@ -99,6 +176,31 @@ export function DashboardClient() {
|
|||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* No results message */}
|
||||
{filteredServices.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<svg
|
||||
className="w-16 h-16 text-slate-400 mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-xl font-semibold text-slate-900 dark:text-white">
|
||||
Keine Dienste gefunden
|
||||
</h3>
|
||||
<p className="text-slate-600 dark:text-slate-400 mt-2 text-center">
|
||||
Versuche andere Suchbegriffe oder Filtereinstellungen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
148
app/components/dashboard-filters.tsx
Normal file
148
app/components/dashboard-filters.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
'use client';
|
||||
|
||||
import { ServiceStatus } from '@/src/types/service';
|
||||
|
||||
interface DashboardFiltersProps {
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
selectedCategory: string;
|
||||
onCategoryChange: (category: string) => void;
|
||||
selectedStatus: ServiceStatus | 'all';
|
||||
onStatusChange: (status: ServiceStatus | 'all') => void;
|
||||
categories: string[];
|
||||
onRefresh: () => void;
|
||||
isRefreshing: boolean;
|
||||
visibleCount: number;
|
||||
totalCount: number;
|
||||
lastUpdated: string | null;
|
||||
}
|
||||
|
||||
export function DashboardFilters({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
selectedCategory,
|
||||
onCategoryChange,
|
||||
selectedStatus,
|
||||
onStatusChange,
|
||||
categories,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
visibleCount,
|
||||
totalCount,
|
||||
lastUpdated,
|
||||
}: DashboardFiltersProps) {
|
||||
const formatLastUpdated = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-8 space-y-4">
|
||||
{/* Search and Refresh Row */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<div className="flex-1 max-w-md">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suche nach Name oder Beschreibung..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-lg transition-colors duration-200"
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Aktualisiere...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Jetzt aktualisieren
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters Row */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Category Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Kategorie:</span>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => onCategoryChange(e.target.value)}
|
||||
className="px-3 py-1 border border-slate-300 dark:border-slate-600 rounded-md bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">Alle</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Status:</span>
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => onStatusChange(e.target.value as ServiceStatus | 'all')}
|
||||
className="px-3 py-1 border border-slate-300 dark:border-slate-600 rounded-md bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">Alle</option>
|
||||
<option value="online">Online</option>
|
||||
<option value="warning">Warnung</option>
|
||||
<option value="offline">Offline</option>
|
||||
<option value="unknown">Unbekannt</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600 dark:text-slate-400">
|
||||
<span>
|
||||
{visibleCount} von {totalCount} Diensten
|
||||
</span>
|
||||
{lastUpdated && (
|
||||
<span>
|
||||
Letzte Aktualisierung: {formatLastUpdated(lastUpdated)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,3 +7,5 @@ export { ServiceGrid } from './ServiceGrid';
|
|||
export { FilterBar } from './FilterBar';
|
||||
export { EmptyState } from './EmptyState';
|
||||
export { DashboardClient } from './dashboard-client';
|
||||
export { DashboardFilters } from './dashboard-filters';
|
||||
export { StatusBadge } from './status-badge';
|
||||
|
|
|
|||
55
app/components/status-badge.tsx
Normal file
55
app/components/status-badge.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
'use client';
|
||||
|
||||
import { ServiceStatus } from '@/src/types/service';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: ServiceStatus;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
online: {
|
||||
bgColor: 'bg-green-50 dark:bg-green-950',
|
||||
textColor: 'text-green-700 dark:text-green-300',
|
||||
dotColor: 'bg-green-500',
|
||||
label: 'Online',
|
||||
},
|
||||
warning: {
|
||||
bgColor: 'bg-amber-50 dark:bg-amber-950',
|
||||
textColor: 'text-amber-700 dark:text-amber-300',
|
||||
dotColor: 'bg-amber-500',
|
||||
label: 'Warnung',
|
||||
},
|
||||
offline: {
|
||||
bgColor: 'bg-red-50 dark:bg-red-950',
|
||||
textColor: 'text-red-700 dark:text-red-300',
|
||||
dotColor: 'bg-red-500',
|
||||
label: 'Offline',
|
||||
},
|
||||
unknown: {
|
||||
bgColor: 'bg-slate-50 dark:bg-slate-950',
|
||||
textColor: 'text-slate-700 dark:text-slate-300',
|
||||
dotColor: 'bg-slate-500',
|
||||
label: 'Unbekannt',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function StatusBadge({ status, size = 'md' }: StatusBadgeProps) {
|
||||
const config = STATUS_CONFIG[status];
|
||||
const sizeClasses = size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-3 py-1 text-xs';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center gap-2 rounded-full ${sizeClasses} ${config.bgColor}`}
|
||||
>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${config.dotColor} ${
|
||||
status !== 'unknown' ? 'animate-pulse' : ''
|
||||
}`}
|
||||
/>
|
||||
<span className={`font-medium ${config.textColor}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { DashboardClient } from './components/dashboard-client';
|
||||
import { loadFullConfig } from '@/src/lib/config';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -7,6 +8,8 @@ export const metadata: Metadata = {
|
|||
description: 'Live-Monitoring-Dashboard für dein Homelab',
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
return <DashboardClient />;
|
||||
export default async function Home() {
|
||||
const { appConfig, services } = await loadFullConfig();
|
||||
|
||||
return <DashboardClient initialServices={services} config={appConfig} />;
|
||||
}
|
||||
|
|
|
|||
15
config.json
Normal file
15
config.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"title": "Homelab Dashboard",
|
||||
"subtitle": "Live-Status aller verwalteten Dienste",
|
||||
"description": "Live-Monitoring-Dashboard für dein Homelab",
|
||||
"servicesFile": "src/data/services.json",
|
||||
"refreshInterval": 30000,
|
||||
"categories": [
|
||||
"Infrastruktur",
|
||||
"Smart Home",
|
||||
"Medien",
|
||||
"Dokumente"
|
||||
],
|
||||
"theme": "auto",
|
||||
"loggingLevel": "info"
|
||||
}
|
||||
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
homelab-dashboard:
|
||||
build: .
|
||||
container_name: homelab-dashboard
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
- ./src/data/services.json:/app/src/data/services.json:ro
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- HOSTNAME=0.0.0.0
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {}
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
|
|
|
|||
12
package-lock.json
generated
12
package-lock.json
generated
|
|
@ -10,7 +10,8 @@
|
|||
"dependencies": {
|
||||
"next": "^16.2.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.6",
|
||||
|
|
@ -6516,6 +6517,15 @@
|
|||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,12 +6,13 @@
|
|||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "eslint app src lib --ext .ts,.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^16.2.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.6",
|
||||
|
|
|
|||
90
src/app/api/config/route.ts
Normal file
90
src/app/api/config/route.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { backupConfig, loadFullConfig, saveAppConfig, saveServices } from '@/src/lib/config';
|
||||
import { appConfigSchema, servicesArraySchema } from '@/src/lib/config/schema';
|
||||
import { resolveProjectPath } from '@/src/lib/config/load-config';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const ADMIN_TOKEN = process.env.ADMIN_TOKEN;
|
||||
const CONFIG_PATH = path.join(process.cwd(), 'config.json');
|
||||
|
||||
function jsonError(message: string, status: number) {
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
|
||||
function getBearerToken(request: Request): string {
|
||||
const authorization = request.headers.get('authorization') || '';
|
||||
return authorization.startsWith('Bearer ') ? authorization.slice(7) : '';
|
||||
}
|
||||
|
||||
function isAuthorized(request: Request): boolean {
|
||||
if (!ADMIN_TOKEN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const token = getBearerToken(request);
|
||||
return token.length > 0 && token === ADMIN_TOKEN;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!ADMIN_TOKEN) {
|
||||
return jsonError('Server is not configured for admin access', 500);
|
||||
}
|
||||
|
||||
if (!isAuthorized(request)) {
|
||||
return jsonError('Invalid or missing admin token', 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await loadFullConfig();
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error('GET /api/config failed:', error);
|
||||
return jsonError('Failed to load configuration', 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
if (!ADMIN_TOKEN) {
|
||||
return jsonError('Server is not configured for admin access', 500);
|
||||
}
|
||||
|
||||
if (!isAuthorized(request)) {
|
||||
return jsonError('Invalid or missing admin token', 401);
|
||||
}
|
||||
|
||||
let payload: any;
|
||||
|
||||
try {
|
||||
payload = await request.json();
|
||||
} catch (error) {
|
||||
return jsonError('Request body must be valid JSON', 400);
|
||||
}
|
||||
|
||||
const appConfigResult = appConfigSchema.safeParse(payload.appConfig);
|
||||
if (!appConfigResult.success) {
|
||||
return jsonError(`App config validation failed: ${appConfigResult.error.message}`, 400);
|
||||
}
|
||||
|
||||
const servicesResult = servicesArraySchema.safeParse(payload.services);
|
||||
if (!servicesResult.success) {
|
||||
return jsonError(`Services validation failed: ${servicesResult.error.message}`, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(CONFIG_PATH);
|
||||
await backupConfig();
|
||||
} catch (error) {
|
||||
// ignore missing config file
|
||||
}
|
||||
|
||||
try {
|
||||
const servicesPath = resolveProjectPath(appConfigResult.data.servicesFile);
|
||||
await saveAppConfig(appConfigResult.data, CONFIG_PATH);
|
||||
await saveServices(servicesResult.data, servicesPath);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('PUT /api/config failed:', error);
|
||||
return jsonError('Failed to save configuration', 500);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +1,46 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { services } from '@/src/data/services';
|
||||
import { ServiceCheckResult } from '@/src/types/service';
|
||||
import { loadFullConfig } from '@/src/lib/config';
|
||||
import type { Service } from '@/src/lib/config';
|
||||
|
||||
interface ServiceCheckResult {
|
||||
id: string;
|
||||
status: 'online' | 'warning' | 'offline';
|
||||
responseTimeMs: number;
|
||||
httpStatus: number | null;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
async function checkService(service: typeof services[0]): Promise<ServiceCheckResult> {
|
||||
async function checkService(service: Service): Promise<ServiceCheckResult> {
|
||||
const startTime = Date.now();
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
try {
|
||||
const defaultTimeout = 5000;
|
||||
const defaultAcceptableCodes = [200];
|
||||
const timeout = service.healthcheck?.timeoutMs || defaultTimeout;
|
||||
const acceptableCodes = service.healthcheck?.acceptableStatusCodes || defaultAcceptableCodes;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
|
||||
timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
const response = await fetch(service.monitorUrl, {
|
||||
method: 'HEAD', // Use HEAD to minimize data transfer
|
||||
method: 'HEAD',
|
||||
cache: 'no-store',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
let status: ServiceCheckResult['status'];
|
||||
const isAcceptableStatus = acceptableCodes.includes(response.status);
|
||||
|
||||
if (response.ok) {
|
||||
if (isAcceptableStatus) {
|
||||
status = responseTime <= 2000 ? 'online' : 'warning';
|
||||
} else if (response.status >= 400 && response.status < 500) {
|
||||
status = 'warning';
|
||||
} else {
|
||||
} else if (response.status >= 400 && response.status < 600) {
|
||||
status = 'offline';
|
||||
} else {
|
||||
status = 'warning';
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -38,23 +51,24 @@ async function checkService(service: typeof services[0]): Promise<ServiceCheckRe
|
|||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
id: service.id,
|
||||
status: 'offline',
|
||||
responseTimeMs: responseTime,
|
||||
responseTimeMs: Date.now() - startTime,
|
||||
httpStatus: null,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
services.map(service => checkService(service))
|
||||
);
|
||||
const { services } = await loadFullConfig();
|
||||
const results = await Promise.all(services.map((service: Service) => checkService(service)));
|
||||
|
||||
return NextResponse.json(results);
|
||||
} catch (error) {
|
||||
|
|
|
|||
459
src/components/admin/AdminPage.tsx
Normal file
459
src/components/admin/AdminPage.tsx
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { AppConfig, Service } from '@/src/lib/config';
|
||||
|
||||
const defaultService: Service = {
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
url: 'http://',
|
||||
monitorUrl: 'http://',
|
||||
category: '',
|
||||
healthcheck: {
|
||||
acceptableStatusCodes: [200],
|
||||
timeoutMs: 5000,
|
||||
},
|
||||
};
|
||||
|
||||
const themes = ['auto', 'light', 'dark'] as const;
|
||||
const loggingLevels = ['error', 'warn', 'info', 'debug'] as const;
|
||||
|
||||
export default function AdminPage() {
|
||||
const [appConfig, setAppConfig] = useState<AppConfig | null>(null);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adminToken, setAdminToken] = useState('');
|
||||
const [configLoaded, setConfigLoaded] = useState(false);
|
||||
|
||||
const loadConfig = async () => {
|
||||
if (!adminToken.trim()) {
|
||||
setError('Admin-Token ist erforderlich, um die Konfiguration zu laden.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => null);
|
||||
throw new Error(result?.error || `Failed to load config: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
setAppConfig(data.appConfig);
|
||||
setServices(data.services);
|
||||
setConfigLoaded(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load config');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateAppConfig = (field: keyof AppConfig, value: string | number | string[]) => {
|
||||
if (!appConfig) return;
|
||||
setAppConfig({ ...appConfig, [field]: value } as AppConfig);
|
||||
};
|
||||
|
||||
const updateService = (index: number, field: keyof Service, value: string | number | number[]) => {
|
||||
setServices((current) =>
|
||||
current.map((service, idx) => {
|
||||
if (idx !== index) return service;
|
||||
return {
|
||||
...service,
|
||||
[field]: value,
|
||||
} as Service;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const updateServiceHealthcheck = (
|
||||
index: number,
|
||||
field: 'timeoutMs' | 'acceptableStatusCodes',
|
||||
value: string | number | number[]
|
||||
) => {
|
||||
setServices((current) =>
|
||||
current.map((service, idx) => {
|
||||
if (idx !== index) return service;
|
||||
const healthcheck = service.healthcheck ?? { acceptableStatusCodes: [200], timeoutMs: 5000 };
|
||||
return {
|
||||
...service,
|
||||
healthcheck: {
|
||||
...healthcheck,
|
||||
[field]: value,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const addService = () => {
|
||||
setServices((current) => [...current, defaultService]);
|
||||
};
|
||||
|
||||
const removeService = (index: number) => {
|
||||
setServices((current) => current.filter((_, idx) => idx !== index));
|
||||
};
|
||||
|
||||
const parseStatusCodes = (value: string): number[] => {
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => Number(item));
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
if (!appConfig) {
|
||||
setError('Konfiguration wurde nicht geladen.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (appConfig.refreshInterval <= 0) {
|
||||
setError('Polling-Intervall muss größer als 0 sein.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!adminToken) {
|
||||
setError('Admin-Token ist erforderlich, um Änderungen zu speichern.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedCategories = appConfig.categories.map((category) => category.trim()).filter(Boolean);
|
||||
if (normalizedCategories.length === 0) {
|
||||
setError('Mindestens eine gültige Kategorie ist erforderlich.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (new Set(normalizedCategories).size !== normalizedCategories.length) {
|
||||
setError('Kategorien dürfen nicht doppelt vorkommen.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const serviceIds = new Set<string>();
|
||||
|
||||
for (const service of services) {
|
||||
if (!service.id.trim() || !service.name.trim() || !service.url.trim() || !service.monitorUrl.trim() || !service.category.trim()) {
|
||||
setError('Alle Dienste müssen ID, Name, URL, Monitor-URL und Kategorie enthalten.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (serviceIds.has(service.id.trim())) {
|
||||
setError(`Die Service-ID "${service.id.trim()}" ist doppelt vergeben.`);
|
||||
return false;
|
||||
}
|
||||
serviceIds.add(service.id.trim());
|
||||
|
||||
if (!normalizedCategories.includes(service.category.trim())) {
|
||||
setError(`Die Kategorie "${service.category.trim()}" ist nicht in den globalen Kategorien enthalten.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(service.url);
|
||||
new URL(service.monitorUrl);
|
||||
} catch {
|
||||
setError('Jede Service-URL muss ein gültiges URL-Format haben.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const acceptableCodes = service.healthcheck?.acceptableStatusCodes ?? [200];
|
||||
if (acceptableCodes.length === 0 || acceptableCodes.some((code) => !Number.isInteger(code) || code < 100 || code > 599)) {
|
||||
setError('Akzeptierte Statuscodes müssen Ganzzahlen zwischen 100 und 599 sein.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const timeoutMs = service.healthcheck?.timeoutMs ?? 5000;
|
||||
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
|
||||
setError('Timeout muss eine positive ganze Zahl sein.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const saveConfig = async () => {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
|
||||
if (!appConfig) {
|
||||
setError('Keine Konfiguration geladen.');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateForm()) {
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
appConfig: {
|
||||
...appConfig,
|
||||
refreshInterval: Number(appConfig.refreshInterval),
|
||||
},
|
||||
services,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(result?.error || 'Failed to save configuration');
|
||||
}
|
||||
|
||||
setMessage('Konfiguration erfolgreich gespeichert.');
|
||||
await loadConfig();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Speichern fehlgeschlagen');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const serviceCodeStrings = useMemo(
|
||||
() =>
|
||||
services.map((service) =>
|
||||
(service.healthcheck?.acceptableStatusCodes ?? [200]).join(', ')
|
||||
),
|
||||
[services]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
|
||||
<div className="max-w-8xl mx-auto px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8 rounded-3xl border border-slate-200 bg-white/90 p-6 shadow-xl shadow-slate-200/50 dark:border-slate-700 dark:bg-slate-900/90 dark:shadow-slate-950/20">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold">Admin-Konfiguration</h1>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
Hier kannst du globale Einstellungen und Services bearbeiten.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">Admin-Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={adminToken}
|
||||
onChange={(event) => setAdminToken(event.target.value)}
|
||||
className="w-full rounded-2xl border border-slate-300 bg-slate-50 px-4 py-2 text-sm dark:border-slate-700 dark:bg-slate-800"
|
||||
placeholder="Bearer-Token eingeben"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadConfig()}
|
||||
disabled={loading}
|
||||
className="w-full rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
{loading ? 'Lade…' : configLoaded ? 'Neu laden' : 'Konfiguration laden'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!configLoaded && !loading ? (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white/90 p-8 text-center text-slate-500 shadow-xl dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-300">
|
||||
Gib dein Admin-Token ein, um die Konfiguration zu laden.
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white/90 p-8 text-center text-slate-500 shadow-xl dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-300">
|
||||
Lade Konfiguration…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
<div className="mb-6 rounded-3xl border border-rose-200 bg-rose-50 px-5 py-4 text-sm text-rose-800 shadow-sm dark:border-rose-700/40 dark:bg-rose-950/30 dark:text-rose-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{message && (
|
||||
<div className="mb-6 rounded-3xl border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-900 shadow-sm dark:border-emerald-700/40 dark:bg-emerald-950/30 dark:text-emerald-200">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="mb-8 rounded-3xl border border-slate-200 bg-white/90 p-6 shadow-xl shadow-slate-200/50 dark:border-slate-700 dark:bg-slate-900/90">
|
||||
<h2 className="mb-4 text-xl font-semibold">Globale Einstellungen</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Polling-Intervall (ms)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1000}
|
||||
value={appConfig?.refreshInterval ?? 30000}
|
||||
onChange={(event) => updateAppConfig('refreshInterval', Number(event.target.value))}
|
||||
className="mt-2 w-full rounded-2xl border border-slate-300 bg-slate-50 px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-800"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Theme</span>
|
||||
<select
|
||||
value={appConfig?.theme ?? 'auto'}
|
||||
onChange={(event) => updateAppConfig('theme', event.target.value)}
|
||||
className="mt-2 w-full rounded-2xl border border-slate-300 bg-slate-50 px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-800"
|
||||
>
|
||||
{themes.map((theme) => (
|
||||
<option key={theme} value={theme}>{theme}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Logging-Level</span>
|
||||
<select
|
||||
value={appConfig?.loggingLevel ?? 'info'}
|
||||
onChange={(event) => updateAppConfig('loggingLevel', event.target.value)}
|
||||
className="mt-2 w-full rounded-2xl border border-slate-300 bg-slate-50 px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-800"
|
||||
>
|
||||
{loggingLevels.map((level) => (
|
||||
<option key={level} value={level}>{level}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-8 rounded-3xl border border-slate-200 bg-white/90 p-6 shadow-xl shadow-slate-200/50 dark:border-slate-700 dark:bg-slate-900/90">
|
||||
<div className="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Services</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Bearbeite bestehende Dienste oder füge neue hinzu.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addService}
|
||||
className="rounded-2xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700"
|
||||
>
|
||||
Dienst hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{services.map((service, index) => (
|
||||
<div key={`${service.id}-${index}`} className="rounded-3xl border border-slate-200 bg-slate-50 p-5 dark:border-slate-700 dark:bg-slate-950">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<h3 className="text-lg font-semibold">Dienst {index + 1}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeService(index)}
|
||||
className="rounded-2xl border border-rose-300 bg-rose-50 px-3 py-1.5 text-sm text-rose-700 transition hover:bg-rose-100 dark:border-rose-600 dark:bg-rose-950/40 dark:text-rose-200"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">ID</span>
|
||||
<input
|
||||
type="text"
|
||||
value={service.id}
|
||||
onChange={(event) => updateService(index, 'id', event.target.value)}
|
||||
className="mt-2 w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={service.name}
|
||||
onChange={(event) => updateService(index, 'name', event.target.value)}
|
||||
className="mt-2 w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Beschreibung</span>
|
||||
<textarea
|
||||
value={service.description}
|
||||
onChange={(event) => updateService(index, 'description', event.target.value)}
|
||||
rows={3}
|
||||
className="mt-2 w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm outline-none dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">URL</span>
|
||||
<input
|
||||
type="text"
|
||||
value={service.url}
|
||||
onChange={(event) => updateService(index, 'url', event.target.value)}
|
||||
className="mt-2 w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Monitor-URL</span>
|
||||
<input
|
||||
type="text"
|
||||
value={service.monitorUrl}
|
||||
onChange={(event) => updateService(index, 'monitorUrl', event.target.value)}
|
||||
className="mt-2 w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Kategorie</span>
|
||||
<input
|
||||
type="text"
|
||||
value={service.category}
|
||||
onChange={(event) => updateService(index, 'category', event.target.value)}
|
||||
className="mt-2 w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Timeout (ms)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={100}
|
||||
value={service.healthcheck?.timeoutMs ?? 5000}
|
||||
onChange={(event) => updateServiceHealthcheck(index, 'timeoutMs', Number(event.target.value))}
|
||||
className="mt-2 w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Akzeptierte Statuscodes</span>
|
||||
<input
|
||||
type="text"
|
||||
value={serviceCodeStrings[index]}
|
||||
onChange={(event) => updateServiceHealthcheck(index, 'acceptableStatusCodes', parseStatusCodes(event.target.value))}
|
||||
className="mt-2 w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-900"
|
||||
placeholder="200, 302"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Änderungen werden auf die lokale Konfigurationsdatei geschrieben.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveConfig}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center justify-center rounded-2xl bg-slate-900 px-6 py-3 text-sm font-semibold text-white transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:bg-slate-500"
|
||||
>
|
||||
{saving ? 'Speichere…' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
src/data/services.json
Normal file
146
src/data/services.json
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
[
|
||||
{
|
||||
"id": "unraid",
|
||||
"name": "Unraid",
|
||||
"description": "NAS und Server Management Platform",
|
||||
"url": "http://localhost",
|
||||
"monitorUrl": "http://localhost",
|
||||
"category": "Infrastruktur",
|
||||
"healthcheck": {
|
||||
"acceptableStatusCodes": [200, 302],
|
||||
"timeoutMs": 10000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "unifi",
|
||||
"name": "UniFi",
|
||||
"description": "Netzwerk-Management und WLAN-Controller",
|
||||
"url": "http://localhost:8443",
|
||||
"monitorUrl": "https://localhost:8443",
|
||||
"category": "Infrastruktur",
|
||||
"healthcheck": {
|
||||
"acceptableStatusCodes": [200, 302, 403],
|
||||
"timeoutMs": 8000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "checkmk",
|
||||
"name": "Checkmk",
|
||||
"description": "Monitoring und Alerting System",
|
||||
"url": "http://localhost/checkmk",
|
||||
"monitorUrl": "http://localhost/checkmk",
|
||||
"category": "Infrastruktur",
|
||||
"healthcheck": {
|
||||
"acceptableStatusCodes": [200, 302],
|
||||
"timeoutMs": 5000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nginx-proxy",
|
||||
"name": "Nginx Proxy Manager",
|
||||
"description": "Reverse Proxy und SSL-Verwaltung",
|
||||
"url": "http://localhost:81",
|
||||
"monitorUrl": "http://localhost:81",
|
||||
"category": "Infrastruktur",
|
||||
"healthcheck": {
|
||||
"acceptableStatusCodes": [200, 302],
|
||||
"timeoutMs": 5000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "home-assistant",
|
||||
"name": "Home Assistant",
|
||||
"description": "Smart Home Automation und Integration",
|
||||
"url": "http://localhost:8123",
|
||||
"monitorUrl": "http://localhost:8123",
|
||||
"category": "Smart Home",
|
||||
"healthcheck": {
|
||||
"acceptableStatusCodes": [200, 302],
|
||||
"timeoutMs": 5000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "plex",
|
||||
"name": "Plex",
|
||||
"description": "Medienserver und Streaming-Dienst",
|
||||
"url": "http://localhost:32400",
|
||||
"monitorUrl": "http://localhost:32400/web",
|
||||
"category": "Medien",
|
||||
"healthcheck": {
|
||||
"acceptableStatusCodes": [200, 302, 401],
|
||||
"timeoutMs": 8000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jellyfin",
|
||||
"name": "Jellyfin",
|
||||
"description": "Open-Source Medienserver",
|
||||
"url": "http://localhost:8096",
|
||||
"monitorUrl": "http://localhost:8096",
|
||||
"category": "Medien",
|
||||
"healthcheck": {
|
||||
"acceptableStatusCodes": [200, 302],
|
||||
"timeoutMs": 5000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "radarr",
|
||||
"name": "Radarr",
|
||||
"description": "Automatisches Filmmanagement",
|
||||
"url": "http://localhost:7878",
|
||||
"monitorUrl": "http://localhost:7878",
|
||||
"category": "Medien",
|
||||
"healthcheck": {
|
||||
"acceptableStatusCodes": [200, 302],
|
||||
"timeoutMs": 5000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sonarr",
|
||||
"name": "Sonarr",
|
||||
"description": "Automatisches Serienmanagement",
|
||||
"url": "http://localhost:8989",
|
||||
"monitorUrl": "http://localhost:8989",
|
||||
"category": "Medien",
|
||||
"healthcheck": {
|
||||
"acceptableStatusCodes": [200, 302],
|
||||
"timeoutMs": 5000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "prowlarr",
|
||||
"name": "Prowlarr",
|
||||
"description": "Indexer und Proxy Manager",
|
||||
"url": "http://localhost:9696",
|
||||
"monitorUrl": "http://localhost:9696",
|
||||
"category": "Medien",
|
||||
"healthcheck": {
|
||||
"acceptableStatusCodes": [200, 302],
|
||||
"timeoutMs": 5000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "seafile",
|
||||
"name": "Seafile",
|
||||
"description": "Dateifreigabe und Cloud Storage",
|
||||
"url": "http://localhost:8000",
|
||||
"monitorUrl": "http://localhost:8000",
|
||||
"category": "Dokumente",
|
||||
"healthcheck": {
|
||||
"acceptableStatusCodes": [200, 302],
|
||||
"timeoutMs": 5000
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "forgejo",
|
||||
"name": "Forgejo",
|
||||
"description": "Self-hosted Git und Code Repository",
|
||||
"url": "http://localhost:3000",
|
||||
"monitorUrl": "http://localhost:3000",
|
||||
"category": "Dokumente",
|
||||
"healthcheck": {
|
||||
"acceptableStatusCodes": [200, 302],
|
||||
"timeoutMs": 5000
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -1,107 +1,27 @@
|
|||
import { Service } from '@/src/types/service';
|
||||
import 'server-only';
|
||||
import { config } from '@/src/lib/config';
|
||||
import { servicesArraySchema } from '@/src/lib/config/schema';
|
||||
import { resolveProjectPath } from '@/src/lib/config/load-config';
|
||||
import type { Service } from '@/src/lib/config';
|
||||
|
||||
export const services: Service[] = [
|
||||
// Infrastruktur
|
||||
{
|
||||
id: 'unraid',
|
||||
name: 'Unraid',
|
||||
description: 'NAS und Server Management Platform',
|
||||
url: 'http://localhost',
|
||||
monitorUrl: 'http://localhost',
|
||||
category: 'Infrastruktur',
|
||||
},
|
||||
{
|
||||
id: 'unifi',
|
||||
name: 'UniFi',
|
||||
description: 'Netzwerk-Management und WLAN-Controller',
|
||||
url: 'http://localhost:8443',
|
||||
monitorUrl: 'http://localhost:8443',
|
||||
category: 'Infrastruktur',
|
||||
},
|
||||
{
|
||||
id: 'checkmk',
|
||||
name: 'Checkmk',
|
||||
description: 'Monitoring und Alerting System',
|
||||
url: 'http://localhost/checkmk',
|
||||
monitorUrl: 'http://localhost/checkmk',
|
||||
category: 'Infrastruktur',
|
||||
},
|
||||
{
|
||||
id: 'nginx-proxy',
|
||||
name: 'Nginx Proxy Manager',
|
||||
description: 'Reverse Proxy und SSL-Verwaltung',
|
||||
url: 'http://localhost:81',
|
||||
monitorUrl: 'http://localhost:81',
|
||||
category: 'Infrastruktur',
|
||||
},
|
||||
// Services werden einmal beim Import geladen
|
||||
// Note: This uses synchronous loading for backward compatibility
|
||||
// Future versions should use the async loadServices function
|
||||
let cachedServices: Service[] | null = null;
|
||||
|
||||
// Smart Home
|
||||
{
|
||||
id: 'home-assistant',
|
||||
name: 'Home Assistant',
|
||||
description: 'Smart Home Automation und Integration',
|
||||
url: 'http://localhost:8123',
|
||||
monitorUrl: 'http://localhost:8123',
|
||||
category: 'Smart Home',
|
||||
},
|
||||
function loadServicesSync(): Service[] {
|
||||
if (!cachedServices) {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const servicesPath = resolveProjectPath(config.servicesFile);
|
||||
const servicesData = fs.readFileSync(servicesPath, 'utf8');
|
||||
cachedServices = servicesArraySchema.parse(JSON.parse(servicesData));
|
||||
} catch (error) {
|
||||
console.warn('Failed to load services synchronously, using empty array');
|
||||
cachedServices = [];
|
||||
}
|
||||
}
|
||||
return cachedServices;
|
||||
}
|
||||
|
||||
// Medien
|
||||
{
|
||||
id: 'plex',
|
||||
name: 'Plex',
|
||||
description: 'Medienserver und Streaming-Dienst',
|
||||
url: 'http://localhost:32400',
|
||||
monitorUrl: 'http://localhost:32400',
|
||||
category: 'Medien',
|
||||
},
|
||||
{
|
||||
id: 'jellyfin',
|
||||
name: 'Jellyfin',
|
||||
description: 'Open-Source Medienserver',
|
||||
url: 'http://localhost:8096',
|
||||
monitorUrl: 'http://localhost:8096',
|
||||
category: 'Medien',
|
||||
},
|
||||
{
|
||||
id: 'radarr',
|
||||
name: 'Radarr',
|
||||
description: 'Automatisches Filmmanagement',
|
||||
url: 'http://localhost:7878',
|
||||
monitorUrl: 'http://localhost:7878',
|
||||
category: 'Medien',
|
||||
},
|
||||
{
|
||||
id: 'sonarr',
|
||||
name: 'Sonarr',
|
||||
description: 'Automatisches Serienmanagement',
|
||||
url: 'http://localhost:8989',
|
||||
monitorUrl: 'http://localhost:8989',
|
||||
category: 'Medien',
|
||||
},
|
||||
{
|
||||
id: 'prowlarr',
|
||||
name: 'Prowlarr',
|
||||
description: 'Indexer und Proxy Manager',
|
||||
url: 'http://localhost:9696',
|
||||
monitorUrl: 'http://localhost:9696',
|
||||
category: 'Medien',
|
||||
},
|
||||
|
||||
// Dokumente
|
||||
{
|
||||
id: 'seafile',
|
||||
name: 'Seafile',
|
||||
description: 'Dateifreigabe und Cloud Storage',
|
||||
url: 'http://localhost:8000',
|
||||
monitorUrl: 'http://localhost:8000',
|
||||
category: 'Dokumente',
|
||||
},
|
||||
{
|
||||
id: 'forgejo',
|
||||
name: 'Forgejo',
|
||||
description: 'Self-hosted Git und Code Repository',
|
||||
url: 'http://localhost:3000',
|
||||
monitorUrl: 'http://localhost:3000',
|
||||
category: 'Dokumente',
|
||||
},
|
||||
];
|
||||
export const services: Service[] = loadServicesSync();
|
||||
|
|
|
|||
60
src/lib/config.ts
Normal file
60
src/lib/config.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import 'server-only';
|
||||
import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './config/schema';
|
||||
import { resolveProjectPath } from './config/load-config';
|
||||
|
||||
// Legacy export for backward compatibility
|
||||
export type { AppConfig };
|
||||
|
||||
// Load config synchronously for backward compatibility
|
||||
let cachedConfig: AppConfig | null = null;
|
||||
let cachedServices: Services | null = null;
|
||||
|
||||
function loadConfigSync(): AppConfig {
|
||||
if (!cachedConfig) {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const configPath = path.join(process.cwd(), 'config.json');
|
||||
const configData = fs.readFileSync(configPath, 'utf8');
|
||||
cachedConfig = appConfigSchema.parse(JSON.parse(configData));
|
||||
} catch (error) {
|
||||
console.warn('Failed to load config synchronously, using defaults');
|
||||
cachedConfig = {
|
||||
title: 'Homelab Dashboard',
|
||||
subtitle: 'Live-Status aller verwalteten Dienste',
|
||||
description: 'Live-Monitoring-Dashboard für dein Homelab',
|
||||
servicesFile: 'src/data/services.json',
|
||||
refreshInterval: 30000,
|
||||
categories: ['Infrastruktur', 'Smart Home', 'Medien', 'Dokumente'],
|
||||
theme: 'auto',
|
||||
loggingLevel: 'info',
|
||||
};
|
||||
}
|
||||
}
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
function loadServicesSync(): Services {
|
||||
if (!cachedServices) {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const config = loadConfigSync();
|
||||
const servicesPath = resolveProjectPath(config.servicesFile);
|
||||
const servicesData = fs.readFileSync(servicesPath, 'utf8');
|
||||
cachedServices = servicesArraySchema.parse(JSON.parse(servicesData));
|
||||
} catch (error) {
|
||||
console.warn('Failed to load services synchronously, using empty array');
|
||||
cachedServices = [];
|
||||
}
|
||||
}
|
||||
return cachedServices;
|
||||
}
|
||||
|
||||
// Export cached versions for backward compatibility
|
||||
export const config: AppConfig = loadConfigSync();
|
||||
export const services: Services = loadServicesSync();
|
||||
|
||||
// Export new async functions
|
||||
export { loadAppConfig, loadServices, loadFullConfig } from './config/load-config';
|
||||
export { saveAppConfig, saveServices, backupConfig } from './config/save-config';
|
||||
export type { Service, Healthcheck } from './config/schema';
|
||||
117
src/lib/config/load-config.ts
Normal file
117
src/lib/config/load-config.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import 'server-only';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './schema';
|
||||
|
||||
// Default configurations
|
||||
const defaultAppConfig: AppConfig = {
|
||||
title: 'Homelab Dashboard',
|
||||
subtitle: 'Live-Status aller verwalteten Dienste',
|
||||
description: 'Live-Monitoring-Dashboard für dein Homelab',
|
||||
servicesFile: 'src/data/services.json',
|
||||
refreshInterval: 30000,
|
||||
categories: ['Infrastruktur', 'Smart Home', 'Medien', 'Dokumente'],
|
||||
theme: 'auto',
|
||||
loggingLevel: 'info',
|
||||
};
|
||||
|
||||
const defaultServices: Services = [];
|
||||
|
||||
function resolveProjectPath(relativePath: string): string {
|
||||
const normalizedPath = path.normalize(relativePath);
|
||||
const resolvedPath = path.resolve(process.cwd(), normalizedPath);
|
||||
const relativeToRoot = path.relative(process.cwd(), resolvedPath);
|
||||
|
||||
if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) {
|
||||
throw new Error(`Path must stay within the project directory: ${relativePath}`);
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt und validiert die App-Konfiguration
|
||||
*/
|
||||
export async function loadAppConfig(configPath?: string): Promise<AppConfig> {
|
||||
const filePath = configPath || path.join(process.cwd(), 'config.json');
|
||||
|
||||
try {
|
||||
const configData = await fs.readFile(filePath, 'utf8');
|
||||
const rawConfig = JSON.parse(configData);
|
||||
|
||||
// Validate with zod
|
||||
const validatedConfig = appConfigSchema.parse(rawConfig);
|
||||
|
||||
return validatedConfig;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load app config from ${filePath}:`, error);
|
||||
|
||||
// Return defaults if file doesn't exist or is invalid
|
||||
if (error instanceof Error && error.message.includes('ENOENT')) {
|
||||
console.info('Using default app configuration');
|
||||
return defaultAppConfig;
|
||||
}
|
||||
|
||||
// For validation errors, try to merge with defaults
|
||||
if (error instanceof Error && error.name === 'ZodError') {
|
||||
console.warn('Configuration validation failed, using defaults where possible');
|
||||
return defaultAppConfig;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt und validiert die Services-Konfiguration
|
||||
*/
|
||||
export async function loadServices(servicesPath?: string): Promise<Services> {
|
||||
const filePath = servicesPath || resolveProjectPath('src/data/services.json');
|
||||
|
||||
try {
|
||||
const servicesData = await fs.readFile(filePath, 'utf8');
|
||||
const rawServices = JSON.parse(servicesData);
|
||||
|
||||
// Validate with zod
|
||||
const validatedServices = servicesArraySchema.parse(rawServices);
|
||||
|
||||
return validatedServices;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load services from ${filePath}:`, error);
|
||||
|
||||
// Return empty array if file doesn't exist
|
||||
if (error instanceof Error && error.message.includes('ENOENT')) {
|
||||
console.info('No services configuration found, using empty array');
|
||||
return defaultServices;
|
||||
}
|
||||
|
||||
// For validation errors, return empty array
|
||||
if (error instanceof Error && error.name === 'ZodError') {
|
||||
console.warn('Services validation failed, using empty array');
|
||||
return defaultServices;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt die vollständige Konfiguration (App + Services)
|
||||
*/
|
||||
export async function loadFullConfig(configPath?: string): Promise<{
|
||||
appConfig: AppConfig;
|
||||
services: Services;
|
||||
}> {
|
||||
const appConfig = await loadAppConfig(configPath);
|
||||
|
||||
const servicesPath = resolveProjectPath(appConfig.servicesFile);
|
||||
|
||||
const services = await loadServices(servicesPath);
|
||||
|
||||
return {
|
||||
appConfig,
|
||||
services,
|
||||
};
|
||||
}
|
||||
|
||||
export { resolveProjectPath };
|
||||
87
src/lib/config/save-config.ts
Normal file
87
src/lib/config/save-config.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import 'server-only';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { appConfigSchema, servicesArraySchema, type AppConfig, type Services } from './schema';
|
||||
import { resolveProjectPath } from './load-config';
|
||||
|
||||
/**
|
||||
* Speichert die App-Konfiguration
|
||||
*/
|
||||
export async function saveAppConfig(config: AppConfig, configPath?: string): Promise<void> {
|
||||
const filePath = configPath || path.join(process.cwd(), 'config.json');
|
||||
|
||||
try {
|
||||
// Validate before saving
|
||||
appConfigSchema.parse(config);
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(filePath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
// Write with pretty formatting
|
||||
const configData = JSON.stringify(config, null, 2);
|
||||
await fs.writeFile(filePath, configData, 'utf8');
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to save app config to ${filePath}:`, error);
|
||||
throw new Error(`Failed to save configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert die Services-Konfiguration
|
||||
*/
|
||||
export async function saveServices(services: Services, servicesPath?: string): Promise<void> {
|
||||
const filePath = servicesPath || resolveProjectPath('src/data/services.json');
|
||||
|
||||
try {
|
||||
// Validate before saving
|
||||
servicesArraySchema.parse(services);
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(filePath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
// Write with pretty formatting
|
||||
const servicesData = JSON.stringify(services, null, 2);
|
||||
await fs.writeFile(filePath, servicesData, 'utf8');
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to save services to ${filePath}:`, error);
|
||||
throw new Error(`Failed to save services: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt ein Backup der aktuellen Konfiguration
|
||||
*/
|
||||
export async function backupConfig(backupDir?: string): Promise<string> {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupPath = backupDir || path.join(process.cwd(), 'backups');
|
||||
const backupFile = path.join(backupPath, `config-backup-${timestamp}.json`);
|
||||
|
||||
try {
|
||||
// Load current config
|
||||
const { loadFullConfig } = await import('./load-config');
|
||||
const { appConfig, services } = await loadFullConfig();
|
||||
|
||||
// Create backup data
|
||||
const backupData = {
|
||||
timestamp,
|
||||
appConfig,
|
||||
services,
|
||||
};
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(backupPath, { recursive: true });
|
||||
|
||||
// Write backup
|
||||
const backupJson = JSON.stringify(backupData, null, 2);
|
||||
await fs.writeFile(backupFile, backupJson, 'utf8');
|
||||
|
||||
return backupFile;
|
||||
} catch (error) {
|
||||
console.error('Failed to create backup:', error);
|
||||
throw new Error(`Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
65
src/lib/config/schema.ts
Normal file
65
src/lib/config/schema.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
const safeRelativePathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.refine((value) => !value.startsWith('/') && !value.startsWith('\\'), {
|
||||
message: 'Path must be relative to the project root',
|
||||
})
|
||||
.refine((value) => !value.split(/[\\/]+/).includes('..'), {
|
||||
message: 'Path must not contain parent directory segments',
|
||||
});
|
||||
|
||||
// Healthcheck Schema
|
||||
export const healthcheckSchema = z.object({
|
||||
acceptableStatusCodes: z.array(z.number().int().min(100).max(599)).min(1).default([200]),
|
||||
timeoutMs: z.number().int().positive().default(5000),
|
||||
}).strict();
|
||||
|
||||
// Service Schema
|
||||
export const serviceSchema = z.object({
|
||||
id: z.string().min(1).max(50),
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().min(1).max(500),
|
||||
url: z.string().url(),
|
||||
monitorUrl: z.string().url(),
|
||||
category: z.string().min(1).max(50),
|
||||
healthcheck: healthcheckSchema.optional(),
|
||||
}).strict();
|
||||
|
||||
// App Config Schema
|
||||
export const appConfigSchema = z.object({
|
||||
title: z.string().min(1).max(100),
|
||||
subtitle: z.string().min(1).max(200),
|
||||
description: z.string().min(1).max(500),
|
||||
servicesFile: safeRelativePathSchema,
|
||||
refreshInterval: z.number().int().min(1000).max(300000).default(30000), // 1s to 5min
|
||||
categories: z.array(z.string().min(1).max(50)).min(1),
|
||||
theme: z.enum(['light', 'dark', 'auto']).default('auto'),
|
||||
loggingLevel: z.enum(['error', 'warn', 'info', 'debug']).default('info'),
|
||||
}).strict();
|
||||
|
||||
// Services Array Schema
|
||||
export const servicesArraySchema = z.array(serviceSchema).superRefine((services, ctx) => {
|
||||
const ids = new Set<string>();
|
||||
|
||||
services.forEach((service, index) => {
|
||||
if (ids.has(service.id)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate service id: ${service.id}`,
|
||||
path: [index, 'id'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ids.add(service.id);
|
||||
});
|
||||
});
|
||||
|
||||
// Type exports
|
||||
export type Service = z.infer<typeof serviceSchema>;
|
||||
export type Healthcheck = z.infer<typeof healthcheckSchema>;
|
||||
export type AppConfig = z.infer<typeof appConfigSchema>;
|
||||
export type Services = z.infer<typeof servicesArraySchema>;
|
||||
|
|
@ -7,6 +7,10 @@ export interface Service {
|
|||
url: string;
|
||||
monitorUrl: string;
|
||||
category: string;
|
||||
healthcheck?: {
|
||||
acceptableStatusCodes?: number[];
|
||||
timeoutMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ServiceCheckResult {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue