feat: add remote worker system for distributed translation
- Add remote translator worker (Docker) that connects via WebSocket - Add backend Go handlers for remote worker CRUD (REST + WebSocket) - Add frontend Admin panel for managing remote workers - Add ProtectedRoute component to secure admin routes - Add PostgreSQL schema for remote_workers table - Fix duplicate Load function in config.go - Fix frontend duplicate fetchStatus function - Fix Dockerfiles missing go.sum (use go mod download + tidy) Features: - Workers connect to server via WebSocket (no ports needed) - API key authentication per worker - Job assigner automatically distributes translation jobs - Worker status tracking (online/offline/disabled) - Timeout handling for stuck jobs
This commit is contained in:
parent
b8e48f3199
commit
8093cc1e40
21 changed files with 2065 additions and 58 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import { Layout } from './components/layout/Layout'
|
||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||
import { Home } from './pages/Home'
|
||||
import { News } from './pages/News'
|
||||
import { Feeds } from './pages/Feeds'
|
||||
|
|
@ -64,13 +65,25 @@ function App() {
|
|||
<Route path="favorites" element={<Favorites />} />
|
||||
<Route path="account" element={<Account />} />
|
||||
<Route path="login" element={<Login />} />
|
||||
<Route path="admin/aliases" element={<AdminAliases />} />
|
||||
<Route path="admin/users" element={<AdminUsers />} />
|
||||
<Route path="admin/settings" element={<AdminSettings />} />
|
||||
<Route path="admin/workers" element={<AdminWorkers />} />
|
||||
<Route
|
||||
path="admin/aliases"
|
||||
element={<ProtectedRoute requireAdmin>{<AdminAliases />}</ProtectedRoute>}
|
||||
/>
|
||||
<Route
|
||||
path="admin/users"
|
||||
element={<ProtectedRoute requireAdmin>{<AdminUsers />}</ProtectedRoute>}
|
||||
/>
|
||||
<Route
|
||||
path="admin/settings"
|
||||
element={<ProtectedRoute requireAdmin>{<AdminSettings />}</ProtectedRoute>}
|
||||
/>
|
||||
<Route
|
||||
path="admin/workers"
|
||||
element={<ProtectedRoute requireAdmin>{<AdminWorkers />}</ProtectedRoute>}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App
|
||||
47
frontend/src/components/ProtectedRoute.tsx
Normal file
47
frontend/src/components/ProtectedRoute.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { Navigate } from 'react-router-dom'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode
|
||||
requireAdmin?: boolean
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children, requireAdmin = false }: ProtectedRouteProps) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null)
|
||||
const [isAdmin, setIsAdmin] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
const userData = localStorage.getItem('user')
|
||||
|
||||
if (token && userData) {
|
||||
try {
|
||||
const user = JSON.parse(userData)
|
||||
setIsAuthenticated(true)
|
||||
setIsAdmin(user.is_admin === true)
|
||||
} catch {
|
||||
setIsAuthenticated(false)
|
||||
}
|
||||
} else {
|
||||
setIsAuthenticated(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (isAuthenticated === null) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
if (requireAdmin && !isAdmin) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../services/api'
|
||||
import { Cpu, Play, Square, Settings, RefreshCw, Loader2, Server } from 'lucide-react'
|
||||
import { Cpu, Play, Square, Settings, RefreshCw, Loader2, Server, Wifi, Plus, Trash2, Key } from 'lucide-react'
|
||||
|
||||
interface WorkerStatus {
|
||||
type: string
|
||||
|
|
@ -9,6 +9,16 @@ interface WorkerStatus {
|
|||
running: number
|
||||
}
|
||||
|
||||
interface RemoteWorker {
|
||||
id: number
|
||||
name: string
|
||||
api_key?: string
|
||||
capabilities: string
|
||||
status: string
|
||||
last_seen?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function AdminWorkers() {
|
||||
const [status, setStatus] = useState<WorkerStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -16,24 +26,50 @@ export function AdminWorkers() {
|
|||
const [configType, setConfigType] = useState<'cpu' | 'gpu'>('cpu')
|
||||
const [configWorkers, setConfigWorkers] = useState(2)
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
}, [])
|
||||
const [remoteWorkers, setRemoteWorkers] = useState<RemoteWorker[]>([])
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newWorkerName, setNewWorkerName] = useState('')
|
||||
const [newWorkerCapabilities, setNewWorkerCapabilities] = useState('cpu')
|
||||
const [newWorkerKey, setNewWorkerKey] = useState<string | null>(null)
|
||||
|
||||
const fetchStatus = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.get('/admin/workers/status')
|
||||
console.log('Status response:', res.data)
|
||||
setStatus(res.data)
|
||||
setConfigType(res.data.type)
|
||||
setConfigWorkers(res.data.workers)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching status:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRemoteWorkers = async () => {
|
||||
try {
|
||||
const res = await api.get('/admin/workers/remote')
|
||||
console.log('Remote workers response:', res.data)
|
||||
setRemoteWorkers(res.data)
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching remote workers:', err)
|
||||
// If 401, user is not authenticated - try refreshing
|
||||
if (err.response?.status === 401) {
|
||||
const userData = localStorage.getItem('user')
|
||||
if (userData) {
|
||||
const user = JSON.parse(userData)
|
||||
console.log('Current user from localStorage:', user)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
fetchRemoteWorkers()
|
||||
}, [])
|
||||
|
||||
const handleStart = async () => {
|
||||
setActionLoading('start')
|
||||
try {
|
||||
|
|
@ -76,6 +112,53 @@ export function AdminWorkers() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleAddRemoteWorker = async () => {
|
||||
if (!newWorkerName.trim()) return
|
||||
setActionLoading('add')
|
||||
try {
|
||||
const res = await api.post('/admin/workers/remote', {
|
||||
name: newWorkerName,
|
||||
capabilities: newWorkerCapabilities
|
||||
})
|
||||
setNewWorkerKey(res.data.api_key)
|
||||
setNewWorkerName('')
|
||||
fetchRemoteWorkers()
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error || 'Error al crear worker')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteRemoteWorker = async (id: number) => {
|
||||
if (!confirm('¿Eliminar este worker remoto?')) return
|
||||
try {
|
||||
await api.delete(`/admin/workers/remote/${id}`)
|
||||
fetchRemoteWorkers()
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error || 'Error al eliminar worker')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleRemoteWorker = async (id: number) => {
|
||||
try {
|
||||
await api.post(`/admin/workers/remote/${id}/toggle`)
|
||||
fetchRemoteWorkers()
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error || 'Error al cambiar estado')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegenerateKey = async (id: number) => {
|
||||
if (!confirm('¿Regenerar API key? La anterior deixará de funcionar.')) return
|
||||
try {
|
||||
const res = await api.post(`/admin/workers/remote/${id}/regenerate-key`)
|
||||
setNewWorkerKey(res.data.api_key)
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error || 'Error al regenerar key')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
|
|
@ -256,6 +339,123 @@ export function AdminWorkers() {
|
|||
<li>• Recomendado: 2-4 workers para CPU, 1-2 para GPU</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Wifi className="h-5 w-5" />
|
||||
Workers Remotos
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Añadir Worker
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<h3 className="font-medium mb-3">Nuevo Worker Remoto</h3>
|
||||
<div className="flex gap-3 mb-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nombre del worker"
|
||||
value={newWorkerName}
|
||||
onChange={(e) => setNewWorkerName(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border rounded-lg dark:bg-gray-600 dark:border-gray-600"
|
||||
/>
|
||||
<select
|
||||
value={newWorkerCapabilities}
|
||||
onChange={(e) => setNewWorkerCapabilities(e.target.value)}
|
||||
className="px-3 py-2 border rounded-lg dark:bg-gray-600 dark:border-gray-600"
|
||||
>
|
||||
<option value="cpu">CPU</option>
|
||||
<option value="gpu">GPU</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleAddRemoteWorker}
|
||||
disabled={actionLoading === 'add'}
|
||||
className="btn-primary"
|
||||
>
|
||||
{actionLoading === 'add' ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Crear'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowAddForm(false); setNewWorkerKey(null) }}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newWorkerKey && (
|
||||
<div className="mb-4 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Key className="h-4 w-4 text-green-600" />
|
||||
<span className="font-medium text-green-800 dark:text-green-200">API Key creada</span>
|
||||
</div>
|
||||
<code className="block p-2 bg-white dark:bg-gray-800 rounded text-sm break-all font-mono">
|
||||
{newWorkerKey}
|
||||
</code>
|
||||
<p className="text-sm text-green-700 dark:text-green-300 mt-2">
|
||||
Copia esta clave. No se mostrará de nuevo.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setNewWorkerKey(null)}
|
||||
className="mt-2 text-sm text-green-700 dark:text-green-300 underline"
|
||||
>
|
||||
Ocultar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remoteWorkers.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-4">No hay workers remotos configurados</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{remoteWorkers.map((worker) => (
|
||||
<div key={worker.id} className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full ${
|
||||
worker.status === 'online' ? 'bg-green-500' : 'bg-gray-400'
|
||||
}`} />
|
||||
<div>
|
||||
<span className="font-medium">{worker.name}</span>
|
||||
<span className="ml-2 text-sm text-gray-500">{worker.capabilities}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleToggleRemoteWorker(worker.id)}
|
||||
className="px-2 py-1 text-xs rounded border hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
>
|
||||
{worker.status === 'disabled' ? 'Activar' : 'Desactivar'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRegenerateKey(worker.id)}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
title="Regenerar API Key"
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteRemoteWorker(worker.id)}
|
||||
className="p-2 text-red-500 hover:text-red-700"
|
||||
title="Eliminar"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue