se agregaron nuevas caracteristicas
This commit is contained in:
Generated
+7
@@ -52,6 +52,7 @@
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@@ -1227,6 +1228,7 @@
|
||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -1237,6 +1239,7 @@
|
||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1305,6 +1308,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.11.12",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
@@ -1575,6 +1579,7 @@
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -1616,6 +1621,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -1789,6 +1795,7 @@
|
||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
||||
+16
-2
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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' }),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user