UI: echter Build hinter nginx, relative API-Pfade, Logs-Seite ehrlich
Vorher lief der Vite-ENTWICKLUNGSSERVER als Produktion, und vier Dateien riefen hartkodiert http://localhost:8000 auf — vom PC aus fragte der Browser damit den PC selbst nach Laufwerken (deshalb blieb das UI immer leer); Settings nutzte ein nie gesetztes VITE_API_URL. Die Logs-Seite zeigte fest einprogrammierte Fantasie-Einträge ("The Matrix gerippt"). - lib/api.ts: axios-Instanz mit baseURL /api; nginx proxied /api → api:8000 (ein Origin, kein CORS), SSE-tauglich (proxy_buffering off) - Dockerfile: Vite-Build → nginx:alpine statt npm run dev - Logs + LiveLog von echtem /logs-Endpoint; MetadataPreview: echte Laufwerke aus /devices statt erfundener /dev/bluray-Optionen, startet echten Job Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+14
-7
@@ -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
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache curl
|
COPY docker/ui/package.json docker/ui/package-lock.json ./
|
||||||
|
RUN npm ci --no-audit --no-fund
|
||||||
COPY docker/ui/package.json docker/ui/package-lock.json* ./
|
|
||||||
RUN npm ci 2>/dev/null || npm install
|
|
||||||
|
|
||||||
COPY docker/ui/ .
|
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
|
EXPOSE 80
|
||||||
|
|
||||||
CMD ["npm", "run", "dev"]
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import axios from 'axios'
|
|
||||||
import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
|
import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
|
|
||||||
interface Device {
|
interface Device {
|
||||||
@@ -13,8 +13,6 @@ interface Device {
|
|||||||
model?: string
|
model?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_URL = 'http://localhost:8000'
|
|
||||||
|
|
||||||
export default function DeviceDiscovery() {
|
export default function DeviceDiscovery() {
|
||||||
const [devices, setDevices] = useState<Device[]>([])
|
const [devices, setDevices] = useState<Device[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -22,7 +20,7 @@ export default function DeviceDiscovery() {
|
|||||||
|
|
||||||
const refreshDevices = async () => {
|
const refreshDevices = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${API_URL}/devices`)
|
const response = await api.get('/devices')
|
||||||
setDevices(response.data)
|
setDevices(response.data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Laden der Geräte:', error)
|
console.error('Fehler beim Laden der Geräte:', error)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import axios from 'axios'
|
import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react'
|
||||||
import { Terminal, Disc, Play, CheckCircle, AlertCircle, Clock, Download } from 'lucide-react'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
|
|
||||||
interface LiveLogEntry {
|
interface LiveLogEntry {
|
||||||
@@ -16,12 +16,10 @@ export default function LiveLogSection() {
|
|||||||
const [currentJob, setCurrentJob] = useState<any>(null)
|
const [currentJob, setCurrentJob] = useState<any>(null)
|
||||||
const { theme } = useDarkMode()
|
const { theme } = useDarkMode()
|
||||||
|
|
||||||
const API_URL = 'http://localhost:8000'
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchJobs = async () => {
|
const fetchJobs = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${API_URL}/jobs`)
|
const response = await api.get('/jobs')
|
||||||
const jobs = response.data
|
const jobs = response.data
|
||||||
const current = jobs.find((j: any) => j.status === 'processing')
|
const current = jobs.find((j: any) => j.status === 'processing')
|
||||||
setCurrentJob(current || jobs.find((j: any) => j.status === 'pending'))
|
setCurrentJob(current || jobs.find((j: any) => j.status === 'pending'))
|
||||||
@@ -30,15 +28,23 @@ export default function LiveLogSection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchJobs()
|
const fetchLogs = async () => {
|
||||||
const jobInterval = setInterval(fetchJobs, 5000)
|
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
|
fetchJobs()
|
||||||
// const logInterval = setInterval(fetchLogs, 1000)
|
fetchLogs()
|
||||||
|
const jobInterval = setInterval(fetchJobs, 5000)
|
||||||
|
const logInterval = setInterval(fetchLogs, 5000)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(jobInterval)
|
clearInterval(jobInterval)
|
||||||
// clearInterval(logInterval)
|
clearInterval(logInterval)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import axios from 'axios'
|
|
||||||
import { Clock, Activity, HardDrive, BarChart, AlertCircle, CheckCircle, Disc, Play, Pause, SkipForward } from 'lucide-react'
|
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 { useDarkMode } from '../context/ThemeContext'
|
||||||
import DeviceDiscovery from '../components/DeviceDiscovery'
|
import DeviceDiscovery from '../components/DeviceDiscovery'
|
||||||
import LiveLogSection from '../components/LiveLogSection'
|
import LiveLogSection from '../components/LiveLogSection'
|
||||||
@@ -24,11 +24,9 @@ interface Device {
|
|||||||
status: 'empty' | 'ready' | 'ripping'
|
status: 'empty' | 'ready' | 'ripping'
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_URL = 'http://localhost:8000'
|
|
||||||
|
|
||||||
async function fetchJobs(): Promise<Job[]> {
|
async function fetchJobs(): Promise<Job[]> {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${API_URL}/jobs`)
|
const response = await api.get('/jobs')
|
||||||
return response.data
|
return response.data
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
@@ -37,7 +35,7 @@ async function fetchJobs(): Promise<Job[]> {
|
|||||||
|
|
||||||
async function fetchDevices(): Promise<Device[]> {
|
async function fetchDevices(): Promise<Device[]> {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${API_URL}/devices`)
|
const response = await api.get('/devices')
|
||||||
return response.data
|
return response.data
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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'
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
|
|
||||||
interface LogEntry {
|
interface LogEntry {
|
||||||
@@ -18,25 +19,11 @@ export default function LogsPage() {
|
|||||||
const { theme } = useDarkMode()
|
const { theme } = useDarkMode()
|
||||||
|
|
||||||
const refreshLogs = async () => {
|
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 {
|
try {
|
||||||
// TODO: API endpoint für Logs implementieren
|
const response = await api.get('/logs', { params: { limit: 200 } })
|
||||||
// const response = await axios.get(`${API_URL}/logs`)
|
setLogs(response.data)
|
||||||
// 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)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Laden der Logs:', error)
|
console.error('Fehler beim Laden der Logs:', error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import axios from 'axios'
|
|
||||||
import { BookOpen, Music, Film, AlertCircle, CheckCircle, Search, Loader2 } from 'lucide-react'
|
import { BookOpen, Music, Film, AlertCircle, CheckCircle, Search, Loader2 } from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
|
|
||||||
const API_URL = 'http://localhost:8000'
|
interface DeviceOption {
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
interface Metadata {
|
interface Metadata {
|
||||||
type: 'movie' | 'tv' | 'music' | 'unknown'
|
type: 'movie' | 'tv' | 'music' | 'unknown'
|
||||||
@@ -35,12 +39,32 @@ interface PrescanResult {
|
|||||||
|
|
||||||
export default function MetadataPreview() {
|
export default function MetadataPreview() {
|
||||||
const [device, setDevice] = useState<string>('')
|
const [device, setDevice] = useState<string>('')
|
||||||
|
const [devices, setDevices] = useState<DeviceOption[]>([])
|
||||||
const [result, setResult] = useState<PrescanResult | null>(null)
|
const [result, setResult] = useState<PrescanResult | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
const { theme } = useDarkMode()
|
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 () => {
|
const handleScan = async () => {
|
||||||
if (!device) {
|
if (!device) {
|
||||||
setError('Bitte wählen Sie ein Gerät aus.')
|
setError('Bitte wählen Sie ein Gerät aus.')
|
||||||
@@ -51,7 +75,7 @@ export default function MetadataPreview() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`${API_URL}/prescan`, { device_path: device })
|
const response = await api.post('/prescan', { device_path: device })
|
||||||
setResult(response.data)
|
setResult(response.data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Pre-Scan fehlgeschlagen.')
|
setError('Pre-Scan fehlgeschlagen.')
|
||||||
@@ -66,16 +90,16 @@ export default function MetadataPreview() {
|
|||||||
setConfirming(true)
|
setConfirming(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(`${API_URL}/metadata/confirm`, {
|
await api.post('/metadata/confirm', {
|
||||||
title: result.title,
|
title: result.title,
|
||||||
year: result.year,
|
year: result.year,
|
||||||
metadata: result.metadata
|
metadata: result.metadata
|
||||||
})
|
})
|
||||||
|
|
||||||
// Job erstellen
|
// Job erstellen — POST /jobs existiert seit 23.07. wirklich
|
||||||
await axios.post(`${API_URL}/jobs`, {
|
await api.post('/jobs', {
|
||||||
device: device,
|
device_path: device,
|
||||||
type: result.disc_type
|
title: result.title
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Bestätigung fehlgeschlagen.')
|
setError('Bestätigung fehlgeschlagen.')
|
||||||
@@ -119,10 +143,14 @@ export default function MetadataPreview() {
|
|||||||
onChange={(e) => setDevice(e.target.value)}
|
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'}`}
|
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'}`}
|
||||||
>
|
>
|
||||||
<option value="" className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>Gerät auswählen...</option>
|
<option value="" className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>
|
||||||
<option value="/dev/cdrom" className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>CD-ROM (/dev/cdrom)</option>
|
{devices.length === 0 ? 'Kein Laufwerk gefunden' : 'Gerät auswählen...'}
|
||||||
<option value="/dev/dvd" className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>DVD (/dev/dvd)</option>
|
</option>
|
||||||
<option value="/dev/bluray" className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>Blu-ray (/dev/bluray)</option>
|
{devices.map((d) => (
|
||||||
|
<option key={d.path} value={d.path} className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>
|
||||||
|
{d.name} ({d.path}){d.status === 'empty' ? ' — keine Disc' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import axios from 'axios'
|
|
||||||
import { Settings, Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle } from 'lucide-react'
|
import { Settings, Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle } from 'lucide-react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
|
|
||||||
interface SettingsState {
|
interface SettingsState {
|
||||||
@@ -39,8 +39,9 @@ export default function SettingsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${import.meta.env.VITE_API_URL}/settings`)
|
const response = await api.get('/settings')
|
||||||
setSettings(response.data)
|
// Leeres Objekt von der API = noch nichts gespeichert → Defaults behalten
|
||||||
|
setSettings({ ...defaultSettings, ...response.data })
|
||||||
} catch {
|
} catch {
|
||||||
setSettings(defaultSettings)
|
setSettings(defaultSettings)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -58,7 +59,7 @@ export default function SettingsPage() {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await axios.post(`${import.meta.env.VITE_API_URL}/settings`, settings)
|
await api.post('/settings', settings)
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
setTimeout(() => setSaved(false), 3000)
|
setTimeout(() => setSaved(false), 3000)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user