feat: Initial commit for OnEver Drive centralized backup system with Windows PyQt6 agent and Proxmox LXC deployment

This commit is contained in:
2026-08-13 19:33:44 -03:00
commit 1bfb808c79
77 changed files with 10675 additions and 0 deletions
+212
View File
@@ -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>
);
};
+67
View File
@@ -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>
);
};
+55
View File
@@ -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>
);
};
+569
View File
@@ -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;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+288
View File
@@ -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>
);
};
+194
View File
@@ -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 &nbsp;|&nbsp; {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 &nbsp;|&nbsp; {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>
);
};
+117
View File
@@ -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>
);
};
+336
View File
@@ -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>
);
};
+174
View File
@@ -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>
);
};
+204
View File
@@ -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>
);
};
+197
View File
@@ -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}`),
};
+67
View File
@@ -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();