feat: Initial commit for OnEver Drive centralized backup system with Windows PyQt6 agent and Proxmox LXC deployment
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<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>OnEver Drive — Centralized Enterprise Backup & Sync</title>
|
||||
<meta name="description" content="Plataforma centralizada de backup y sincronización para Windows sobre Proxmox VE con transferencia por bloques, reanudación e integridad SHA-256." />
|
||||
<!-- Google Fonts: Outfit & JetBrains Mono -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1869
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "onever-drive-dashboard",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { Topbar } from './components/Topbar';
|
||||
import { DashboardView } from './pages/DashboardView';
|
||||
import { ClientsView } from './pages/ClientsView';
|
||||
import { JobsView } from './pages/JobsView';
|
||||
import { RestoreView } from './pages/RestoreView';
|
||||
import { EventsView } from './pages/EventsView';
|
||||
import { LoginView } from './pages/LoginView';
|
||||
import { ActiveUpload } from './components/LiveTransferMeter';
|
||||
import {
|
||||
api,
|
||||
DashboardStats,
|
||||
ClientItem,
|
||||
BackupJobItem,
|
||||
EventLogItem,
|
||||
getAuthToken,
|
||||
getCurrentUser,
|
||||
setAuthToken
|
||||
} from './services/api';
|
||||
import { wsClient } from './services/websocket';
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const [currentUser, setCurrentUser] = useState<any | null>(getCurrentUser());
|
||||
const [authToken, setTokenState] = useState<string | null>(getAuthToken());
|
||||
|
||||
const [currentTab, setCurrentTab] = useState<string>('dashboard');
|
||||
const [isWsConnected, setIsWsConnected] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [clients, setClients] = useState<ClientItem[]>([]);
|
||||
const [jobs, setJobs] = useState<BackupJobItem[]>([]);
|
||||
const [events, setEvents] = useState<EventLogItem[]>([]);
|
||||
const [activeUploads, setActiveUploads] = useState<ActiveUpload[]>([]);
|
||||
|
||||
const handleLoginSuccess = (user: any) => {
|
||||
setCurrentUser(user);
|
||||
setTokenState(getAuthToken());
|
||||
loadData();
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setAuthToken(null);
|
||||
setCurrentUser(null);
|
||||
setTokenState(null);
|
||||
setStats(null);
|
||||
setClients([]);
|
||||
setJobs([]);
|
||||
setEvents([]);
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
if (!getAuthToken()) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [statsRes, clientsRes, jobsRes, eventsRes] = await Promise.all([
|
||||
api.getStats().catch(() => null),
|
||||
api.getClients().catch(() => []),
|
||||
api.getJobs().catch(() => []),
|
||||
api.getEvents(50).catch(() => []),
|
||||
]);
|
||||
|
||||
if (statsRes) setStats(statsRes);
|
||||
setClients(clientsRes);
|
||||
setJobs(jobsRes);
|
||||
setEvents(eventsRes);
|
||||
} catch (err) {
|
||||
console.error('Error loading dashboard data:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleUnauthorized = () => {
|
||||
handleLogout();
|
||||
};
|
||||
|
||||
window.addEventListener('oed_unauthorized', handleUnauthorized);
|
||||
|
||||
if (authToken) {
|
||||
loadData();
|
||||
wsClient.connect();
|
||||
|
||||
const unsubscribe = wsClient.subscribe((evt) => {
|
||||
if (evt.type === 'WS_CONNECTED') {
|
||||
setIsWsConnected(true);
|
||||
} else if (evt.type === 'WS_DISCONNECTED') {
|
||||
setIsWsConnected(false);
|
||||
} else if (evt.type === 'UPLOAD_PROGRESS') {
|
||||
const upload = evt.data as ActiveUpload;
|
||||
setActiveUploads((prev) => {
|
||||
const index = prev.findIndex((u) => u.session_code === upload.session_code);
|
||||
if (index >= 0) {
|
||||
const next = [...prev];
|
||||
next[index] = { ...next[index], ...upload };
|
||||
return next;
|
||||
}
|
||||
return [upload, ...prev];
|
||||
});
|
||||
} else if (evt.type === 'UPLOAD_COMPLETED') {
|
||||
const sessionCode = evt.data.session_code;
|
||||
setTimeout(() => {
|
||||
setActiveUploads((prev) => prev.filter((u) => u.session_code !== sessionCode));
|
||||
loadData();
|
||||
}, 2000);
|
||||
} else if (evt.type === 'EVENT_LOG') {
|
||||
setEvents((prev) => [evt.data, ...prev.slice(0, 49)]);
|
||||
} else if (evt.type === 'CLIENT_REGISTERED' || evt.type === 'CLIENT_HEARTBEAT') {
|
||||
api.getClients().then(setClients).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (getAuthToken()) {
|
||||
api.getStats().then((s) => s && setStats(s)).catch(() => {});
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
clearInterval(interval);
|
||||
window.removeEventListener('oed_unauthorized', handleUnauthorized);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('oed_unauthorized', handleUnauthorized);
|
||||
};
|
||||
}, [authToken]);
|
||||
|
||||
// If not authenticated, render Login Screen
|
||||
if (!authToken || !currentUser) {
|
||||
return <LoginView onLoginSuccess={handleLoginSuccess} />;
|
||||
}
|
||||
|
||||
const getTabTitle = () => {
|
||||
switch (currentTab) {
|
||||
case 'dashboard':
|
||||
return 'Panel Principal & Telemetría';
|
||||
case 'clients':
|
||||
return 'Clientes Windows';
|
||||
case 'jobs':
|
||||
return 'Trabajos de Backup';
|
||||
case 'restore':
|
||||
return 'Explorador & Restore';
|
||||
case 'events':
|
||||
return 'Auditoría & Logs';
|
||||
default:
|
||||
return 'OnEver Drive';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<Sidebar
|
||||
currentTab={currentTab}
|
||||
setCurrentTab={setCurrentTab}
|
||||
isWsConnected={isWsConnected}
|
||||
/>
|
||||
|
||||
<div className="main-wrapper">
|
||||
<Topbar
|
||||
title={getTabTitle()}
|
||||
user={currentUser}
|
||||
onRefresh={loadData}
|
||||
onLogout={handleLogout}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<main className="content-scrollable">
|
||||
{currentTab === 'dashboard' && (
|
||||
<DashboardView
|
||||
stats={stats}
|
||||
events={events}
|
||||
activeUploads={activeUploads}
|
||||
onNavigateToClients={() => setCurrentTab('clients')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentTab === 'clients' && (
|
||||
<ClientsView
|
||||
clients={clients}
|
||||
onRefresh={loadData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentTab === 'jobs' && (
|
||||
<JobsView
|
||||
jobs={jobs}
|
||||
clients={clients}
|
||||
onRefresh={loadData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentTab === 'restore' && (
|
||||
<RestoreView
|
||||
clients={clients}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentTab === 'events' && (
|
||||
<EventsView
|
||||
events={events}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { UploadCloud, CheckCircle2, AlertTriangle } from 'lucide-react';
|
||||
|
||||
export interface ActiveUpload {
|
||||
session_code: string;
|
||||
filename: string;
|
||||
client_id: number;
|
||||
chunk_index: number;
|
||||
received_chunks: number;
|
||||
total_chunks: number;
|
||||
progress_percent: number;
|
||||
status?: string;
|
||||
speed_mb?: number;
|
||||
}
|
||||
|
||||
interface LiveTransferMeterProps {
|
||||
uploads: ActiveUpload[];
|
||||
}
|
||||
|
||||
export const LiveTransferMeter: React.FC<LiveTransferMeterProps> = ({ uploads }) => {
|
||||
if (uploads.length === 0) {
|
||||
return (
|
||||
<div className="glass-card" style={{ marginBottom: '28px', textAlign: 'center', padding: '32px 20px' }}>
|
||||
<UploadCloud size={32} color="var(--text-dim)" style={{ margin: '0 auto 10px auto' }} />
|
||||
<h4 style={{ fontSize: '0.95rem', fontWeight: 600, color: 'var(--text-muted)' }}>Sin transferencias activas</h4>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-dim)', marginTop: '4px' }}>
|
||||
Los agentes Windows transmitirán bloques de archivos automáticamente según sus trabajos programados.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-card" style={{ marginBottom: '28px', border: '1px solid rgba(6, 182, 212, 0.3)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div className="pulse-dot" />
|
||||
<h3 style={{ fontSize: '1rem', fontWeight: 700 }}>Transferencias en Vivo ({uploads.length})</h3>
|
||||
</div>
|
||||
<span className="badge badge-syncing">Motor de Chunks Activo</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{uploads.map((upload) => {
|
||||
const isComplete = upload.progress_percent >= 100;
|
||||
return (
|
||||
<div
|
||||
key={upload.session_code}
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.3)',
|
||||
padding: '16px',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--border-color)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<div>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.92rem', color: '#fff' }}>
|
||||
{upload.filename}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginLeft: '12px' }}>
|
||||
Cliente #{upload.client_id}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.85rem', color: 'var(--accent-cyan)', fontWeight: 600 }}>
|
||||
{upload.received_chunks} / {upload.total_chunks} Chunks ({upload.progress_percent}%)
|
||||
</span>
|
||||
{isComplete && <CheckCircle2 size={16} color="var(--accent-emerald)" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="progress-track" style={{ height: '10px' }}>
|
||||
<div
|
||||
className={`progress-fill ${isComplete ? '' : 'animated'}`}
|
||||
style={{ width: `${Math.min(100, upload.progress_percent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.74rem', color: 'var(--text-dim)', marginTop: '6px' }}>
|
||||
<span>Sesión: {upload.session_code.substring(0, 18)}...</span>
|
||||
<span>{isComplete ? 'Ensamblando & Verificando SHA-256...' : 'Transmitiendo bloques de 4 MB'}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
HardDrive,
|
||||
Layers,
|
||||
RotateCcw,
|
||||
FileText,
|
||||
ShieldCheck,
|
||||
Server
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SidebarProps {
|
||||
currentTab: string;
|
||||
setCurrentTab: (tab: string) => void;
|
||||
isWsConnected: boolean;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({ currentTab, setCurrentTab, isWsConnected }) => {
|
||||
const menuItems = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ id: 'clients', label: 'Clientes Windows', icon: HardDrive },
|
||||
{ id: 'jobs', label: 'Trabajos de Backup', icon: Layers },
|
||||
{ id: 'restore', label: 'Explorador & Restore', icon: RotateCcw },
|
||||
{ id: 'events', label: 'Auditoría & Logs', icon: FileText },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="brand-icon">
|
||||
<ShieldCheck size={24} />
|
||||
</div>
|
||||
<div className="brand-text">
|
||||
<h1>OnEver Drive</h1>
|
||||
<span>Proxmox Edition</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="nav-links">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = currentTab === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`nav-btn ${isActive ? 'active' : ''}`}
|
||||
onClick={() => setCurrentTab(item.id)}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div className="ws-status-badge">
|
||||
<div className={`pulse-dot ${isWsConnected ? '' : 'offline'}`} />
|
||||
<span>{isWsConnected ? 'Telemetría en Vivo' : 'Reconectando WS...'}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-dim)', textAlign: 'center', marginTop: '4px' }}>
|
||||
v1.0.0 • Proxmox LXC
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { RefreshCw, Server, User as UserIcon, LogOut } from 'lucide-react';
|
||||
|
||||
interface TopbarProps {
|
||||
title: string;
|
||||
user: any | null;
|
||||
onRefresh: () => void;
|
||||
onLogout: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const Topbar: React.FC<TopbarProps> = ({ title, user, onRefresh, onLogout, isLoading }) => {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar-title">
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
|
||||
<div className="topbar-actions">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.84rem', color: 'var(--text-muted)' }}>
|
||||
<Server size={16} color="var(--accent-cyan)" />
|
||||
<span>LXC Proxmox Node</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
style={{ padding: '8px 14px' }}
|
||||
title="Actualizar datos"
|
||||
>
|
||||
<RefreshCw size={15} className={isLoading ? 'spin-anim' : ''} />
|
||||
<span>Refrescar</span>
|
||||
</button>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 12px', background: 'rgba(255,255,255,0.05)', borderRadius: 'var(--radius-sm)' }}>
|
||||
<UserIcon size={16} color="var(--accent-indigo)" />
|
||||
<span style={{ fontSize: '0.84rem', fontWeight: 600 }}>
|
||||
{user?.email || 'admin@oneverdrive.local'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={onLogout}
|
||||
style={{ padding: '8px 12px', fontSize: '0.82rem' }}
|
||||
title="Cerrar sesión"
|
||||
>
|
||||
<LogOut size={15} />
|
||||
<span>Salir</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,569 @@
|
||||
:root {
|
||||
--bg-main: #090d16;
|
||||
--bg-card: rgba(17, 24, 39, 0.75);
|
||||
--bg-card-hover: rgba(31, 41, 55, 0.85);
|
||||
--bg-subtle: rgba(255, 255, 255, 0.03);
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--border-focus: rgba(6, 182, 212, 0.5);
|
||||
|
||||
--text-main: #f3f4f6;
|
||||
--text-muted: #9ca3af;
|
||||
--text-dim: #6b7280;
|
||||
|
||||
--accent-cyan: #06b6d4;
|
||||
--accent-cyan-glow: rgba(6, 182, 212, 0.25);
|
||||
--accent-indigo: #6366f1;
|
||||
--accent-emerald: #10b981;
|
||||
--accent-emerald-glow: rgba(16, 185, 129, 0.25);
|
||||
--accent-amber: #f59e0b;
|
||||
--accent-rose: #f43f5e;
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 24px;
|
||||
|
||||
--shadow-card: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||
--shadow-glow: 0 0 20px rgba(6, 182, 212, 0.15);
|
||||
|
||||
--font-main: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-main);
|
||||
background-color: var(--bg-main);
|
||||
color: var(--text-main);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
background-image:
|
||||
radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.12) 0px, transparent 50%),
|
||||
radial-gradient(at 100% 0%, rgba(6, 182, 212, 0.10) 0px, transparent 50%),
|
||||
radial-gradient(at 50% 100%, rgba(16, 185, 129, 0.08) 0px, transparent 50%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* App Container Layout */
|
||||
.app-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar Navigation */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background: rgba(13, 19, 33, 0.85);
|
||||
backdrop-filter: blur(16px);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px 16px;
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 8px 24px 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-indigo));
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 16px var(--accent-cyan-glow);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.brand-text h1 {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(to right, #fff, #93c5fd);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.brand-text span {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent-cyan);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-main);
|
||||
border-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.nav-btn.active {
|
||||
background: linear-gradient(90deg, rgba(6, 182, 212, 0.15), rgba(99, 102, 241, 0.05));
|
||||
border-color: rgba(6, 182, 212, 0.3);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.nav-btn.active svg {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ws-status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.pulse-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-emerald);
|
||||
box-shadow: 0 0 8px var(--accent-emerald);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.pulse-dot.offline {
|
||||
background: var(--accent-rose);
|
||||
box-shadow: 0 0 8px var(--accent-rose);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(0.95); opacity: 0.7; }
|
||||
50% { transform: scale(1.15); opacity: 1; }
|
||||
100% { transform: scale(0.95); opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* Main Content Area */
|
||||
.main-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 70px;
|
||||
padding: 0 32px;
|
||||
background: rgba(13, 19, 33, 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar-title h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.content-scrollable {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
/* Glassmorphism Card System */
|
||||
.glass-card {
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow-card);
|
||||
transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.stat-card.emerald::after { background: var(--accent-emerald); }
|
||||
.stat-card.indigo::after { background: var(--accent-indigo); }
|
||||
.stat-card.amber::after { background: var(--accent-amber); }
|
||||
|
||||
.stat-info h3 {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 1.85rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
color: #fff;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Progress Bar / Storage Meter */
|
||||
.storage-meter-container {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 8px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-cyan), var(--accent-indigo));
|
||||
border-radius: 999px;
|
||||
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 0 12px var(--accent-cyan-glow);
|
||||
}
|
||||
|
||||
.progress-fill.animated {
|
||||
background: linear-gradient(90deg, #06b6d4, #6366f1, #06b6d4);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-indigo));
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 14px var(--accent-cyan-glow);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px var(--accent-cyan-glow);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-subtle);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(244, 63, 94, 0.15);
|
||||
border-color: rgba(244, 63, 94, 0.3);
|
||||
color: #fda4af;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: rgba(244, 63, 94, 0.25);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.modern-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.modern-table th {
|
||||
padding: 14px 18px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modern-table td {
|
||||
padding: 16px 18px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-main);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.modern-table tr:hover td {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.badge-online {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #34d399;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.badge-offline {
|
||||
background: rgba(107, 114, 128, 0.2);
|
||||
color: #9ca3af;
|
||||
border: 1px solid rgba(107, 114, 128, 0.3);
|
||||
}
|
||||
|
||||
.badge-syncing {
|
||||
background: rgba(6, 182, 212, 0.15);
|
||||
color: #38bdf8;
|
||||
border: 1px solid rgba(6, 182, 212, 0.3);
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: rgba(244, 63, 94, 0.15);
|
||||
color: #f87171;
|
||||
border: 1px solid rgba(244, 63, 94, 0.3);
|
||||
}
|
||||
|
||||
.hash-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.76rem;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #67e8f9;
|
||||
}
|
||||
|
||||
/* Modal Dialogs */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
width: 100%;
|
||||
max-width: 580px;
|
||||
background: #111827;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 28px;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
|
||||
animation: modalIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(10px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 0.92rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 0 3px var(--accent-cyan-glow);
|
||||
}
|
||||
|
||||
.code-box {
|
||||
background: #060911;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
color: #38bdf8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -0,0 +1,288 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
HardDrive,
|
||||
Copy,
|
||||
Check,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
Laptop,
|
||||
Server as ServerIcon,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { ClientItem, api } from '../services/api';
|
||||
|
||||
interface ClientsViewProps {
|
||||
clients: ClientItem[];
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh }) => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [clientHint, setClientHint] = useState('');
|
||||
const [generatedCode, setGeneratedCode] = useState<{ code: string; expires_at: string } | null>(null);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const handleGenerateCode = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.createRegistrationCode(clientHint);
|
||||
setGeneratedCode(res);
|
||||
} catch (err: any) {
|
||||
alert(`Error generating registration code: ${err.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (client: ClientItem) => {
|
||||
if (confirm(`¿Estás seguro de revocar las credenciales para el cliente ${client.name} (${client.client_code})?`)) {
|
||||
try {
|
||||
await api.revokeClient(client.id);
|
||||
onRefresh();
|
||||
} catch (err: any) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (client: ClientItem) => {
|
||||
if (confirm(`¿Eliminar definitivamente el cliente ${client.name} y todos sus registros?`)) {
|
||||
try {
|
||||
await api.deleteClient(client.id);
|
||||
onRefresh();
|
||||
} catch (err: any) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const serverUrl = window.location.origin;
|
||||
const psCommand = generatedCode
|
||||
? `python agent_cli.py register --server "${serverUrl}" --code "${generatedCode.code}"`
|
||||
: '';
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '1.2rem', fontWeight: 700 }}>Clientes Windows Registrados</h3>
|
||||
<p style={{ fontSize: '0.84rem', color: 'var(--text-muted)', marginTop: '2px' }}>
|
||||
Administración centralizada de agentes Windows 10, 11 y Windows Server
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={() => { setShowModal(true); setGeneratedCode(null); }}>
|
||||
<Plus size={16} />
|
||||
Registrar Nuevo Cliente
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="glass-card">
|
||||
<div className="table-container">
|
||||
<table className="modern-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cliente ID</th>
|
||||
<th>Nombre / Hostname</th>
|
||||
<th>Sistema Operativo</th>
|
||||
<th>Dirección IP</th>
|
||||
<th>Estado</th>
|
||||
<th>Espacio Utilizado</th>
|
||||
<th>Última Conexión</th>
|
||||
<th style={{ textAlign: 'right' }}>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clients.map((client) => {
|
||||
const isOnline = client.status === 'ONLINE';
|
||||
return (
|
||||
<tr key={client.id}>
|
||||
<td>
|
||||
<span className="hash-badge" style={{ color: 'var(--accent-cyan)' }}>
|
||||
{client.client_code}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{client.os_info?.includes('Server') ? (
|
||||
<ServerIcon size={16} color="var(--accent-indigo)" />
|
||||
) : (
|
||||
<Laptop size={16} color="var(--text-muted)" />
|
||||
)}
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{client.name}</div>
|
||||
<div style={{ fontSize: '0.74rem', color: 'var(--text-dim)' }}>
|
||||
{client.hostname || 'Desconocido'} • v{client.agent_version}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ fontSize: '0.84rem', color: 'var(--text-muted)' }}>
|
||||
{client.os_info || 'Windows'}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.84rem', fontFamily: 'var(--font-mono)' }}>
|
||||
{client.ip_address || '—'}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${isOnline ? 'badge-online' : 'badge-offline'}`}>
|
||||
{client.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.84rem' }}>
|
||||
{formatBytes(client.storage_used_bytes)}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.82rem', color: 'var(--text-dim)' }}>
|
||||
{client.last_seen_at
|
||||
? new Date(client.last_seen_at).toLocaleString()
|
||||
: 'Nunca'}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '6px 10px', fontSize: '0.78rem' }}
|
||||
onClick={() => handleRevoke(client)}
|
||||
title="Revocar credenciales"
|
||||
>
|
||||
<ShieldAlert size={14} color="var(--accent-amber)" />
|
||||
Revocar
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
style={{ padding: '6px 10px', fontSize: '0.78rem' }}
|
||||
onClick={() => handleDelete(client)}
|
||||
title="Eliminar cliente"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{clients.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-dim)' }}>
|
||||
No hay clientes Windows registrados. Haz clic en "Registrar Nuevo Cliente" para comenzar.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registration Modal */}
|
||||
{showModal && (
|
||||
<div className="modal-backdrop">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h3 style={{ fontSize: '1.1rem', fontWeight: 700 }}>Registrar Agente Windows</h3>
|
||||
<button
|
||||
style={{ background: 'transparent', border: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}
|
||||
onClick={() => setShowModal(false)}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!generatedCode ? (
|
||||
<form onSubmit={handleGenerateCode}>
|
||||
<div className="form-group">
|
||||
<label>Nombre identificador del equipo (Opcional):</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Ej: Servidor SQL Producción"
|
||||
value={clientHint}
|
||||
onChange={(e) => setClientHint(e.target.value)}
|
||||
/>
|
||||
<p style={{ fontSize: '0.78rem', color: 'var(--text-dim)', marginTop: '4px' }}>
|
||||
Se generará un código de un solo uso válido por 48 horas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', marginTop: '24px' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowModal(false)}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||
{loading ? 'Generando...' : 'Generar Código de Registro'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ textAlign: 'center', margin: '16px 0 24px 0' }}>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
Código de Registro Único
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '2rem',
|
||||
fontWeight: 800,
|
||||
color: 'var(--accent-cyan)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
letterSpacing: '0.1em',
|
||||
marginTop: '6px',
|
||||
}}
|
||||
>
|
||||
{generatedCode.code}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-dim)', marginTop: '4px' }}>
|
||||
Expira: {new Date(generatedCode.expires_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Comando de instalación en Windows (PowerShell / CMD):</label>
|
||||
<div className="code-box">
|
||||
<span>{psCommand}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '6px 10px', fontSize: '0.75rem' }}
|
||||
onClick={() => copyToClipboard(psCommand)}
|
||||
>
|
||||
{isCopied ? <Check size={14} color="var(--accent-emerald)" /> : <Copy size={14} />}
|
||||
{isCopied ? 'Copiado' : 'Copiar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '24px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
onRefresh();
|
||||
}}
|
||||
>
|
||||
Listo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Users,
|
||||
CheckCircle,
|
||||
HardDrive,
|
||||
Activity,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
Server
|
||||
} from 'lucide-react';
|
||||
import { DashboardStats, EventLogItem } from '../services/api';
|
||||
import { LiveTransferMeter, ActiveUpload } from '../components/LiveTransferMeter';
|
||||
|
||||
interface DashboardViewProps {
|
||||
stats: DashboardStats | null;
|
||||
events: EventLogItem[];
|
||||
activeUploads: ActiveUpload[];
|
||||
onNavigateToClients: () => void;
|
||||
}
|
||||
|
||||
export const DashboardView: React.FC<DashboardViewProps> = ({
|
||||
stats,
|
||||
events,
|
||||
activeUploads,
|
||||
onNavigateToClients,
|
||||
}) => {
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const usedBytes = stats?.storage.used_bytes || 0;
|
||||
const totalBytes = stats?.storage.total_bytes || 1;
|
||||
const usagePct = stats?.storage.usage_percent || 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* High-level Statistics Grid */}
|
||||
<div className="stats-grid">
|
||||
<div className="glass-card stat-card">
|
||||
<div className="stat-info">
|
||||
<h3>Clientes Windows</h3>
|
||||
<div className="stat-number">{stats?.total_clients ?? 0}</div>
|
||||
<div className="stat-sub" style={{ color: 'var(--accent-emerald)' }}>
|
||||
● {stats?.online_clients ?? 0} Online | {stats?.offline_clients ?? 0} Offline
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-icon">
|
||||
<Users size={22} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card stat-card emerald">
|
||||
<div className="stat-info">
|
||||
<h3>Backups Hoy</h3>
|
||||
<div className="stat-number">{stats?.backups_today_count ?? 0}</div>
|
||||
<div className="stat-sub" style={{ color: '#34d399' }}>
|
||||
✓ {stats?.backups_today_success ?? 0} Exitosos | {stats?.backups_today_failed ?? 0} Fallidos
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-icon" style={{ color: 'var(--accent-emerald)' }}>
|
||||
<CheckCircle size={22} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card stat-card indigo">
|
||||
<div className="stat-info">
|
||||
<h3>Almacenamiento Proxmox</h3>
|
||||
<div className="stat-number">{formatBytes(usedBytes)}</div>
|
||||
<div className="stat-sub">
|
||||
{usagePct}% de {formatBytes(totalBytes)} asignados
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-icon" style={{ color: 'var(--accent-indigo)' }}>
|
||||
<HardDrive size={22} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card stat-card amber">
|
||||
<div className="stat-info">
|
||||
<h3>Trabajos Activos</h3>
|
||||
<div className="stat-number">{stats?.total_jobs ?? 0}</div>
|
||||
<div className="stat-sub">
|
||||
{stats?.active_uploads_count ?? 0} subidas en curso
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-icon" style={{ color: 'var(--accent-amber)' }}>
|
||||
<Activity size={22} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Storage Pool Progress Bar */}
|
||||
<div className="glass-card" style={{ marginBottom: '28px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '1rem', fontWeight: 700 }}>Pool de Almacenamiento Proxmox VE (LXC)</h3>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-dim)', marginTop: '2px' }}>
|
||||
Ruta: <code style={{ color: 'var(--accent-cyan)' }}>{stats?.storage.storage_root || '/storage/backups'}</code>
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<span style={{ fontSize: '1.2rem', fontWeight: 800, fontFamily: 'var(--font-mono)' }}>
|
||||
{usagePct}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="storage-meter-container">
|
||||
<div className="progress-track">
|
||||
<div className="progress-fill" style={{ width: `${Math.min(100, usagePct)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Transfer Telemetry */}
|
||||
<LiveTransferMeter uploads={activeUploads} />
|
||||
|
||||
{/* Recent Events & Quick Actions Grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '24px' }}>
|
||||
<div className="glass-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<h3 style={{ fontSize: '1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Clock size={18} color="var(--accent-cyan)" />
|
||||
Actividad Reciente del Sistema
|
||||
</h3>
|
||||
<span style={{ fontSize: '0.78rem', color: 'var(--text-muted)' }}>Últimos eventos</span>
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
<table className="modern-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hora</th>
|
||||
<th>Evento</th>
|
||||
<th>Mensaje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.slice(0, 6).map((evt) => (
|
||||
<tr key={evt.id}>
|
||||
<td style={{ fontSize: '0.78rem', color: 'var(--text-dim)', whiteSpace: 'nowrap' }}>
|
||||
{new Date(evt.timestamp).toLocaleTimeString()}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${
|
||||
evt.severity === 'ERROR' ? 'badge-error' :
|
||||
evt.event_type.includes('COMPLETED') ? 'badge-online' : 'badge-syncing'
|
||||
}`}>
|
||||
{evt.event_type}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontSize: '0.85rem' }}>{evt.message}</td>
|
||||
</tr>
|
||||
))}
|
||||
{events.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} style={{ textAlign: 'center', color: 'var(--text-dim)', padding: '24px' }}>
|
||||
No hay eventos registrados recientemente.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '12px' }}>OnEver Architecture</h3>
|
||||
<p style={{ fontSize: '0.82rem', color: 'var(--text-muted)', lineHeight: '1.5' }}>
|
||||
Plataforma desacoplada en dos capas:
|
||||
</p>
|
||||
<ul style={{ fontSize: '0.82rem', color: 'var(--text-dim)', marginTop: '8px', paddingLeft: '18px', lineHeight: '1.6' }}>
|
||||
<li><strong>Backend API</strong>: FastAPI + PostgreSQL en LXC 1</li>
|
||||
<li><strong>Storage Node</strong>: Volumen aislado en LXC 2</li>
|
||||
<li><strong>Motor Chunks</strong>: Subida por bloques de 4MB con reanudación y SHA-256</li>
|
||||
<li><strong>Agente Windows</strong>: Servicio de fondo con detector de locks SQL</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<button className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }} onClick={onNavigateToClients}>
|
||||
<Users size={16} />
|
||||
Administrar Clientes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FileText, ShieldAlert, CheckCircle, Info, AlertTriangle, Search } from 'lucide-react';
|
||||
import { EventLogItem } from '../services/api';
|
||||
|
||||
interface EventsViewProps {
|
||||
events: EventLogItem[];
|
||||
}
|
||||
|
||||
export const EventsView: React.FC<EventsViewProps> = ({ events }) => {
|
||||
const [filterSeverity, setFilterSeverity] = useState<string>('ALL');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const filteredEvents = events.filter((evt) => {
|
||||
const matchesSev = filterSeverity === 'ALL' || evt.severity === filterSeverity;
|
||||
const matchesSearch =
|
||||
evt.message.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
evt.event_type.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
return matchesSev && matchesSearch;
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '1.2rem', fontWeight: 700 }}>Registro de Auditoría & Eventos</h3>
|
||||
<p style={{ fontSize: '0.84rem', color: 'var(--text-muted)', marginTop: '2px' }}>
|
||||
Trazabilidad completa de inicios de sesión, transferencias por bloques, registros y retenciones
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card" style={{ marginBottom: '24px', padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: 1, minWidth: '240px', position: 'relative' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
style={{ paddingLeft: '36px' }}
|
||||
placeholder="Filtrar eventos o palabras clave..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Search size={16} color="var(--text-dim)" style={{ position: 'absolute', left: '12px', top: '14px' }} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{['ALL', 'INFO', 'WARNING', 'ERROR'].map((sev) => (
|
||||
<button
|
||||
key={sev}
|
||||
className={`btn ${filterSeverity === sev ? 'btn-primary' : 'btn-secondary'}`}
|
||||
style={{ padding: '8px 14px', fontSize: '0.78rem' }}
|
||||
onClick={() => setFilterSeverity(sev)}
|
||||
>
|
||||
{sev === 'ALL' ? 'Todos' : sev}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card">
|
||||
<div className="table-container">
|
||||
<table className="modern-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Severidad</th>
|
||||
<th>Tipo de Evento</th>
|
||||
<th>Detalle del Mensaje</th>
|
||||
<th>Cliente ID</th>
|
||||
<th>IP / Usuario</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredEvents.map((evt) => (
|
||||
<tr key={evt.id}>
|
||||
<td style={{ fontSize: '0.82rem', fontFamily: 'var(--font-mono)', color: 'var(--text-dim)', whiteSpace: 'nowrap' }}>
|
||||
{new Date(evt.timestamp).toLocaleString()}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`badge ${
|
||||
evt.severity === 'ERROR' ? 'badge-error' :
|
||||
evt.severity === 'WARNING' ? 'badge-offline' : 'badge-online'
|
||||
}`}
|
||||
>
|
||||
{evt.severity}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="hash-badge" style={{ color: 'var(--accent-cyan)' }}>
|
||||
{evt.event_type}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontSize: '0.88rem' }}>{evt.message}</td>
|
||||
<td style={{ fontSize: '0.82rem', fontFamily: 'var(--font-mono)', color: 'var(--text-muted)' }}>
|
||||
{evt.client_id ? `#${evt.client_id}` : '—'}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.8rem', color: 'var(--text-dim)' }}>
|
||||
{evt.user_email || evt.ip_address || 'Sistema'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredEvents.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-dim)' }}>
|
||||
No hay registros de eventos que coincidan con los filtros.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,336 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Play,
|
||||
Trash2,
|
||||
Clock,
|
||||
Folder,
|
||||
Filter,
|
||||
Calendar,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { BackupJobItem, ClientItem, api } from '../services/api';
|
||||
|
||||
interface JobsViewProps {
|
||||
jobs: BackupJobItem[];
|
||||
clients: ClientItem[];
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh }) => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
client_id: clients[0]?.id || 0,
|
||||
name: '',
|
||||
source_path: 'C:\\SQLBackups',
|
||||
file_patterns: '*.bak,*.mdf',
|
||||
schedule_cron: '0 2 * * *',
|
||||
keep_daily: 7,
|
||||
keep_weekly: 4,
|
||||
keep_monthly: 12,
|
||||
min_stable_time_seconds: 60,
|
||||
});
|
||||
|
||||
const handleCreateJob = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.client_id) {
|
||||
alert('Por favor selecciona un cliente');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.createJob(formData);
|
||||
setShowModal(false);
|
||||
onRefresh();
|
||||
} catch (err: any) {
|
||||
alert(`Error creando trabajo: ${err.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrigger = async (job: BackupJobItem) => {
|
||||
try {
|
||||
await api.triggerJob(job.id);
|
||||
alert(`¡Trabajo ${job.job_code} encolado para ejecución inmediata!`);
|
||||
onRefresh();
|
||||
} catch (err: any) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (job: BackupJobItem) => {
|
||||
if (confirm(`¿Eliminar el trabajo de backup ${job.name} (${job.job_code})?`)) {
|
||||
try {
|
||||
await api.deleteJob(job.id);
|
||||
onRefresh();
|
||||
} catch (err: any) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getClientName = (clientId: number) => {
|
||||
const client = clients.find((c) => c.id === clientId);
|
||||
return client ? `${client.name} (${client.client_code})` : `Cliente #${clientId}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '1.2rem', fontWeight: 700 }}>Trabajos de Backup Programados</h3>
|
||||
<p style={{ fontSize: '0.84rem', color: 'var(--text-muted)', marginTop: '2px' }}>
|
||||
Configuración de rutas de origen Windows, filtros de extensión y políticas de retención
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
if (clients.length === 0) {
|
||||
alert('Primero debes registrar al menos un cliente Windows.');
|
||||
return;
|
||||
}
|
||||
setFormData((prev) => ({ ...prev, client_id: clients[0].id }));
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Crear Trabajo de Backup
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="glass-card">
|
||||
<div className="table-container">
|
||||
<table className="modern-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job ID</th>
|
||||
<th>Nombre del Trabajo</th>
|
||||
<th>Cliente Asignado</th>
|
||||
<th>Origen en Windows</th>
|
||||
<th>Filtros</th>
|
||||
<th>Retención</th>
|
||||
<th>Horario / Cron</th>
|
||||
<th>Estado</th>
|
||||
<th style={{ textAlign: 'right' }}>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((job) => (
|
||||
<tr key={job.id}>
|
||||
<td>
|
||||
<span className="hash-badge" style={{ color: 'var(--accent-indigo)' }}>
|
||||
{job.job_code}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontWeight: 600 }}>{job.name}</td>
|
||||
<td style={{ fontSize: '0.84rem', color: 'var(--text-muted)' }}>
|
||||
{getClientName(job.client_id)}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.82rem', fontFamily: 'var(--font-mono)' }}>
|
||||
<Folder size={14} color="var(--accent-cyan)" />
|
||||
{job.source_path}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="hash-badge">{job.file_patterns}</span>
|
||||
</td>
|
||||
<td style={{ fontSize: '0.82rem' }}>
|
||||
<span style={{ color: '#34d399' }}>{job.keep_daily}d</span> /{' '}
|
||||
<span style={{ color: '#60a5fa' }}>{job.keep_weekly}w</span> /{' '}
|
||||
<span style={{ color: '#c084fc' }}>{job.keep_monthly}m</span>
|
||||
</td>
|
||||
<td style={{ fontSize: '0.82rem', fontFamily: 'var(--font-mono)', color: 'var(--text-dim)' }}>
|
||||
{job.schedule_cron}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`badge ${
|
||||
job.status === 'RUNNING' ? 'badge-syncing' :
|
||||
job.status === 'QUEUED' ? 'badge-syncing' : 'badge-online'
|
||||
}`}
|
||||
>
|
||||
{job.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '6px 10px', fontSize: '0.78rem', color: 'var(--accent-cyan)' }}
|
||||
onClick={() => handleTrigger(job)}
|
||||
title="Ejecutar backup ahora"
|
||||
>
|
||||
<Play size={13} />
|
||||
Ejecutar
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
style={{ padding: '6px 10px', fontSize: '0.78rem' }}
|
||||
onClick={() => handleDelete(job)}
|
||||
title="Eliminar trabajo"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{jobs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-dim)' }}>
|
||||
No hay trabajos de backup configurados. Haz clic en "Crear Trabajo de Backup".
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Job Modal */}
|
||||
{showModal && (
|
||||
<div className="modal-backdrop">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h3 style={{ fontSize: '1.1rem', fontWeight: 700 }}>Nuevo Trabajo de Backup</h3>
|
||||
<button
|
||||
style={{ background: 'transparent', border: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}
|
||||
onClick={() => setShowModal(false)}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateJob}>
|
||||
<div className="form-group">
|
||||
<label>Cliente Windows Destino:</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={formData.client_id}
|
||||
onChange={(e) => setFormData({ ...formData, client_id: Number(e.target.value) })}
|
||||
>
|
||||
{clients.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name} ({c.client_code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Nombre del Trabajo:</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="form-input"
|
||||
placeholder="Ej: SQL Server Producción Diaria"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div className="form-group">
|
||||
<label>Ruta Origen en Windows:</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="form-input"
|
||||
placeholder="C:\SQLBackups"
|
||||
value={formData.source_path}
|
||||
onChange={(e) => setFormData({ ...formData, source_path: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Filtros de Archivo:</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="form-input"
|
||||
placeholder="*.bak,*.mdf"
|
||||
value={formData.file_patterns}
|
||||
onChange={(e) => setFormData({ ...formData, file_patterns: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div className="form-group">
|
||||
<label>Horario (Expresión Cron):</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="form-input"
|
||||
placeholder="0 2 * * *"
|
||||
value={formData.schedule_cron}
|
||||
onChange={(e) => setFormData({ ...formData, schedule_cron: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Estabilidad Mínima Archivo (segundos):</label>
|
||||
<input
|
||||
type="number"
|
||||
min="10"
|
||||
required
|
||||
className="form-input"
|
||||
value={formData.min_stable_time_seconds}
|
||||
onChange={(e) => setFormData({ ...formData, min_stable_time_seconds: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Políticas de Retención (Conservar copias):</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '12px' }}>
|
||||
<div>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>Días (Diario):</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
className="form-input"
|
||||
value={formData.keep_daily}
|
||||
onChange={(e) => setFormData({ ...formData, keep_daily: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>Semanas (Semanal):</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
className="form-input"
|
||||
value={formData.keep_weekly}
|
||||
onChange={(e) => setFormData({ ...formData, keep_weekly: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>Meses (Mensual):</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
className="form-input"
|
||||
value={formData.keep_monthly}
|
||||
onChange={(e) => setFormData({ ...formData, keep_monthly: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', marginTop: '24px' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowModal(false)}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||
{loading ? 'Guardando...' : 'Crear Trabajo'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ShieldCheck, Lock, Mail, ArrowRight, Server, Key } from 'lucide-react';
|
||||
import { api, setAuthToken } from '../services/api';
|
||||
|
||||
interface LoginViewProps {
|
||||
onLoginSuccess: (user: any) => void;
|
||||
}
|
||||
|
||||
export const LoginView: React.FC<LoginViewProps> = ({ onLoginSuccess }) => {
|
||||
const [email, setEmail] = useState('admin@oneverdrive.local');
|
||||
const [password, setPassword] = useState('Admin1234!');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await api.login(email.trim(), password);
|
||||
setAuthToken(res.access_token);
|
||||
localStorage.setItem('oed_user', JSON.stringify(res.user));
|
||||
onLoginSuccess(res.user);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Credenciales incorrectas o error en el servidor.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fillDefaultCredentials = () => {
|
||||
setEmail('admin@oneverdrive.local');
|
||||
setPassword('Admin1234!');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '24px',
|
||||
background: 'radial-gradient(ellipse at center, #111827 0%, #0B0F19 100%)',
|
||||
}}
|
||||
>
|
||||
<div className="glass-card" style={{ width: '100%', maxWidth: '440px', padding: '36px 32px' }}>
|
||||
{/* Brand Header */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '28px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '56px',
|
||||
height: '56px',
|
||||
margin: '0 auto 16px auto',
|
||||
background: 'linear-gradient(135deg, #06B6D4, #6366F1)',
|
||||
borderRadius: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 0 24px rgba(6, 182, 212, 0.35)',
|
||||
color: '#FFFFFF',
|
||||
}}
|
||||
>
|
||||
<ShieldCheck size={32} />
|
||||
</div>
|
||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 800, letterSpacing: '-0.02em', color: '#FFFFFF' }}>
|
||||
OnEver Drive
|
||||
</h2>
|
||||
<p style={{ fontSize: '0.84rem', color: '#94A3B8', marginTop: '4px' }}>
|
||||
Plataforma Centralizada de Backup & Sincronización
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'rgba(244, 63, 94, 0.15)',
|
||||
border: '1px solid rgba(244, 63, 94, 0.3)',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 14px',
|
||||
fontSize: '0.84rem',
|
||||
color: '#FDA4AF',
|
||||
marginBottom: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login Form */}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<Mail size={14} color="#06B6D4" /> Correo Electrónico:
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
className="form-input"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@oneverdrive.local"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginTop: '16px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<Lock size={14} color="#06B6D4" /> Contraseña:
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
className="form-input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
padding: '12px',
|
||||
fontSize: '0.95rem',
|
||||
marginTop: '24px',
|
||||
}}
|
||||
>
|
||||
<span>{loading ? 'Autenticando...' : 'Iniciar Sesión'}</span>
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Quick Demo Credentials */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: '24px',
|
||||
paddingTop: '20px',
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '0.78rem', color: '#64748B', marginBottom: '8px' }}>
|
||||
Credenciales de Administrador por Defecto:
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fillDefaultCredentials}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.04)',
|
||||
border: '1px dashed rgba(6, 182, 212, 0.4)',
|
||||
borderRadius: '6px',
|
||||
color: '#38BDF8',
|
||||
padding: '6px 12px',
|
||||
fontSize: '0.76rem',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
admin@oneverdrive.local / Admin1234!
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Download,
|
||||
Trash2,
|
||||
FileCheck,
|
||||
ShieldCheck,
|
||||
Search,
|
||||
HardDrive,
|
||||
Filter
|
||||
} from 'lucide-react';
|
||||
import { BackupFileItem, ClientItem, api } from '../services/api';
|
||||
|
||||
interface RestoreViewProps {
|
||||
clients: ClientItem[];
|
||||
}
|
||||
|
||||
export const RestoreView: React.FC<RestoreViewProps> = ({ clients }) => {
|
||||
const [backups, setBackups] = useState<BackupFileItem[]>([]);
|
||||
const [selectedClientId, setSelectedClientId] = useState<number | undefined>(undefined);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchBackups = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.getBackups(selectedClientId);
|
||||
setBackups(res);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch backups:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchBackups();
|
||||
}, [selectedClientId]);
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const handleDownload = (backup: BackupFileItem) => {
|
||||
window.open(`/api/backups/${backup.id}/download`, '_blank');
|
||||
};
|
||||
|
||||
const handleDelete = async (backup: BackupFileItem) => {
|
||||
if (confirm(`¿Eliminar la copia de seguridad '${backup.filename}' del almacenamiento central?`)) {
|
||||
try {
|
||||
await api.deleteBackup(backup.id);
|
||||
fetchBackups();
|
||||
} catch (err: any) {
|
||||
alert(`Error eliminando archivo: ${err.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const filteredBackups = backups.filter((b) =>
|
||||
b.filename.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
b.sha256.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const getClientName = (clientId: number) => {
|
||||
const c = clients.find((client) => client.id === clientId);
|
||||
return c ? `${c.name} (${c.client_code})` : `Cliente #${clientId}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '1.2rem', fontWeight: 700 }}>Explorador de Backups & Restauración</h3>
|
||||
<p style={{ fontSize: '0.84rem', color: 'var(--text-muted)', marginTop: '2px' }}>
|
||||
Visualización, verificación de integridad SHA-256 y descarga directa de archivos respaldados
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter and Search Bar */}
|
||||
<div className="glass-card" style={{ marginBottom: '24px', padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: 1, minWidth: '240px', position: 'relative' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
style={{ paddingLeft: '36px' }}
|
||||
placeholder="Buscar por nombre de archivo o hash SHA-256..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Search size={16} color="var(--text-dim)" style={{ position: 'absolute', left: '12px', top: '14px' }} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Filter size={16} color="var(--text-muted)" />
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ width: 'auto' }}
|
||||
value={selectedClientId || ''}
|
||||
onChange={(e) => setSelectedClientId(e.target.value ? Number(e.target.value) : undefined)}
|
||||
>
|
||||
<option value="">Todos los Clientes</option>
|
||||
{clients.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name} ({c.client_code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backups Table */}
|
||||
<div className="glass-card">
|
||||
<div className="table-container">
|
||||
<table className="modern-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Archivo Resguardado</th>
|
||||
<th>Cliente Origen</th>
|
||||
<th>Tamaño</th>
|
||||
<th>Integridad SHA-256</th>
|
||||
<th>Retención</th>
|
||||
<th>Fecha de Respaldo</th>
|
||||
<th style={{ textAlign: 'right' }}>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredBackups.map((backup) => (
|
||||
<tr key={backup.id}>
|
||||
<td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<FileCheck size={18} color="var(--accent-emerald)" />
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{backup.filename}</div>
|
||||
<div style={{ fontSize: '0.74rem', color: 'var(--text-dim)' }}>
|
||||
{backup.relative_path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ fontSize: '0.84rem', color: 'var(--text-muted)' }}>
|
||||
{getClientName(backup.client_id)}
|
||||
</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.86rem' }}>
|
||||
{formatBytes(backup.file_size)}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<ShieldCheck size={14} color="var(--accent-emerald)" />
|
||||
<span className="hash-badge" title={backup.sha256}>
|
||||
{backup.sha256.substring(0, 16)}...
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-online">
|
||||
{backup.retention_tag}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontSize: '0.82rem', color: 'var(--text-dim)' }}>
|
||||
{new Date(backup.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ padding: '6px 12px', fontSize: '0.78rem' }}
|
||||
onClick={() => handleDownload(backup)}
|
||||
title="Descargar archivo íntegro"
|
||||
>
|
||||
<Download size={14} />
|
||||
Descargar
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
style={{ padding: '6px 10px', fontSize: '0.78rem' }}
|
||||
onClick={() => handleDelete(backup)}
|
||||
title="Eliminar de almacenamiento"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredBackups.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-dim)' }}>
|
||||
{loading ? 'Cargando copias de seguridad...' : 'No se encontraron archivos de backup.'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
const API_BASE = '/api';
|
||||
|
||||
export interface DashboardStats {
|
||||
total_clients: number;
|
||||
online_clients: number;
|
||||
offline_clients: number;
|
||||
total_jobs: number;
|
||||
backups_today_count: number;
|
||||
backups_today_success: number;
|
||||
backups_today_failed: number;
|
||||
active_uploads_count: number;
|
||||
storage: {
|
||||
total_bytes: number;
|
||||
used_bytes: number;
|
||||
free_bytes: number;
|
||||
usage_percent: number;
|
||||
storage_root: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ClientItem {
|
||||
id: number;
|
||||
client_code: string;
|
||||
name: string;
|
||||
hostname?: string;
|
||||
os_info?: string;
|
||||
ip_address?: string;
|
||||
agent_version: string;
|
||||
status: string;
|
||||
storage_used_bytes: number;
|
||||
storage_quota_bytes: number;
|
||||
last_seen_at?: string;
|
||||
last_backup_at?: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface BackupJobItem {
|
||||
id: number;
|
||||
job_code: string;
|
||||
client_id: number;
|
||||
name: string;
|
||||
source_path: string;
|
||||
file_patterns: string;
|
||||
schedule_cron: string;
|
||||
is_active: boolean;
|
||||
keep_daily: number;
|
||||
keep_weekly: number;
|
||||
keep_monthly: number;
|
||||
min_stable_time_seconds: number;
|
||||
status: string;
|
||||
last_run_at?: string;
|
||||
next_run_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface BackupFileItem {
|
||||
id: number;
|
||||
client_id: number;
|
||||
job_id?: number;
|
||||
session_id?: number;
|
||||
filename: string;
|
||||
relative_path: string;
|
||||
file_size: number;
|
||||
sha256: string;
|
||||
retention_tag: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface EventLogItem {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
event_type: string;
|
||||
severity: string;
|
||||
client_id?: number;
|
||||
job_id?: number;
|
||||
user_email?: string;
|
||||
ip_address?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const getAuthToken = (): string | null => {
|
||||
return localStorage.getItem('oed_token');
|
||||
};
|
||||
|
||||
export const getCurrentUser = (): any | null => {
|
||||
const saved = localStorage.getItem('oed_user');
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const setAuthToken = (token: string | null) => {
|
||||
if (token) {
|
||||
localStorage.setItem('oed_token', token);
|
||||
} else {
|
||||
localStorage.removeItem('oed_token');
|
||||
localStorage.removeItem('oed_user');
|
||||
}
|
||||
};
|
||||
|
||||
const request = async <T>(endpoint: string, options: RequestInit = {}): Promise<T> => {
|
||||
const token = getAuthToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
setAuthToken(null);
|
||||
window.dispatchEvent(new Event('oed_unauthorized'));
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Authentication required' }));
|
||||
throw new Error(errorData.detail || 'Authentication required');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Network error' }));
|
||||
throw new Error(errorData.detail || `Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const api = {
|
||||
// Auth
|
||||
login: (email: string, password: string) =>
|
||||
request<{ access_token: string; user: any }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
}),
|
||||
|
||||
// Stats
|
||||
getStats: () => request<DashboardStats>('/stats'),
|
||||
|
||||
// Clients
|
||||
getClients: () => request<ClientItem[]>('/clients'),
|
||||
createRegistrationCode: (clientNameHint?: string) =>
|
||||
request<{ code: string; expires_at: string }>('/clients/registration-code', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ client_name_hint: clientNameHint, expires_in_hours: 48 }),
|
||||
}),
|
||||
revokeClient: (clientId: number) =>
|
||||
request<{ message: string }>(`/clients/${clientId}/revoke`, { method: 'POST' }),
|
||||
deleteClient: (clientId: number) =>
|
||||
request<{ message: string }>(`/clients/${clientId}`, { method: 'DELETE' }),
|
||||
|
||||
// Jobs
|
||||
getJobs: (clientId?: number) =>
|
||||
request<BackupJobItem[]>(clientId ? `/jobs?client_id=${clientId}` : '/jobs'),
|
||||
createJob: (jobData: {
|
||||
client_id: number;
|
||||
name: string;
|
||||
source_path: string;
|
||||
file_patterns: string;
|
||||
schedule_cron: string;
|
||||
keep_daily: number;
|
||||
keep_weekly: number;
|
||||
keep_monthly: number;
|
||||
min_stable_time_seconds: number;
|
||||
}) =>
|
||||
request<BackupJobItem>('/jobs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(jobData),
|
||||
}),
|
||||
triggerJob: (jobId: number) =>
|
||||
request<{ message: string }>(`/jobs/${jobId}/trigger`, { method: 'POST' }),
|
||||
deleteJob: (jobId: number) =>
|
||||
request<{ message: string }>(`/jobs/${jobId}`, { method: 'DELETE' }),
|
||||
|
||||
// Backups
|
||||
getBackups: (clientId?: number, jobId?: number) => {
|
||||
const params = new URLSearchParams();
|
||||
if (clientId) params.append('client_id', clientId.toString());
|
||||
if (jobId) params.append('job_id', jobId.toString());
|
||||
return request<BackupFileItem[]>(`/backups?${params.toString()}`);
|
||||
},
|
||||
deleteBackup: (backupId: number) =>
|
||||
request<{ message: string }>(`/backups/${backupId}`, { method: 'DELETE' }),
|
||||
|
||||
// Events
|
||||
getEvents: (limit: number = 50) => request<EventLogItem[]>(`/events?limit=${limit}`),
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
export type WebSocketCallback = (event: { type: string; data: any }) => void;
|
||||
|
||||
class WebSocketClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private listeners: Set<WebSocketCallback> = new Set();
|
||||
private reconnectInterval = 3000;
|
||||
private isConnected = false;
|
||||
|
||||
public connect() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
const wsUrl = `${protocol}//${host}/ws/telemetry`;
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.isConnected = true;
|
||||
this.notify({ type: 'WS_CONNECTED', data: { status: true } });
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const parsed = JSON.parse(event.data);
|
||||
this.notify(parsed);
|
||||
} catch {
|
||||
// Ignored
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.isConnected = false;
|
||||
this.notify({ type: 'WS_DISCONNECTED', data: { status: false } });
|
||||
setTimeout(() => this.connect(), this.reconnectInterval);
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
this.ws?.close();
|
||||
};
|
||||
} catch {
|
||||
setTimeout(() => this.connect(), this.reconnectInterval);
|
||||
}
|
||||
}
|
||||
|
||||
public subscribe(callback: WebSocketCallback) {
|
||||
this.listeners.add(callback);
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
private notify(payload: { type: string; data: any }) {
|
||||
this.listeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error('WebSocket subscriber error:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public getStatus() {
|
||||
return this.isConnected;
|
||||
}
|
||||
}
|
||||
|
||||
export const wsClient = new WebSocketClient();
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://127.0.0.1:8000',
|
||||
ws: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user