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 |
|
| Status | Farbe | Beschreibung |
|
||||||
|--------|-------|-------------|
|
|--------|-------|-------------|
|
||||||
| Online | 🟢 Grün | Service läuft einwandfrei |
|
| 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 |
|
| Warning | 🟡 Orange | Service hat Probleme, läuft aber |
|
||||||
| Offline | 🔴 Rot | Service ist nicht erreichbar |
|
| 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';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { ServiceCheckResult } from '@/src/types/service';
|
import { ServiceStatus } from '@/src/types/service';
|
||||||
import { services } from '@/src/data/services';
|
import { AppConfig, Service } from '@/src/lib/config';
|
||||||
import { ServiceCard } from './ServiceCard';
|
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 [results, setResults] = useState<ServiceCheckResult[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 {
|
try {
|
||||||
setError(null);
|
if (isManualRefresh) {
|
||||||
|
setRefreshing(true);
|
||||||
|
} else {
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch('/api/status');
|
const response = await fetch('/api/status');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch status');
|
throw new Error('Failed to fetch status');
|
||||||
}
|
}
|
||||||
const data: ServiceCheckResult[] = await response.json();
|
const data: ServiceCheckResult[] = await response.json();
|
||||||
setResults(data);
|
setResults(data);
|
||||||
|
setLastUpdated(new Date().toISOString());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
|
|
||||||
// Auto-refresh every 30 seconds
|
const interval = setInterval(() => fetchStatus(), config.refreshInterval);
|
||||||
const interval = setInterval(fetchStatus, 30000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
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 => {
|
const getResultForService = (serviceId: string): ServiceCheckResult | undefined => {
|
||||||
return results.find(result => result.id === serviceId);
|
return results.find(result => result.id === serviceId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleManualRefresh = () => {
|
||||||
|
fetchStatus(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -47,18 +106,20 @@ export function DashboardClient() {
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-3xl sm:text-4xl font-bold text-white tracking-tight">
|
<h1 className="text-3xl sm:text-4xl font-bold text-white tracking-tight">
|
||||||
Homelab Dashboard
|
{config.title}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="text-sm text-slate-400">
|
<div className="text-sm text-slate-400">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
'Lade Status...'
|
'Lade Status...'
|
||||||
|
) : refreshing ? (
|
||||||
|
'Aktualisiere...'
|
||||||
) : (
|
) : (
|
||||||
'Automatische Aktualisierung: 30s'
|
`Automatische Aktualisierung: ${Math.round(config.refreshInterval / 1000)}s`
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-400 text-sm">
|
<p className="text-slate-400 text-sm">
|
||||||
Live-Status aller verwalteten Dienste
|
{config.subtitle}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -89,9 +150,25 @@ export function DashboardClient() {
|
||||||
</div>
|
</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 */}
|
{/* Services Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
<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
|
<ServiceCard
|
||||||
key={service.id}
|
key={service.id}
|
||||||
service={service}
|
service={service}
|
||||||
|
|
@ -99,7 +176,32 @@ export function DashboardClient() {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</main>
|
||||||
</div>
|
</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 { FilterBar } from './FilterBar';
|
||||||
export { EmptyState } from './EmptyState';
|
export { EmptyState } from './EmptyState';
|
||||||
export { DashboardClient } from './dashboard-client';
|
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 type { Metadata } from 'next';
|
||||||
import { DashboardClient } from './components/dashboard-client';
|
import { DashboardClient } from './components/dashboard-client';
|
||||||
|
import { loadFullConfig } from '@/src/lib/config';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
|
@ -7,6 +8,8 @@ export const metadata: Metadata = {
|
||||||
description: 'Live-Monitoring-Dashboard für dein Homelab',
|
description: 'Live-Monitoring-Dashboard für dein Homelab',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Home() {
|
export default async function Home() {
|
||||||
return <DashboardClient />;
|
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} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {}
|
const nextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = nextConfig
|
module.exports = nextConfig
|
||||||
|
|
|
||||||
12
package-lock.json
generated
12
package-lock.json
generated
|
|
@ -10,7 +10,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "^16.2.3",
|
"next": "^16.2.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.10.6",
|
"@types/node": "^20.10.6",
|
||||||
|
|
@ -6516,6 +6517,15 @@
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "eslint app src lib --ext .ts,.tsx"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "^16.2.3",
|
"next": "^16.2.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.10.6",
|
"@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 { NextResponse } from 'next/server';
|
||||||
import { services } from '@/src/data/services';
|
import { loadFullConfig } from '@/src/lib/config';
|
||||||
import { ServiceCheckResult } from '@/src/types/service';
|
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';
|
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();
|
const startTime = Date.now();
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const defaultTimeout = 5000;
|
||||||
|
const defaultAcceptableCodes = [200];
|
||||||
|
const timeout = service.healthcheck?.timeoutMs || defaultTimeout;
|
||||||
|
const acceptableCodes = service.healthcheck?.acceptableStatusCodes || defaultAcceptableCodes;
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
|
timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||||
|
|
||||||
const response = await fetch(service.monitorUrl, {
|
const response = await fetch(service.monitorUrl, {
|
||||||
method: 'HEAD', // Use HEAD to minimize data transfer
|
method: 'HEAD',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
const responseTime = Date.now() - startTime;
|
const responseTime = Date.now() - startTime;
|
||||||
|
|
||||||
let status: ServiceCheckResult['status'];
|
let status: ServiceCheckResult['status'];
|
||||||
|
const isAcceptableStatus = acceptableCodes.includes(response.status);
|
||||||
|
|
||||||
if (response.ok) {
|
if (isAcceptableStatus) {
|
||||||
status = responseTime <= 2000 ? 'online' : 'warning';
|
status = responseTime <= 2000 ? 'online' : 'warning';
|
||||||
} else if (response.status >= 400 && response.status < 500) {
|
} else if (response.status >= 400 && response.status < 600) {
|
||||||
status = 'warning';
|
|
||||||
} else {
|
|
||||||
status = 'offline';
|
status = 'offline';
|
||||||
|
} else {
|
||||||
|
status = 'warning';
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -38,23 +51,24 @@ async function checkService(service: typeof services[0]): Promise<ServiceCheckRe
|
||||||
checkedAt: new Date().toISOString(),
|
checkedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const responseTime = Date.now() - startTime;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: service.id,
|
id: service.id,
|
||||||
status: 'offline',
|
status: 'offline',
|
||||||
responseTimeMs: responseTime,
|
responseTimeMs: Date.now() - startTime,
|
||||||
httpStatus: null,
|
httpStatus: null,
|
||||||
checkedAt: new Date().toISOString(),
|
checkedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
} finally {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const results = await Promise.all(
|
const { services } = await loadFullConfig();
|
||||||
services.map(service => checkService(service))
|
const results = await Promise.all(services.map((service: Service) => checkService(service)));
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(results);
|
return NextResponse.json(results);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -64,4 +78,4 @@ export async function GET() {
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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[] = [
|
// Services werden einmal beim Import geladen
|
||||||
// Infrastruktur
|
// Note: This uses synchronous loading for backward compatibility
|
||||||
{
|
// Future versions should use the async loadServices function
|
||||||
id: 'unraid',
|
let cachedServices: Service[] | null = null;
|
||||||
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',
|
|
||||||
},
|
|
||||||
|
|
||||||
// Smart Home
|
function loadServicesSync(): Service[] {
|
||||||
{
|
if (!cachedServices) {
|
||||||
id: 'home-assistant',
|
try {
|
||||||
name: 'Home Assistant',
|
const fs = require('fs');
|
||||||
description: 'Smart Home Automation und Integration',
|
const servicesPath = resolveProjectPath(config.servicesFile);
|
||||||
url: 'http://localhost:8123',
|
const servicesData = fs.readFileSync(servicesPath, 'utf8');
|
||||||
monitorUrl: 'http://localhost:8123',
|
cachedServices = servicesArraySchema.parse(JSON.parse(servicesData));
|
||||||
category: 'Smart Home',
|
} catch (error) {
|
||||||
},
|
console.warn('Failed to load services synchronously, using empty array');
|
||||||
|
cachedServices = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cachedServices;
|
||||||
|
}
|
||||||
|
|
||||||
// Medien
|
export const services: Service[] = loadServicesSync();
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
|
||||||
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;
|
url: string;
|
||||||
monitorUrl: string;
|
monitorUrl: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
healthcheck?: {
|
||||||
|
acceptableStatusCodes?: number[];
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServiceCheckResult {
|
export interface ServiceCheckResult {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue