diff --git a/docker/ui/Dockerfile b/docker/ui/Dockerfile index 21c1e29..c143c6b 100644 --- a/docker/ui/Dockerfile +++ b/docker/ui/Dockerfile @@ -1,14 +1,21 @@ -FROM node:22-alpine +# UI-Image: Vite-Build → statisch via nginx (KONZEPT-konform). +# Vorher lief hier `npm run dev` — der Vite-ENTWICKLUNGSSERVER — als +# "Produktion". Jetzt: echter Build, nginx liefert aus und proxied /api. + +FROM node:22-alpine AS build WORKDIR /app -RUN apk add --no-cache curl - -COPY docker/ui/package.json docker/ui/package-lock.json* ./ -RUN npm ci 2>/dev/null || npm install +COPY docker/ui/package.json docker/ui/package-lock.json ./ +RUN npm ci --no-audit --no-fund COPY docker/ui/ . +RUN npm run build + + +FROM nginx:alpine + +COPY docker/ui/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html EXPOSE 80 - -CMD ["npm", "run", "dev"] diff --git a/docker/ui/nginx.conf b/docker/ui/nginx.conf new file mode 100644 index 0000000..6a593df --- /dev/null +++ b/docker/ui/nginx.conf @@ -0,0 +1,25 @@ +# Rippy UI: statische Dateien + API-Proxy. +# /api/* wird an den api-Container durchgereicht (Präfix wird entfernt), +# alles andere ist das gebaute React-UI. Damit braucht der Browser nur +# EINEN Origin — kein hartkodiertes localhost, kein CORS-Gefrickel. + +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://api:8000/; + proxy_http_version 1.1; + proxy_set_header Host $host; + # SSE (/api/stream/jobs) braucht ungepufferte, lange Verbindungen: + proxy_buffering off; + proxy_read_timeout 3600s; + } + + location / { + try_files $uri /index.html; + } +} diff --git a/docker/ui/src/components/DeviceDiscovery.tsx b/docker/ui/src/components/DeviceDiscovery.tsx index b7c8a96..195f7ca 100644 --- a/docker/ui/src/components/DeviceDiscovery.tsx +++ b/docker/ui/src/components/DeviceDiscovery.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' -import axios from 'axios' import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react' +import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' interface Device { @@ -13,8 +13,6 @@ interface Device { model?: string } -const API_URL = 'http://localhost:8000' - export default function DeviceDiscovery() { const [devices, setDevices] = useState([]) const [loading, setLoading] = useState(true) @@ -22,7 +20,7 @@ export default function DeviceDiscovery() { const refreshDevices = async () => { try { - const response = await axios.get(`${API_URL}/devices`) + const response = await api.get('/devices') setDevices(response.data) } catch (error) { console.error('Fehler beim Laden der Geräte:', error) diff --git a/docker/ui/src/components/LiveLogSection.tsx b/docker/ui/src/components/LiveLogSection.tsx index da72d4d..5094679 100644 --- a/docker/ui/src/components/LiveLogSection.tsx +++ b/docker/ui/src/components/LiveLogSection.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' -import axios from 'axios' -import { Terminal, Disc, Play, CheckCircle, AlertCircle, Clock, Download } from 'lucide-react' +import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react' +import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' interface LiveLogEntry { @@ -16,12 +16,10 @@ export default function LiveLogSection() { const [currentJob, setCurrentJob] = useState(null) const { theme } = useDarkMode() - const API_URL = 'http://localhost:8000' - useEffect(() => { const fetchJobs = async () => { try { - const response = await axios.get(`${API_URL}/jobs`) + const response = await api.get('/jobs') const jobs = response.data const current = jobs.find((j: any) => j.status === 'processing') setCurrentJob(current || jobs.find((j: any) => j.status === 'pending')) @@ -30,15 +28,23 @@ export default function LiveLogSection() { } } - fetchJobs() - const jobInterval = setInterval(fetchJobs, 5000) + const fetchLogs = async () => { + try { + const response = await api.get('/logs', { params: { limit: 50 } }) + setLogs(response.data) + } catch (error) { + console.error('Fehler beim Laden der Logs:', error) + } + } - // TODO: WebSocket/SSE für Echtzeit-Logs implementieren - // const logInterval = setInterval(fetchLogs, 1000) + fetchJobs() + fetchLogs() + const jobInterval = setInterval(fetchJobs, 5000) + const logInterval = setInterval(fetchLogs, 5000) return () => { clearInterval(jobInterval) - // clearInterval(logInterval) + clearInterval(logInterval) } }, []) diff --git a/docker/ui/src/pages/Dashboard.tsx b/docker/ui/src/pages/Dashboard.tsx index 7e9ed10..6cffd5f 100644 --- a/docker/ui/src/pages/Dashboard.tsx +++ b/docker/ui/src/pages/Dashboard.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -import axios from 'axios' import { Clock, Activity, HardDrive, BarChart, AlertCircle, CheckCircle, Disc, Play, Pause, SkipForward } from 'lucide-react' +import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' import DeviceDiscovery from '../components/DeviceDiscovery' import LiveLogSection from '../components/LiveLogSection' @@ -24,11 +24,9 @@ interface Device { status: 'empty' | 'ready' | 'ripping' } -const API_URL = 'http://localhost:8000' - async function fetchJobs(): Promise { try { - const response = await axios.get(`${API_URL}/jobs`) + const response = await api.get('/jobs') return response.data } catch { return [] @@ -37,7 +35,7 @@ async function fetchJobs(): Promise { async function fetchDevices(): Promise { try { - const response = await axios.get(`${API_URL}/devices`) + const response = await api.get('/devices') return response.data } catch { return [] diff --git a/docker/ui/src/pages/Logs.tsx b/docker/ui/src/pages/Logs.tsx index 6f6c06a..6f3def2 100644 --- a/docker/ui/src/pages/Logs.tsx +++ b/docker/ui/src/pages/Logs.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' -import { Terminal, Download, AlertCircle, CheckCircle, Clock, RefreshCw } from 'lucide-react' +import { Terminal, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react' +import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' interface LogEntry { @@ -18,25 +19,11 @@ export default function LogsPage() { const { theme } = useDarkMode() const refreshLogs = async () => { + // Echte Ereignisse aus der Datenbank — die Mock-Einträge („The Matrix + // gerippt") sind raus. Was hier steht, ist passiert; sonst ist es leer. try { - // TODO: API endpoint für Logs implementieren - // const response = await axios.get(`${API_URL}/logs`) - // setLogs(response.data) - - // Mock-Daten für Demo - const mockLogs: LogEntry[] = [ - { id: '1', timestamp: '2024-01-15 14:30:22', level: 'info', source: 'udev-daemon', message: 'Disc-Einwurf erkannt' }, - { id: '2', timestamp: '2024-01-15 14:30:25', level: 'info', source: 'worker', message: 'Job erstellt: DVD, Device: /dev/disc/PLEXTOR_BD_XD' }, - { id: '3', timestamp: '2024-01-15 14:30:30', level: 'success', source: 'prescan', message: 'Pre-Scan abgeschlossen, Confidence: 0.92' }, - { id: '4', timestamp: '2024-01-15 14:30:35', level: 'info', source: 'tmdb', message: 'Metadaten lookup erfolgreich: "The Matrix"' }, - { id: '5', timestamp: '2024-01-15 14:31:00', level: 'info', source: 'ripping', message: 'Starte MakeMKV-Ripping...' }, - { id: '6', timestamp: '2024-01-15 14:35:22', level: 'warning', source: 'ripping', message: 'Kapitel 5 übersprungen (Trailer)' }, - { id: '7', timestamp: '2024-01-15 14:40:15', level: 'success', source: 'ripping', message: 'Ripping abgeschlossen, Dateigröße: 4.2 GB' }, - { id: '8', timestamp: '2024-01-15 14:40:20', level: 'info', source: 'jellyfin', message: 'NFO-Datei generiert' }, - { id: '9', timestamp: '2024-01-15 14:40:25', level: 'info', source: 'jellyfin', message: 'Poster heruntergeladen' }, - { id: '10', timestamp: '2024-01-15 14:40:30', level: 'success', source: 'worker', message: 'Job abgeschlossen' }, - ] - setLogs(mockLogs) + const response = await api.get('/logs', { params: { limit: 200 } }) + setLogs(response.data) } catch (error) { console.error('Fehler beim Laden der Logs:', error) } finally { diff --git a/docker/ui/src/pages/MetadataPreview.tsx b/docker/ui/src/pages/MetadataPreview.tsx index e795f21..e5c5283 100644 --- a/docker/ui/src/pages/MetadataPreview.tsx +++ b/docker/ui/src/pages/MetadataPreview.tsx @@ -1,10 +1,14 @@ import { useState, useEffect } from 'react' -import axios from 'axios' import { BookOpen, Music, Film, AlertCircle, CheckCircle, Search, Loader2 } from 'lucide-react' import type { LucideIcon } from 'lucide-react' +import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' -const API_URL = 'http://localhost:8000' +interface DeviceOption { + path: string + name: string + status: string +} interface Metadata { type: 'movie' | 'tv' | 'music' | 'unknown' @@ -35,12 +39,32 @@ interface PrescanResult { export default function MetadataPreview() { const [device, setDevice] = useState('') + const [devices, setDevices] = useState([]) const [result, setResult] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [confirming, setConfirming] = useState(false) const { theme } = useDarkMode() + // Echte Laufwerke laden — vorher standen hier erfundene Festwerte + // (/dev/dvd, /dev/bluray), die es im Container nie gab. + useEffect(() => { + const loadDevices = async () => { + try { + const response = await api.get('/devices') + setDevices(response.data) + if (response.data.length === 1) { + setDevice(response.data[0].path) + } + } catch { + setDevices([]) + } + } + loadDevices() + const interval = setInterval(loadDevices, 5000) + return () => clearInterval(interval) + }, []) + const handleScan = async () => { if (!device) { setError('Bitte wählen Sie ein Gerät aus.') @@ -51,7 +75,7 @@ export default function MetadataPreview() { setError(null) try { - const response = await axios.post(`${API_URL}/prescan`, { device_path: device }) + const response = await api.post('/prescan', { device_path: device }) setResult(response.data) } catch (err) { setError('Pre-Scan fehlgeschlagen.') @@ -66,16 +90,16 @@ export default function MetadataPreview() { setConfirming(true) try { - await axios.post(`${API_URL}/metadata/confirm`, { + await api.post('/metadata/confirm', { title: result.title, year: result.year, metadata: result.metadata }) - // Job erstellen - await axios.post(`${API_URL}/jobs`, { - device: device, - type: result.disc_type + // Job erstellen — POST /jobs existiert seit 23.07. wirklich + await api.post('/jobs', { + device_path: device, + title: result.title }) } catch (err) { setError('Bestätigung fehlgeschlagen.') @@ -119,10 +143,14 @@ export default function MetadataPreview() { onChange={(e) => setDevice(e.target.value)} className={`flex-1 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-700 border-slate-600 text-slate-100' : 'bg-white border-gray-300 text-slate-900'}`} > - - - - + + {devices.map((d) => ( + + ))}