Etappe 5: API + Auth + WebUI — Erste Implementierung

- React-UI: Dashboard mit Stats, Geräte, Jobs
- API: /jobs und /devices endpoints
- CORS aktiviert für UI-Kommunikation
This commit is contained in:
Hitonabi
2026-07-21 16:52:30 +02:00
parent e62ff83e17
commit 4d1f613a1c
10 changed files with 405 additions and 9 deletions
+67 -2
View File
@@ -1,6 +1,10 @@
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
import os
import subprocess
app = FastAPI(
title="Rippy API",
@@ -8,6 +12,30 @@ app = FastAPI(
version="1.0.0"
)
# CORS hinzufügen
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class Job(BaseModel):
id: str
type: str
status: str
device: str
startTime: str
endTime: Optional[str] = None
progress: int = 0
class Device(BaseModel):
id: str
name: str
type: str
path: str
status: str
@app.get("/health")
async def health_check():
@@ -21,3 +49,40 @@ async def root():
"version": "1.0.0",
"description": "Automatisches Ripping-System für CD, DVD und Blu-ray"
}
@app.get("/jobs", response_model=List[Job])
async def get_jobs():
"""Holt alle Jobs."""
return []
@app.get("/devices", response_model=List[Device])
async def get_devices():
"""Holt alle Geräte."""
devices = []
try:
result = subprocess.run(
["ls", "-la", "/dev/disc/"],
capture_output=True,
text=True,
timeout=5
)
for line in result.stdout.strip().split('\n')[1:]:
if line and 'total' not in line:
parts = line.split()
if len(parts) >= 9:
name = parts[-1]
devices.append(Device(
id=name,
name=f"Laufwerk {name}",
type="dvd",
path=f"/dev/disc/{name}",
status="ready"
))
except Exception:
pass
return devices
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rippy — Automatisches Ripping-System</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+14 -1
View File
@@ -8,8 +8,21 @@
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.3.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"vite": "^5.4.0"
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"vite": "^5.4.10"
},
"packageManager": "npm@10.8.0"
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+12
View File
@@ -0,0 +1,12 @@
import { useState } from 'react'
import Dashboard from './pages/Dashboard'
function App() {
return (
<div className="min-h-screen bg-gray-100">
<Dashboard />
</div>
)
}
export default App
+17
View File
@@ -0,0 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+248
View File
@@ -0,0 +1,248 @@
import { useEffect, useState } from 'react'
import axios from 'axios'
import { BarChart, Activity, HardDrive, Clock, AlertCircle, CheckCircle } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
interface Job {
id: string
type: 'cd' | 'dvd' | 'bluray'
status: 'pending' | 'processing' | 'completed' | 'failed'
device: string
startTime: string
endTime?: string
progress: number
}
interface Device {
id: string
name: string
type: 'cd' | 'dvd' | 'bluray'
path: string
status: 'empty' | 'ready' | 'ripping'
}
const API_URL = 'http://localhost:8000'
async function fetchJobs(): Promise<Job[]> {
try {
const response = await axios.get(`${API_URL}/jobs`)
return response.data
} catch {
return []
}
}
async function fetchDevices(): Promise<Device[]> {
try {
const response = await axios.get(`${API_URL}/devices`)
return response.data
} catch {
return []
}
}
function StatusBadge({ status }: { status: string }) {
const styles = {
pending: 'bg-yellow-100 text-yellow-800',
processing: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
}
const labels = {
pending: 'In Warteschlange',
processing: 'In Bearbeitung',
completed: 'Abgeschlossen',
failed: 'Fehlgeschlagen',
}
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${styles[status as keyof typeof styles] || 'bg-gray-100 text-gray-800'}`}>
{labels[status as keyof typeof labels] || status}
</span>
)
}
function ProgressBar({ progress }: { progress: number }) {
return (
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
)
}
export default function Dashboard() {
const [jobs, setJobs] = useState<Job[]>([])
const [devices, setDevices] = useState<Device[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const loadData = async () => {
try {
const [jobsData, devicesData] = await Promise.all([
fetchJobs(),
fetchDevices()
])
setJobs(jobsData)
setDevices(devicesData)
} finally {
setLoading(false)
}
}
loadData()
const interval = setInterval(loadData, 5000)
return () => clearInterval(interval)
}, [])
const stats = {
pending: jobs.filter(j => j.status === 'pending').length,
processing: jobs.filter(j => j.status === 'processing').length,
completed: jobs.filter(j => j.status === 'completed').length,
failed: jobs.filter(j => j.status === 'failed').length,
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
)
}
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600 mt-1">Übersicht über alle Ripping-Jobs und Geräte</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<StatCard
icon={Clock}
label="Warteschlange"
value={stats.pending.toString()}
color="yellow"
/>
<StatCard
icon={Activity}
label="In Bearbeitung"
value={stats.processing.toString()}
color="blue"
/>
<StatCard
icon={CheckCircle}
label="Abgeschlossen"
value={stats.completed.toString()}
color="green"
/>
<StatCard
icon={AlertCircle}
label="Fehlgeschlagen"
value={stats.failed.toString()}
color="red"
/>
</div>
{/* Devices */}
<div className="mb-8">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Geräte</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{devices.length === 0 ? (
<div className="text-gray-500">Keine Geräte gefunden</div>
) : (
devices.map(device => (
<div key={device.id} className="bg-white p-4 rounded-lg shadow">
<div className="flex items-center justify-between mb-2">
<h3 className="font-medium">{device.name}</h3>
<span className="text-sm text-gray-500 capitalize">{device.type}</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<HardDrive size={16} />
<span>{device.path}</span>
</div>
<div className="mt-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${
device.status === 'empty' ? 'bg-gray-100 text-gray-600' :
device.status === 'ready' ? 'bg-green-100 text-green-700' :
'bg-blue-100 text-blue-700'
}`}>
{device.status === 'empty' ? 'Leer' :
device.status === 'ready' ? 'Bereit' :
'Rippt'}
</span>
</div>
</div>
))
)}
</div>
</div>
{/* Recent Jobs */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Neueste Jobs</h2>
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Typ</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Gerät</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Fortschritt</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Startzeit</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{jobs.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-4 text-center text-gray-500">
Keine Jobs vorhanden
</td>
</tr>
) : (
jobs.slice(0, 10).map(job => (
<tr key={job.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium capitalize">{job.type}</td>
<td className="px-6 py-4 whitespace-nowrap"><StatusBadge status={job.status} /></td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{job.device}</td>
<td className="px-6 py-4 whitespace-nowrap">
<ProgressBar progress={job.progress} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(job.startTime).toLocaleString('de-DE')}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
function StatCard({ icon: Icon, label, value, color }: { icon: LucideIcon, label: string, value: string, color: string }) {
const colors = {
yellow: 'bg-yellow-50 text-yellow-600',
blue: 'bg-blue-50 text-blue-600',
green: 'bg-green-50 text-green-600',
red: 'bg-red-50 text-red-600',
}
return (
<div className={`p-4 rounded-lg ${colors[color as keyof typeof colors]}`}>
<div className="flex items-center gap-3">
<Icon size={24} />
<div>
<p className="text-sm font-medium opacity-90">{label}</p>
<p className="text-2xl font-bold">{value}</p>
</div>
</div>
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
+7 -6
View File
@@ -1,13 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
root: './',
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173
},
build: {
outDir: 'dist',
assetsDir: 'assets'
},
server: {
port: 80,
host: true
sourcemap: false
}
})