se agregaron nuevas caracteristicas

This commit is contained in:
Carlos Tello
2026-08-13 23:07:01 -03:00
parent 1bfb808c79
commit d736db982e
23 changed files with 1244 additions and 100 deletions
+16 -2
View File
@@ -7,6 +7,7 @@ import { JobsView } from './pages/JobsView';
import { RestoreView } from './pages/RestoreView';
import { EventsView } from './pages/EventsView';
import { LoginView } from './pages/LoginView';
import { SettingsView } from './pages/SettingsView';
import { ActiveUpload } from './components/LiveTransferMeter';
import {
api,
@@ -16,7 +17,8 @@ import {
EventLogItem,
getAuthToken,
getCurrentUser,
setAuthToken
setAuthToken,
SystemSettingsResponse
} from './services/api';
import { wsClient } from './services/websocket';
@@ -32,6 +34,7 @@ export const App: React.FC = () => {
const [clients, setClients] = useState<ClientItem[]>([]);
const [jobs, setJobs] = useState<BackupJobItem[]>([]);
const [events, setEvents] = useState<EventLogItem[]>([]);
const [settings, setSettings] = useState<SystemSettingsResponse | null>(null);
const [activeUploads, setActiveUploads] = useState<ActiveUpload[]>([]);
const handleLoginSuccess = (user: any) => {
@@ -54,17 +57,19 @@ export const App: React.FC = () => {
if (!getAuthToken()) return;
setIsLoading(true);
try {
const [statsRes, clientsRes, jobsRes, eventsRes] = await Promise.all([
const [statsRes, clientsRes, jobsRes, eventsRes, settingsRes] = await Promise.all([
api.getStats().catch(() => null),
api.getClients().catch(() => []),
api.getJobs().catch(() => []),
api.getEvents(50).catch(() => []),
api.getSettings().catch(() => null),
]);
if (statsRes) setStats(statsRes);
setClients(clientsRes);
setJobs(jobsRes);
setEvents(eventsRes);
if (settingsRes) setSettings(settingsRes);
} catch (err) {
console.error('Error loading dashboard data:', err);
} finally {
@@ -147,6 +152,8 @@ export const App: React.FC = () => {
return 'Explorador & Restore';
case 'events':
return 'Auditoría & Logs';
case 'settings':
return 'Configuración Global';
default:
return 'OnEver Drive';
}
@@ -191,6 +198,7 @@ export const App: React.FC = () => {
jobs={jobs}
clients={clients}
onRefresh={loadData}
settings={settings}
/>
)}
@@ -205,6 +213,12 @@ export const App: React.FC = () => {
events={events}
/>
)}
{currentTab === 'settings' && (
<SettingsView
onRefresh={loadData}
/>
)}
</main>
</div>
</div>
+3 -1
View File
@@ -6,7 +6,8 @@ import {
RotateCcw,
FileText,
ShieldCheck,
Server
Server,
Settings
} from 'lucide-react';
interface SidebarProps {
@@ -22,6 +23,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentTab, setCurrentTab, isW
{ id: 'jobs', label: 'Trabajos de Backup', icon: Layers },
{ id: 'restore', label: 'Explorador & Restore', icon: RotateCcw },
{ id: 'events', label: 'Auditoría & Logs', icon: FileText },
{ id: 'settings', label: 'Configuración Global', icon: Settings },
];
return (
+135 -3
View File
@@ -8,7 +8,9 @@ import {
Trash2,
Laptop,
Server as ServerIcon,
X
X,
Edit2,
RefreshCw
} from 'lucide-react';
import { ClientItem, api } from '../services/api';
@@ -19,6 +21,10 @@ interface ClientsViewProps {
export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh }) => {
const [showModal, setShowModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [editingClient, setEditingClient] = useState<ClientItem | null>(null);
const [editAlias, setEditAlias] = useState('');
const [editQuotaGb, setEditQuotaGb] = useState<number>(100);
const [clientHint, setClientHint] = useState('');
const [generatedCode, setGeneratedCode] = useState<{ code: string; expires_at: string } | null>(null);
const [isCopied, setIsCopied] = useState(false);
@@ -67,6 +73,47 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
}
};
const handleReRegister = async (client: ClientItem) => {
if (confirm(`¿Estás seguro de que deseas volver a generar el código de vinculación para el cliente "${client.name}"?\n\nEsto revocará de inmediato los tokens de acceso actuales del equipo.`)) {
setLoading(true);
try {
const codeData = await api.generateReRegisterCode(client.id);
setGeneratedCode(codeData);
setShowModal(true);
} catch (err: any) {
alert(`Error al generar código de re-vinculación: ${err.message}`);
} finally {
setLoading(false);
}
}
};
const handleOpenEditModal = (client: ClientItem) => {
setEditingClient(client);
setEditAlias(client.alias || '');
setEditQuotaGb(Math.round(client.storage_quota_bytes / (1024 * 1024 * 1024)));
setShowEditModal(true);
};
const handleSaveEdit = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingClient) return;
setLoading(true);
try {
const quotaBytes = editQuotaGb * 1024 * 1024 * 1024;
await api.updateClient(editingClient.id, {
alias: editAlias.trim(),
storage_quota_bytes: quotaBytes
});
setShowEditModal(false);
onRefresh();
} catch (err: any) {
alert(`Error al actualizar cliente: ${err.message}`);
} finally {
setLoading(false);
}
};
const serverUrl = window.location.origin;
const psCommand = generatedCode
? `python agent_cli.py register --server "${serverUrl}" --code "${generatedCode.code}"`
@@ -100,6 +147,7 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
<tr>
<th>Cliente ID</th>
<th>Nombre / Hostname</th>
<th>Ubicación / Alias</th>
<th>Sistema Operativo</th>
<th>Dirección IP</th>
<th>Estado</th>
@@ -133,6 +181,9 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
</div>
</div>
</td>
<td style={{ fontSize: '0.84rem', color: 'var(--accent-cyan)', fontWeight: 600 }}>
{client.alias || '—'}
</td>
<td style={{ fontSize: '0.84rem', color: 'var(--text-muted)' }}>
{client.os_info || 'Windows'}
</td>
@@ -145,7 +196,12 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
</span>
</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.84rem' }}>
{formatBytes(client.storage_used_bytes)}
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div>{formatBytes(client.storage_used_bytes)}</div>
<div style={{ fontSize: '0.74rem', color: 'var(--text-dim)' }}>
de {formatBytes(client.storage_quota_bytes)}
</div>
</div>
</td>
<td style={{ fontSize: '0.82rem', color: 'var(--text-dim)' }}>
{client.last_seen_at
@@ -154,6 +210,14 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
</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={() => handleOpenEditModal(client)}
title="Editar Cliente (Alias / Cuota)"
>
<Edit2 size={14} />
</button>
<button
className="btn btn-secondary"
style={{ padding: '6px 10px', fontSize: '0.78rem' }}
@@ -163,6 +227,15 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
<ShieldAlert size={14} color="var(--accent-amber)" />
Revocar
</button>
<button
className="btn btn-secondary"
style={{ padding: '6px 10px', fontSize: '0.78rem' }}
onClick={() => handleReRegister(client)}
title="Re-vincular Agente (Generar nuevo código)"
>
<RefreshCw size={14} color="var(--accent-cyan)" />
Re-vincular
</button>
<button
className="btn btn-danger"
style={{ padding: '6px 10px', fontSize: '0.78rem' }}
@@ -178,7 +251,7 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
})}
{clients.length === 0 && (
<tr>
<td colSpan={8} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-dim)' }}>
<td colSpan={9} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-dim)' }}>
No hay clientes Windows registrados. Haz clic en "Registrar Nuevo Cliente" para comenzar.
</td>
</tr>
@@ -283,6 +356,65 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
</div>
</div>
)}
{/* Edit Client Modal */}
{showEditModal && editingClient && (
<div className="modal-backdrop">
<div className="modal-card">
<div className="modal-header">
<h3 style={{ fontSize: '1.1rem', fontWeight: 700 }}>Editar Cliente {editingClient.client_code}</h3>
<button
style={{ background: 'transparent', border: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}
onClick={() => setShowEditModal(false)}
>
<X size={20} />
</button>
</div>
<form onSubmit={handleSaveEdit}>
<div className="form-group" style={{ marginBottom: '12px' }}>
<label>Nombre del Equipo (Solo lectura):</label>
<input
type="text"
className="form-input"
disabled
value={editingClient.name}
/>
</div>
<div className="form-group" style={{ marginBottom: '12px' }}>
<label>Alias / Ubicación (ej. "CLUB REGATAS"):</label>
<input
type="text"
className="form-input"
placeholder="Sin ubicación"
value={editAlias}
onChange={(e) => setEditAlias(e.target.value)}
/>
</div>
<div className="form-group" style={{ marginBottom: '12px' }}>
<label>Cuota de Almacenamiento Asignada (GB):</label>
<input
type="number"
min={1}
className="form-input"
value={editQuotaGb}
onChange={(e) => setEditQuotaGb(Number(e.target.value))}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', marginTop: '24px' }}>
<button type="button" className="btn btn-secondary" onClick={() => setShowEditModal(false)}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Guardando...' : 'Guardar Cambios'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
+14 -3
View File
@@ -9,15 +9,16 @@ import {
Calendar,
X
} from 'lucide-react';
import { BackupJobItem, ClientItem, api } from '../services/api';
import { BackupJobItem, ClientItem, api, SystemSettingsResponse } from '../services/api';
interface JobsViewProps {
jobs: BackupJobItem[];
clients: ClientItem[];
onRefresh: () => void;
settings?: SystemSettingsResponse | null;
}
export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh }) => {
export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh, settings }) => {
const [showModal, setShowModal] = useState(false);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
@@ -92,7 +93,17 @@ export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh })
alert('Primero debes registrar al menos un cliente Windows.');
return;
}
setFormData((prev) => ({ ...prev, client_id: clients[0].id }));
setFormData({
client_id: clients[0].id,
name: '',
source_path: 'C:\\SQLBackups',
file_patterns: '*.bak,*.mdf',
schedule_cron: '0 2 * * *',
keep_daily: settings?.default_keep_daily ?? 7,
keep_weekly: settings?.default_keep_weekly ?? 4,
keep_monthly: settings?.default_keep_monthly ?? 12,
min_stable_time_seconds: 60,
});
setShowModal(true);
}}
>
+71 -10
View File
@@ -17,9 +17,20 @@ interface RestoreViewProps {
export const RestoreView: React.FC<RestoreViewProps> = ({ clients }) => {
const [backups, setBackups] = useState<BackupFileItem[]>([]);
const [selectedClientId, setSelectedClientId] = useState<number | undefined>(undefined);
const [selectedGroup, setSelectedGroup] = useState<string>('');
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(false);
// Get unique sorted group names (aliases) from clients
const groups = Array.from(
new Set(clients.map((c) => c.alias).filter((alias): alias is string => !!alias))
).sort();
// Filter clients shown in dropdown based on selected group
const filteredClientsForDropdown = selectedGroup
? clients.filter((c) => c.alias === selectedGroup)
: clients;
const fetchBackups = async () => {
setLoading(true);
try {
@@ -44,8 +55,12 @@ export const RestoreView: React.FC<RestoreViewProps> = ({ clients }) => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const handleDownload = (backup: BackupFileItem) => {
window.open(`/api/backups/${backup.id}/download`, '_blank');
const handleDownload = async (backup: BackupFileItem) => {
try {
await api.downloadBackup(backup);
} catch (err: any) {
alert(`Error descargando archivo: ${err.message}`);
}
};
const handleDelete = async (backup: BackupFileItem) => {
@@ -59,14 +74,40 @@ export const RestoreView: React.FC<RestoreViewProps> = ({ clients }) => {
}
};
const filteredBackups = backups.filter((b) =>
b.filename.toLowerCase().includes(searchQuery.toLowerCase()) ||
b.sha256.toLowerCase().includes(searchQuery.toLowerCase())
);
const filteredBackups = backups.filter((b) => {
const query = searchQuery.toLowerCase();
// Check filename and SHA-256
const matchesFile = b.filename.toLowerCase().includes(query) ||
b.sha256.toLowerCase().includes(query);
// Find client for this backup and check Name, Hostname, Client ID and Alias
const client = clients.find((c) => c.id === b.client_id);
// Filter by selected group (alias) if specified
if (selectedGroup && (!client || client.alias !== selectedGroup)) {
return false;
}
// Filter by selected client if specified
if (selectedClientId && b.client_id !== selectedClientId) {
return false;
}
const matchesClient = client
? client.name.toLowerCase().includes(query) ||
(client.hostname && client.hostname.toLowerCase().includes(query)) ||
client.client_code.toLowerCase().includes(query) ||
(client.alias && client.alias.toLowerCase().includes(query))
: false;
return matchesFile || matchesClient;
});
const getClientName = (clientId: number) => {
const c = clients.find((client) => client.id === clientId);
return c ? `${c.name} (${c.client_code})` : `Cliente #${clientId}`;
if (!c) return `Cliente #${clientId}`;
return `${c.hostname || c.name} [${c.alias || c.name}] (${c.client_code})`;
};
return (
@@ -88,13 +129,33 @@ export const RestoreView: React.FC<RestoreViewProps> = ({ clients }) => {
type="text"
className="form-input"
style={{ paddingLeft: '36px' }}
placeholder="Buscar por nombre de archivo o hash SHA-256..."
placeholder="Buscar por archivo, hash, alias, equipo o cliente ID..."
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={selectedGroup}
onChange={(e) => {
setSelectedGroup(e.target.value);
setSelectedClientId(undefined); // Reset client when group changes
}}
>
<option value="">Todos los Grupos</option>
{groups.map((g) => (
<option key={g} value={g}>
Grupo: {g}
</option>
))}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Filter size={16} color="var(--text-muted)" />
<select
@@ -104,9 +165,9 @@ export const RestoreView: React.FC<RestoreViewProps> = ({ clients }) => {
onChange={(e) => setSelectedClientId(e.target.value ? Number(e.target.value) : undefined)}
>
<option value="">Todos los Clientes</option>
{clients.map((c) => (
{filteredClientsForDropdown.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.client_code})
{c.hostname || c.name} [{c.alias || c.name}] ({c.client_code})
</option>
))}
</select>
+235
View File
@@ -0,0 +1,235 @@
import React, { useState, useEffect } from 'react';
import {
Save,
Folder,
HardDrive,
Calendar,
Loader2,
ShieldCheck,
AlertCircle
} from 'lucide-react';
import { api, SystemSettingsResponse } from '../services/api';
interface SettingsViewProps {
onRefresh: () => void;
}
export const SettingsView: React.FC<SettingsViewProps> = ({ onRefresh }) => {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
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 [formData, setFormData] = useState<SystemSettingsResponse>({
storage_root: '',
global_quota_gb: 1000,
default_client_quota_gb: 100,
default_keep_daily: 7,
default_keep_weekly: 4,
default_keep_monthly: 12
});
const fetchSettings = async () => {
setLoading(true);
setErrorMsg(null);
try {
const res = await api.getSettings();
setFormData(res);
} catch (err: any) {
setErrorMsg(`Error al cargar configuraciones: ${err.message}`);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSettings();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setErrorMsg(null);
setSuccessMsg(null);
try {
const res = await api.updateSettings(formData);
setFormData(res);
setSuccessMsg('✓ Configuraciones globales guardadas y aplicadas con éxito.');
onRefresh(); // Refresh stats in App.tsx
setTimeout(() => setSuccessMsg(null), 5000);
} catch (err: any) {
setErrorMsg(`Error al guardar configuraciones: ${err.message}`);
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '300px', flexDirection: 'column', gap: '16px' }}>
<Loader2 className="animate-spin" size={32} color="var(--accent-cyan)" />
<span style={{ color: 'var(--text-muted)' }}>Cargando configuraciones globales...</span>
</div>
);
}
return (
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
<div style={{ marginBottom: '24px' }}>
<h3 style={{ fontSize: '1.2rem', fontWeight: 700 }}>Configuración Global del Sistema</h3>
<p style={{ fontSize: '0.84rem', color: 'var(--text-muted)', marginTop: '2px' }}>
Personaliza los parámetros del servidor, límites de almacenamiento y políticas de retención
</p>
</div>
{errorMsg && (
<div className="glass-card" style={{ borderLeft: '4px solid var(--accent-rose)', backgroundColor: 'rgba(244, 63, 94, 0.05)', padding: '12px 16px', display: 'flex', gap: '10px', alignItems: 'center', marginBottom: '20px' }}>
<AlertCircle size={18} color="var(--accent-rose)" />
<span style={{ fontSize: '0.86rem', color: '#FDA4AF' }}>{errorMsg}</span>
</div>
)}
{successMsg && (
<div className="glass-card" style={{ borderLeft: '4px solid var(--accent-emerald)', backgroundColor: 'rgba(16, 185, 129, 0.05)', padding: '12px 16px', display: 'flex', gap: '10px', alignItems: 'center', marginBottom: '20px' }}>
<ShieldCheck size={18} color="var(--accent-emerald)" />
<span style={{ fontSize: '0.86rem', color: '#A7F3D0' }}>{successMsg}</span>
</div>
)}
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{/* Section 1: Storage Location */}
<div className="glass-card" style={{ padding: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
<Folder size={20} color="var(--accent-cyan)" />
<h4 style={{ fontSize: '1rem', fontWeight: 600 }}>1. Ubicación de Almacenamiento</h4>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Directorio Raíz de Backups (Ruta Local o NAS Montado):</label>
<input
type="text"
className="form-input"
required
placeholder="Ej: C:\backups o /mnt/nas/backups"
value={formData.storage_root}
onChange={(e) => setFormData({ ...formData, storage_root: e.target.value })}
/>
<p style={{ fontSize: '0.78rem', color: 'var(--text-dim)', marginTop: '6px', lineHeight: '1.4' }}>
Define la ruta donde el motor de streaming ensamblará y almacenará de forma aislada los archivos de cada cliente.
Asegúrate de que el servicio del backend tenga privilegios de lectura y escritura en este directorio.
</p>
</div>
</div>
{/* Section 2: Quotas */}
<div className="glass-card" style={{ padding: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
<HardDrive size={20} color="var(--accent-cyan)" />
<h4 style={{ fontSize: '1rem', fontWeight: 600 }}>2. Límites de Capacidad y Cuotas</h4>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div className="form-group">
<label>Cuota de Disco Global del Sistema (GB):</label>
<input
type="number"
min={1}
className="form-input"
required
value={formData.global_quota_gb}
onChange={(e) => setFormData({ ...formData, global_quota_gb: Number(e.target.value) })}
/>
<span style={{ fontSize: '0.74rem', color: 'var(--text-dim)' }}>
Equivale a: {formatBytes(formData.global_quota_gb * 1024 * 1024 * 1024)}. Límite máximo del sistema.
</span>
</div>
<div className="form-group">
<label>Cuota Inicial por Defecto para Clientes (GB):</label>
<input
type="number"
min={1}
className="form-input"
required
value={formData.default_client_quota_gb}
onChange={(e) => setFormData({ ...formData, default_client_quota_gb: Number(e.target.value) })}
/>
<span style={{ fontSize: '0.74rem', color: 'var(--text-dim)' }}>
Se asigna automáticamente a los nuevos agentes al registrarse.
</span>
</div>
</div>
</div>
{/* Section 3: Retention Defaults */}
<div className="glass-card" style={{ padding: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
<Calendar size={20} color="var(--accent-cyan)" />
<h4 style={{ fontSize: '1rem', fontWeight: 600 }}>3. Políticas de Retención por Defecto</h4>
</div>
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '16px', lineHeight: '1.4' }}>
Establece los tiempos de retención predeterminados que se cargarán al crear nuevos trabajos de backup en el dashboard.
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '16px' }}>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Copias Diarias a Mantener:</label>
<input
type="number"
min={1}
className="form-input"
required
value={formData.default_keep_daily}
onChange={(e) => setFormData({ ...formData, default_keep_daily: Number(e.target.value) })}
/>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Copias Semanales a Mantener:</label>
<input
type="number"
min={1}
className="form-input"
required
value={formData.default_keep_weekly}
onChange={(e) => setFormData({ ...formData, default_keep_weekly: Number(e.target.value) })}
/>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Copias Mensuales a Mantener:</label>
<input
type="number"
min={1}
className="form-input"
required
value={formData.default_keep_monthly}
onChange={(e) => setFormData({ ...formData, default_keep_monthly: Number(e.target.value) })}
/>
</div>
</div>
</div>
{/* Form Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '10px' }}>
<button type="submit" className="btn btn-primary" style={{ padding: '10px 24px' }} disabled={saving}>
{saving ? (
<>
<Loader2 className="animate-spin" size={16} />
Guardando...
</>
) : (
<>
<Save size={16} />
Guardar Configuraciones
</>
)}
</button>
</div>
</form>
</div>
);
};
+42
View File
@@ -22,6 +22,7 @@ export interface ClientItem {
id: number;
client_code: string;
name: string;
alias?: string;
hostname?: string;
os_info?: string;
ip_address?: string;
@@ -80,6 +81,15 @@ export interface EventLogItem {
message: string;
}
export interface SystemSettingsResponse {
storage_root: string;
global_quota_gb: number;
default_client_quota_gb: number;
default_keep_daily: number;
default_keep_weekly: number;
default_keep_monthly: number;
}
export const getAuthToken = (): string | null => {
return localStorage.getItem('oed_token');
};
@@ -156,8 +166,21 @@ export const api = {
}),
revokeClient: (clientId: number) =>
request<{ message: string }>(`/clients/${clientId}/revoke`, { method: 'POST' }),
generateReRegisterCode: (clientId: number) =>
request<{ code: string; expires_at: string; client_id: number }>(`/clients/${clientId}/re-register`, { method: 'POST' }),
deleteClient: (clientId: number) =>
request<{ message: string }>(`/clients/${clientId}`, { method: 'DELETE' }),
updateClient: (clientId: number, data: { alias?: string; storage_quota_bytes?: number }) =>
request<ClientItem>(`/clients/${clientId}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
getSettings: () => request<SystemSettingsResponse>('/settings'),
updateSettings: (data: Partial<SystemSettingsResponse>) =>
request<SystemSettingsResponse>('/settings', {
method: 'PUT',
body: JSON.stringify(data),
}),
// Jobs
getJobs: (clientId?: number) =>
@@ -189,6 +212,25 @@ export const api = {
if (jobId) params.append('job_id', jobId.toString());
return request<BackupFileItem[]>(`/backups?${params.toString()}`);
},
downloadBackup: async (backup: BackupFileItem) => {
const token = getAuthToken();
const response = await fetch(`${API_BASE}/backups/${backup.id}/download`, {
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: 'Network error' }));
throw new Error(errorData.detail || `Download failed with status ${response.status}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = backup.filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
},
deleteBackup: (backupId: number) =>
request<{ message: string }>(`/backups/${backupId}`, { method: 'DELETE' }),