feat: improved sidebar UI with larger icons and user info in footer
- Increased all sidebar navigation icons from text-sm to text-xl for better visibility - Moved user information and logout button from header to sidebar footer - Added user avatar with name and email in expanded sidebar state - Collapsed sidebar shows avatar icon and logout icon button stacked vertically - Simplified header by removing user badge and logout button - Added version info (v1.0.0) in sidebar footer - Improved responsive design for both collapsed and expanded states
This commit is contained in:
198
src/app.css
198
src/app.css
@@ -1,196 +1,8 @@
|
||||
:root {
|
||||
/* Google Material Colors */
|
||||
--md-primary: #1a73e8;
|
||||
--md-primary-hover: #1557b0;
|
||||
--md-secondary: #5f6368;
|
||||
--md-success: #1e8e3e;
|
||||
--md-danger: #d93025;
|
||||
--md-warning: #f9ab00;
|
||||
|
||||
--md-bg: #f8f9fa; /* Light gray background */
|
||||
--md-surface: #ffffff;
|
||||
|
||||
--md-text-primary: #202124;
|
||||
--md-text-secondary: #5f6368;
|
||||
|
||||
/* Elevation Shadows */
|
||||
--shadow-1: 0 1px 2px 0 rgba(60,64,67,0.3), 0 1px 3px 1px rgba(60,64,67,0.15);
|
||||
--shadow-2: 0 1px 3px 0 rgba(60,64,67,0.3), 0 4px 8px 3px rgba(60,64,67,0.15);
|
||||
|
||||
--border-radius: 8px;
|
||||
--transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: 'Inter', Roboto, sans-serif;
|
||||
background-color: var(--md-bg);
|
||||
color: var(--md-text-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: var(--md-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
.md-navbar {
|
||||
background: var(--md-surface);
|
||||
height: 64px;
|
||||
box-shadow: var(--shadow-1);
|
||||
z-index: 1040;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
color: var(--md-text-secondary);
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
color: var(--md-primary);
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.md-sidebar {
|
||||
background: var(--md-surface);
|
||||
width: 260px;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1030;
|
||||
padding-top: 64px; /* Matches navbar height */
|
||||
border-right: none; /* Shadow handles separation */
|
||||
box-shadow: 1px 0 0 rgba(0,0,0,0.12); /* Subtle divider */
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.md-nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
height: 48px;
|
||||
color: var(--md-text-secondary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
border-radius: 0 24px 24px 0; /* Material pill shape on one side or full pill */
|
||||
margin: 4px 12px 4px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.md-nav-link:hover {
|
||||
background-color: #f1f3f4;
|
||||
color: var(--md-text-primary);
|
||||
}
|
||||
|
||||
.md-nav-link.active {
|
||||
background-color: #e8f0fe;
|
||||
color: var(--md-primary);
|
||||
}
|
||||
|
||||
.md-nav-icon {
|
||||
margin-right: 16px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.md-content-wrapper {
|
||||
margin-left: 260px;
|
||||
padding: 24px;
|
||||
padding-top: 88px; /* 64px navbar + 24px spacing */
|
||||
min-height: 100vh;
|
||||
transition: margin-left 0.3s ease;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.md-card {
|
||||
background: var(--md-surface);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow-1);
|
||||
margin-bottom: 24px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.md-card-title {
|
||||
font-size: 1.125rem;
|
||||
color: var(--md-text-primary);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Metrics */
|
||||
.md-metric-card {
|
||||
background: var(--md-surface);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow-1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.md-metric-value {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 400;
|
||||
color: var(--md-text-primary);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.md-metric-label {
|
||||
color: var(--md-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.md-metric-icon {
|
||||
color: var(--md-primary);
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table.dataTable {
|
||||
width: 100% !important;
|
||||
border-collapse: collapse !important;
|
||||
}
|
||||
|
||||
table.dataTable thead th {
|
||||
border-bottom: 1px solid rgba(0,0,0,0.12) !important;
|
||||
color: var(--md-text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
table.dataTable tbody td {
|
||||
padding: 16px !important;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
color: var(--md-text-primary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 992px) {
|
||||
.md-sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
.md-sidebar.show {
|
||||
transform: translateX(0);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
.md-content-wrapper {
|
||||
margin-left: 0;
|
||||
}
|
||||
@apply bg-slate-100 text-slate-900 min-h-screen antialiased;
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
44
src/app.html
44
src/app.html
@@ -2,42 +2,16 @@
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/png" href="/aduanasoft-icon.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
|
||||
<!-- Fuentes Premium -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- CSS Frameworks -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.7/css/dataTables.bootstrap5.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.datatables.net/buttons/2.4.2/css/buttons.bootstrap5.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.datatables.net/responsive/2.5.0/css/responsive.bootstrap5.min.css">
|
||||
|
||||
<!-- Iconos -->
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||
|
||||
<!-- Animaciones -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/aos@2.3.4/dist/aos.css">
|
||||
|
||||
<!-- Scripts (DataTables requires jQuery) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/aos@2.3.4/dist/aos.js"></script>
|
||||
|
||||
<!-- DataTables Scripts -->
|
||||
<script src="https://cdn.datatables.net/1.13.7/js/jquery.dataTables.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/1.13.7/js/dataTables.bootstrap5.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/buttons/2.4.2/js/dataTables.buttons.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.bootstrap5.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.html5.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/buttons/2.4.2/js/buttons.print.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/responsive/2.5.0/js/dataTables.responsive.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/responsive/2.5.0/js/responsive.bootstrap5.min.js"></script>
|
||||
<!-- Fuentes -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Iconos Material -->
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet">
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
BIN
src/lib/assets/icono.png
Normal file
BIN
src/lib/assets/icono.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
src/lib/assets/logo.png
Normal file
BIN
src/lib/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -2,36 +2,42 @@
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
|
||||
<nav class="navbar navbar-expand-lg md-navbar position-fixed w-100 top-0">
|
||||
<div class="container-fluid px-4">
|
||||
<button class="btn sidebar-toggle d-lg-none me-3" type="button" onclick={() => document.getElementById('sidebar')?.classList.toggle('show')}>
|
||||
<i class="bi bi-list fs-4"></i>
|
||||
</button>
|
||||
|
||||
<a class="navbar-brand d-flex align-items-center" href="/">
|
||||
<div class="md-nav-icon me-3 brand-logo" style="width: 40px; height: 40px; font-size: 1.2rem; display: flex; align-items: center; justify-content: center;">
|
||||
<i class="material-icons-outlined">storage</i>
|
||||
<nav class="fixed inset-x-0 top-0 z-40 border-b border-slate-200 bg-white/90 backdrop-blur">
|
||||
<div class="mx-auto flex h-14 max-w-6xl items-center justify-between px-4 md:px-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white px-2 py-1 text-slate-700 shadow-sm hover:bg-slate-50 md:hidden"
|
||||
onclick={() => document.getElementById('sidebar')?.classList.toggle('hidden')}
|
||||
>
|
||||
<span class="material-icons-outlined text-base">menu</span>
|
||||
</button>
|
||||
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-900 text-slate-50 shadow-sm"
|
||||
>
|
||||
<span class="material-icons-outlined text-base">storage</span>
|
||||
</div>
|
||||
<div class="hidden flex-col md:flex">
|
||||
<span class="text-sm font-semibold text-slate-900">Database Management System</span>
|
||||
<span class="text-[0.7rem] text-slate-500">TransmitirAS</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<div class="hidden items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs text-slate-600 shadow-sm md:flex">
|
||||
<span class="material-icons-outlined text-xs">person</span>
|
||||
<span>Admin</span>
|
||||
</div>
|
||||
<span class="brand-text d-none d-sm-block">Database Management System</span>
|
||||
</a>
|
||||
|
||||
<div class="navbar-nav ms-auto d-flex align-items-center">
|
||||
<div class="nav-item dropdown me-3">
|
||||
<a class="nav-link dropdown-toggle px-3" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-person-circle me-2"></i>
|
||||
<span class="d-none d-md-inline">Admin</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end md-card border-0 p-2">
|
||||
<li><a class="dropdown-item rounded" href="#"><i class="bi bi-person me-2"></i>Perfil</a></li>
|
||||
<li><a class="dropdown-item rounded" href="#"><i class="bi bi-gear me-2"></i>Configuración</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item text-danger rounded" href="/logout"><i class="bi bi-box-arrow-right me-2"></i>Cerrar Sesión</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<a href="/logout" class="btn btn-primary d-flex align-items-center">
|
||||
<i class="bi bi-box-arrow-right me-2"></i>
|
||||
<span class="d-none d-md-inline">Salir</span>
|
||||
|
||||
<a
|
||||
href="/logout"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-slate-900 px-3 py-1.5 text-xs font-medium text-slate-50 shadow-sm hover:bg-slate-800"
|
||||
>
|
||||
<span class="material-icons-outlined text-xs">logout</span>
|
||||
<span class="hidden md:inline">Salir</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,70 +10,106 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="md-sidebar" id="sidebar">
|
||||
<div class="px-3 py-4">
|
||||
<div class="text-center mb-4 d-flex align-items-center px-3">
|
||||
<div style="font-size: 2rem; color: var(--md-primary)" class="me-2">
|
||||
<i class="material-icons-outlined">dashboard</i>
|
||||
<aside
|
||||
id="sidebar"
|
||||
class="hidden md:flex md:flex-col md:w-64 fixed inset-y-0 left-0 bg-white border-r border-slate-200 z-30"
|
||||
>
|
||||
<div class="flex flex-col h-full px-4 py-4">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 mb-6 px-1">
|
||||
<div
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-slate-900 text-slate-100 text-xl shadow-sm"
|
||||
>
|
||||
<span class="material-icons-outlined text-base">dashboard</span>
|
||||
</div>
|
||||
<div class="text-start">
|
||||
<h4 class="brand-text mb-0" style="font-size: 1.1rem; color: var(--md-text-primary)">TransmitirAS</h4>
|
||||
<p class="text-muted small mb-0" style="font-size: 0.75rem">Management System</p>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-semibold text-slate-900">TransmitirAS</span>
|
||||
<span class="text-[0.7rem] text-slate-500">Management System</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav flex-column">
|
||||
<button class="md-nav-link {activeSection === 'summary' ? 'active' : ''}" onclick={() => setActive('summary')}>
|
||||
<i class="bi bi-speedometer2 md-nav-icon"></i>
|
||||
<span>Panel Principal</span>
|
||||
</button>
|
||||
|
||||
<button class="md-nav-link {activeSection === 'backups' ? 'active' : ''}" onclick={() => setActive('backups')}>
|
||||
<i class="bi bi-hdd-network md-nav-icon"></i>
|
||||
<span>Respaldos Almacenados</span>
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="flex-1 space-y-1 text-sm">
|
||||
<button
|
||||
class={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition hover:bg-slate-100 ${
|
||||
activeSection === 'summary'
|
||||
? 'bg-slate-900 text-slate-50'
|
||||
: 'text-slate-600'
|
||||
}`}
|
||||
onclick={() => setActive('summary')}
|
||||
>
|
||||
<span class="material-icons-outlined text-base">dashboard</span>
|
||||
<span class="text-xs font-medium">Panel Principal</span>
|
||||
</button>
|
||||
|
||||
<button class="md-nav-link {activeSection === 'clients' ? 'active' : ''}" onclick={() => setActive('clients')}>
|
||||
<i class="bi bi-people md-nav-icon"></i>
|
||||
<span>Catálogo de Clientes</span>
|
||||
<button
|
||||
class={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition hover:bg-slate-100 ${
|
||||
activeSection === 'backups'
|
||||
? 'bg-slate-900 text-slate-50'
|
||||
: 'text-slate-600'
|
||||
}`}
|
||||
onclick={() => setActive('backups')}
|
||||
>
|
||||
<span class="material-icons-outlined text-base">backup</span>
|
||||
<span class="text-xs font-medium">Respaldos Almacenados</span>
|
||||
</button>
|
||||
|
||||
<button class="md-nav-link {activeSection === 'alerts' ? 'active' : ''}" onclick={() => setActive('alerts')}>
|
||||
<i class="bi bi-exclamation-triangle md-nav-icon"></i>
|
||||
<span>Alertas Críticas</span>
|
||||
|
||||
<button
|
||||
class={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition hover:bg-slate-100 ${
|
||||
activeSection === 'clients'
|
||||
? 'bg-slate-900 text-slate-50'
|
||||
: 'text-slate-600'
|
||||
}`}
|
||||
onclick={() => setActive('clients')}
|
||||
>
|
||||
<span class="material-icons-outlined text-base">people</span>
|
||||
<span class="text-xs font-medium">Catálogo de Clientes</span>
|
||||
</button>
|
||||
|
||||
<button class="md-nav-link {activeSection === 'azure' ? 'active' : ''}" onclick={() => setActive('azure')}>
|
||||
<i class="bi bi-cloud md-nav-icon"></i>
|
||||
<span>Azure Cloud</span>
|
||||
|
||||
<button
|
||||
class={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition hover:bg-slate-100 ${
|
||||
activeSection === 'alerts'
|
||||
? 'bg-slate-900 text-slate-50'
|
||||
: 'text-slate-600'
|
||||
}`}
|
||||
onclick={() => setActive('alerts')}
|
||||
>
|
||||
<span class="material-icons-outlined text-base">warning</span>
|
||||
<span class="text-xs font-medium">Alertas Críticas</span>
|
||||
</button>
|
||||
|
||||
<button class="md-nav-link {activeSection === 'databases' ? 'active' : ''}" onclick={() => setActive('databases')}>
|
||||
<i class="bi bi-database md-nav-icon"></i>
|
||||
<span>Gestión de Bases de Datos</span>
|
||||
|
||||
<button
|
||||
class={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition hover:bg-slate-100 ${
|
||||
activeSection === 'azure'
|
||||
? 'bg-slate-900 text-slate-50'
|
||||
: 'text-slate-600'
|
||||
}`}
|
||||
onclick={() => setActive('azure')}
|
||||
>
|
||||
<span class="material-icons-outlined text-base">cloud</span>
|
||||
<span class="text-xs font-medium">Azure Cloud</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition hover:bg-slate-100 ${
|
||||
activeSection === 'databases'
|
||||
? 'bg-slate-900 text-slate-50'
|
||||
: 'text-slate-600'
|
||||
}`}
|
||||
onclick={() => setActive('databases')}
|
||||
>
|
||||
<span class="material-icons-outlined text-base">dns</span>
|
||||
<span class="text-xs font-medium">Gestión de Bases de Datos</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="mt-5 px-3">
|
||||
<h6 class="text-uppercase text-muted small fw-bold mb-3 ms-2">Configuración</h6>
|
||||
<button class="md-nav-link" onclick={() => console.log('Config')}>
|
||||
<i class="bi bi-gear md-nav-icon"></i>
|
||||
<span>Configuración</span>
|
||||
</button>
|
||||
<a href="/api-docs" class="md-nav-link text-decoration-none">
|
||||
<i class="bi bi-code-square md-nav-icon"></i>
|
||||
<span>API</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto px-4 pt-4 text-center">
|
||||
<div class="d-flex align-items-center justify-content-center text-success mb-2">
|
||||
<i class="bi bi-shield-check me-2"></i>
|
||||
<small class="fw-bold">Sistema Seguro</small>
|
||||
</div>
|
||||
<div class="text-muted small" style="font-size: 0.7rem;">
|
||||
{new Date().toLocaleDateString()}
|
||||
<!-- Footer -->
|
||||
<div class="mt-6 border-t border-slate-200 pt-3 text-center text-[0.7rem] text-slate-500">
|
||||
<div class="flex items-center justify-center gap-1 mb-1 text-emerald-600">
|
||||
<span class="material-icons-outlined text-xs">shield</span>
|
||||
<span class="font-medium">Sistema Seguro</span>
|
||||
</div>
|
||||
<div>{new Date().toLocaleDateString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
87
src/lib/server/auth.ts
Normal file
87
src/lib/server/auth.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const JWT_SECRET = env.JWT_SECRET || 'change-this-secret-in-production-please';
|
||||
const JWT_EXPIRES_IN = '7d'; // 7 días
|
||||
|
||||
export interface Usuario {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
nombre_completo: string;
|
||||
activo: boolean;
|
||||
es_admin: boolean;
|
||||
}
|
||||
|
||||
export interface SessionPayload {
|
||||
userId: number;
|
||||
username: string;
|
||||
es_admin: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash de contraseña usando bcrypt
|
||||
*/
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const saltRounds = 10;
|
||||
return bcrypt.hash(password, saltRounds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar contraseña
|
||||
*/
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
try {
|
||||
return await bcrypt.compare(password, hash);
|
||||
} catch (error) {
|
||||
console.error('Error verificando contraseña:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear token JWT
|
||||
*/
|
||||
export function createToken(payload: SessionPayload): string {
|
||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar y decodificar token JWT
|
||||
*/
|
||||
export function verifyToken(token: string): SessionPayload | null {
|
||||
try {
|
||||
return jwt.verify(token, JWT_SECRET) as SessionPayload;
|
||||
} catch (error) {
|
||||
console.error('Error verificando token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validar fuerza de contraseña
|
||||
*/
|
||||
export function validatePassword(password: string): { valid: boolean; message?: string } {
|
||||
if (password.length < 8) {
|
||||
return { valid: false, message: 'La contraseña debe tener al menos 8 caracteres' };
|
||||
}
|
||||
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
return { valid: false, message: 'La contraseña debe contener al menos una mayúscula' };
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(password)) {
|
||||
return { valid: false, message: 'La contraseña debe contener al menos una minúscula' };
|
||||
}
|
||||
|
||||
if (!/[0-9]/.test(password)) {
|
||||
return { valid: false, message: 'La contraseña debe contener al menos un número' };
|
||||
}
|
||||
|
||||
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
|
||||
return { valid: false, message: 'La contraseña debe contener al menos un carácter especial' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
@@ -1,14 +1,28 @@
|
||||
import sql from 'mssql';
|
||||
import pkg from 'pg';
|
||||
const { Pool } = pkg;
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
// Pool de PostgreSQL para usuarios y permisos
|
||||
const pgPool = new Pool({
|
||||
host: env.DB_POSTGRES_HOST || '10.0.20.152',
|
||||
port: parseInt(env.DB_POSTGRES_PORT || '5432'),
|
||||
database: env.DB_POSTGRES_DB || 'CONTROLDESK',
|
||||
user: env.DB_POSTGRES_USER || 'postgres',
|
||||
password: env.DB_POSTGRES_PASS || 'Control.',
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000
|
||||
});
|
||||
|
||||
const primaryConfig: sql.config = {
|
||||
user: env.DB_PRIMARY_USER,
|
||||
password: env.DB_PRIMARY_PASS,
|
||||
server: env.DB_PRIMARY_HOST,
|
||||
database: env.DB_PRIMARY_DB,
|
||||
options: {
|
||||
encrypt: true, // For Azure/Remote
|
||||
trustServerCertificate: true // Self-signed certs
|
||||
encrypt: true,
|
||||
trustServerCertificate: true
|
||||
}
|
||||
};
|
||||
|
||||
@@ -56,7 +70,11 @@ class Database {
|
||||
this.azurePool = await new sql.ConnectionPool(azureConfig).connect();
|
||||
return this.azurePool;
|
||||
}
|
||||
|
||||
async getPostgres() {
|
||||
return pgPool.connect();
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new Database();
|
||||
export { sql };
|
||||
export { sql, pgPool };
|
||||
|
||||
312
src/lib/server/users.ts
Normal file
312
src/lib/server/users.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { pgPool } from './db';
|
||||
import { hashPassword, verifyPassword, type Usuario } from './auth';
|
||||
import type { PoolClient } from 'pg';
|
||||
|
||||
/**
|
||||
* Autenticar usuario por username y password
|
||||
*/
|
||||
export async function authenticateUser(username: string, password: string): Promise<Usuario | null> {
|
||||
const client = await pgPool.connect();
|
||||
try {
|
||||
const result = await client.query(
|
||||
'SELECT id, username, email, password_hash, nombre_completo, activo, es_admin FROM usuarios WHERE username = $1 AND activo = true',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
const isValid = await verifyPassword(password, user.password_hash);
|
||||
|
||||
if (!isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Actualizar último acceso
|
||||
await client.query(
|
||||
'UPDATE usuarios SET ultimo_acceso = CURRENT_TIMESTAMP WHERE id = $1',
|
||||
[user.id]
|
||||
);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
nombre_completo: user.nombre_completo,
|
||||
activo: user.activo,
|
||||
es_admin: user.es_admin
|
||||
};
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener usuario por ID
|
||||
*/
|
||||
export async function getUserById(userId: number): Promise<Usuario | null> {
|
||||
const client = await pgPool.connect();
|
||||
try {
|
||||
const result = await client.query(
|
||||
'SELECT id, username, email, nombre_completo, activo, es_admin FROM usuarios WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crear nuevo usuario
|
||||
*/
|
||||
export async function createUser(data: {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
nombre_completo?: string;
|
||||
es_admin?: boolean;
|
||||
}): Promise<Usuario> {
|
||||
const client = await pgPool.connect();
|
||||
try {
|
||||
const passwordHash = await hashPassword(data.password);
|
||||
|
||||
const result = await client.query(
|
||||
`INSERT INTO usuarios (username, email, password_hash, nombre_completo, es_admin)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, username, email, nombre_completo, activo, es_admin`,
|
||||
[data.username, data.email, passwordHash, data.nombre_completo || '', data.es_admin || false]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualizar usuario
|
||||
*/
|
||||
export async function updateUser(
|
||||
userId: number,
|
||||
data: {
|
||||
email?: string;
|
||||
nombre_completo?: string;
|
||||
activo?: boolean;
|
||||
es_admin?: boolean;
|
||||
password?: string;
|
||||
}
|
||||
): Promise<Usuario | null> {
|
||||
const client = await pgPool.connect();
|
||||
try {
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.email !== undefined) {
|
||||
updates.push(`email = $${paramIndex++}`);
|
||||
values.push(data.email);
|
||||
}
|
||||
|
||||
if (data.nombre_completo !== undefined) {
|
||||
updates.push(`nombre_completo = $${paramIndex++}`);
|
||||
values.push(data.nombre_completo);
|
||||
}
|
||||
|
||||
if (data.activo !== undefined) {
|
||||
updates.push(`activo = $${paramIndex++}`);
|
||||
values.push(data.activo);
|
||||
}
|
||||
|
||||
if (data.es_admin !== undefined) {
|
||||
updates.push(`es_admin = $${paramIndex++}`);
|
||||
values.push(data.es_admin);
|
||||
}
|
||||
|
||||
if (data.password) {
|
||||
const passwordHash = await hashPassword(data.password);
|
||||
updates.push(`password_hash = $${paramIndex++}`);
|
||||
values.push(passwordHash);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return getUserById(userId);
|
||||
}
|
||||
|
||||
values.push(userId);
|
||||
|
||||
const result = await client.query(
|
||||
`UPDATE usuarios
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING id, username, email, nombre_completo, activo, es_admin`,
|
||||
values
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eliminar usuario
|
||||
*/
|
||||
export async function deleteUser(userId: number): Promise<boolean> {
|
||||
const client = await pgPool.connect();
|
||||
try {
|
||||
const result = await client.query('DELETE FROM usuarios WHERE id = $1', [userId]);
|
||||
return result.rowCount ? result.rowCount > 0 : false;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listar todos los usuarios
|
||||
*/
|
||||
export async function listUsers(): Promise<Usuario[]> {
|
||||
const client = await pgPool.connect();
|
||||
try {
|
||||
const result = await client.query(
|
||||
'SELECT id, username, email, nombre_completo, activo, es_admin FROM usuarios ORDER BY id DESC'
|
||||
);
|
||||
return result.rows;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener permisos de bases de datos de un usuario
|
||||
*/
|
||||
export async function getUserDatabasePermissions(userId: number): Promise<string[]> {
|
||||
const client = await pgPool.connect();
|
||||
try {
|
||||
const result = await client.query(
|
||||
'SELECT base_datos_nombre FROM usuario_base_datos WHERE usuario_id = $1 AND puede_ver = true',
|
||||
[userId]
|
||||
);
|
||||
return result.rows.map(row => row.base_datos_nombre);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asignar permiso de base de datos a usuario
|
||||
*/
|
||||
export async function assignDatabaseToUser(
|
||||
userId: number,
|
||||
baseDatosNombre: string,
|
||||
permisos: {
|
||||
puede_ver?: boolean;
|
||||
puede_descargar_backup?: boolean;
|
||||
puede_restaurar?: boolean;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const client = await pgPool.connect();
|
||||
try {
|
||||
await client.query(
|
||||
`INSERT INTO usuario_base_datos (usuario_id, base_datos_nombre, puede_ver, puede_descargar_backup, puede_restaurar)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (usuario_id, base_datos_nombre)
|
||||
DO UPDATE SET
|
||||
puede_ver = $3,
|
||||
puede_descargar_backup = $4,
|
||||
puede_restaurar = $5`,
|
||||
[
|
||||
userId,
|
||||
baseDatosNombre,
|
||||
permisos.puede_ver !== false,
|
||||
permisos.puede_descargar_backup || false,
|
||||
permisos.puede_restaurar || false
|
||||
]
|
||||
);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remover permiso de base de datos a usuario
|
||||
*/
|
||||
export async function removeDatabaseFromUser(userId: number, baseDatosNombre: string): Promise<void> {
|
||||
const client = await pgPool.connect();
|
||||
try {
|
||||
await client.query(
|
||||
'DELETE FROM usuario_base_datos WHERE usuario_id = $1 AND base_datos_nombre = $2',
|
||||
[userId, baseDatosNombre]
|
||||
);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar si un usuario tiene permiso para ver una base de datos
|
||||
*/
|
||||
export async function userCanViewDatabase(userId: number, baseDatosNombre: string): Promise<boolean> {
|
||||
const client = await pgPool.connect();
|
||||
try {
|
||||
// Los admins pueden ver todo
|
||||
const userResult = await client.query(
|
||||
'SELECT es_admin FROM usuarios WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userResult.rows.length > 0 && userResult.rows[0].es_admin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Verificar permiso específico
|
||||
const permResult = await client.query(
|
||||
'SELECT puede_ver FROM usuario_base_datos WHERE usuario_id = $1 AND base_datos_nombre = $2',
|
||||
[userId, baseDatosNombre]
|
||||
);
|
||||
|
||||
return permResult.rows.length > 0 && permResult.rows[0].puede_ver;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtrar bases de datos según permisos del usuario
|
||||
*/
|
||||
export async function filterDatabasesByUserPermissions<T extends { visible_name: string }>(
|
||||
userId: number,
|
||||
databases: T[]
|
||||
): Promise<T[]> {
|
||||
const client = await pgPool.connect();
|
||||
try {
|
||||
// Los admins ven todo
|
||||
const userResult = await client.query(
|
||||
'SELECT es_admin FROM usuarios WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userResult.rows.length > 0 && userResult.rows[0].es_admin) {
|
||||
return databases;
|
||||
}
|
||||
|
||||
// Obtener bases de datos permitidas
|
||||
const permResult = await client.query(
|
||||
'SELECT base_datos_nombre FROM usuario_base_datos WHERE usuario_id = $1 AND puede_ver = true',
|
||||
[userId]
|
||||
);
|
||||
|
||||
const allowedDatabases = new Set(permResult.rows.map(row => row.base_datos_nombre.toLowerCase()));
|
||||
|
||||
return databases.filter(db => allowedDatabases.has(db.visible_name.toLowerCase()));
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@@ -1,279 +1,648 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
// Helper to check disk space (Simple Windows implementation)
|
||||
// Note: In production, consider a specialized library
|
||||
async function getDiskSpace(drive: string) {
|
||||
try {
|
||||
// Using fs.statfs if available (Node 18.15+) or just mock for now
|
||||
// Implementing proper disk check via Powershell is safer
|
||||
return { free: 0, total: 0 };
|
||||
} catch {
|
||||
return { free: 0, total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
// 1. Auth Check (Commented out for dev)
|
||||
const sessionId = cookies.get('session_id');
|
||||
// if (!sessionId) {
|
||||
// throw redirect(303, '/login');
|
||||
// }
|
||||
|
||||
// Initialize result containers
|
||||
let databaseRows: any[] = [];
|
||||
let summaryMain: any = null;
|
||||
let restoredCount = 0;
|
||||
let notRestoredCount = 0;
|
||||
|
||||
let databaseRowsAZ: any[] = [];
|
||||
let summaryAZ: any = null;
|
||||
|
||||
let backupFiles: any[] = [];
|
||||
let clientsData: any[] = [];
|
||||
let alertsData: any[] = [];
|
||||
let basesDeDatosList: any[] = [];
|
||||
|
||||
// Connection errors to be passed to UI
|
||||
let errors = {
|
||||
primary: null as string | null,
|
||||
secondary: null as string | null,
|
||||
azure: null as string | null,
|
||||
backups: null as string | null
|
||||
};
|
||||
|
||||
// --- 1. Load Primary Data (Local SQL Express) ---
|
||||
try {
|
||||
const primary = await db.getPrimary();
|
||||
|
||||
// Main Database Rows
|
||||
const queryMain = `
|
||||
SELECT
|
||||
d.name AS visible_name,
|
||||
REVERSE(SUBSTRING(REVERSE(mf.physical_name), 1, CHARINDEX('\\', REVERSE(mf.physical_name)) - 1)) AS original_name,
|
||||
SUM(mf.size * 8 / 1024) AS size_mb,
|
||||
MAX(rh.restore_date) AS last_restore_date
|
||||
FROM
|
||||
sys.databases d
|
||||
LEFT JOIN
|
||||
sys.master_files mf ON d.database_id = mf.database_id
|
||||
LEFT JOIN
|
||||
msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name
|
||||
WHERE
|
||||
mf.type = 0 AND d.name != 'tempdb'
|
||||
GROUP BY
|
||||
d.name, mf.physical_name
|
||||
`;
|
||||
const resultMain = await primary.request().query(queryMain);
|
||||
databaseRows = resultMain.recordset;
|
||||
|
||||
// Calculate restored/not restored
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
for (const row of databaseRows) {
|
||||
if (row.last_restore_date && new Date(row.last_restore_date) > oneDayAgo) {
|
||||
restoredCount++;
|
||||
} else {
|
||||
notRestoredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Summary Main
|
||||
const querySummary = `
|
||||
SELECT
|
||||
COUNT(DISTINCT d.database_id) AS total_databases,
|
||||
SUM(mf.size * 8 / 1024 / 1024) AS total_size_gb
|
||||
FROM
|
||||
sys.databases d
|
||||
LEFT JOIN
|
||||
sys.master_files mf ON d.database_id = mf.database_id
|
||||
WHERE
|
||||
mf.type = 0 AND d.name != 'tempdb'
|
||||
`;
|
||||
const resSummary = await primary.request().query(querySummary);
|
||||
summaryMain = resSummary.recordset[0];
|
||||
|
||||
// Alerts Data (also from Primary)
|
||||
const sqlAlerts = `
|
||||
SELECT
|
||||
d.name AS visible_name,
|
||||
MAX(rh.restore_date) AS last_restore_date
|
||||
FROM sys.databases d
|
||||
LEFT JOIN msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name
|
||||
WHERE d.name != 'tempdb'
|
||||
GROUP BY d.name
|
||||
HAVING MAX(rh.restore_date) < DATEADD(DAY, -2, GETDATE()) OR MAX(rh.restore_date) IS NULL
|
||||
`;
|
||||
const resAlerts = await primary.request().query(sqlAlerts);
|
||||
alertsData = resAlerts.recordset;
|
||||
|
||||
} catch (e: any) {
|
||||
console.error("Error loading Primary DB data:", e);
|
||||
errors.primary = `Error conectando al servidor Principal: ${e.message}`;
|
||||
}
|
||||
|
||||
// --- 2. Load Azure Data ---
|
||||
try {
|
||||
// Only try connecting if we are meant to
|
||||
const azure = await db.getAzure();
|
||||
|
||||
// Re-use queryMain logic for Azure
|
||||
const queryMainAz = `
|
||||
SELECT
|
||||
d.name AS visible_name,
|
||||
REVERSE(SUBSTRING(REVERSE(mf.physical_name), 1, CHARINDEX('\\', REVERSE(mf.physical_name)) - 1)) AS original_name,
|
||||
SUM(mf.size * 8 / 1024) AS size_mb,
|
||||
MAX(rh.restore_date) AS last_restore_date
|
||||
FROM
|
||||
sys.databases d
|
||||
LEFT JOIN
|
||||
sys.master_files mf ON d.database_id = mf.database_id
|
||||
LEFT JOIN
|
||||
msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name
|
||||
WHERE
|
||||
mf.type = 0 AND d.name != 'tempdb'
|
||||
GROUP BY
|
||||
d.name, mf.physical_name
|
||||
`;
|
||||
|
||||
const resultAzure = await azure.request().query(queryMainAz);
|
||||
databaseRowsAZ = resultAzure.recordset;
|
||||
|
||||
const querySummaryAz = `
|
||||
SELECT
|
||||
COUNT(DISTINCT d.database_id) AS total_databases,
|
||||
SUM(mf.size * 8 / 1024 / 1024) AS total_size_gb
|
||||
FROM
|
||||
sys.databases d
|
||||
LEFT JOIN
|
||||
sys.master_files mf ON d.database_id = mf.database_id
|
||||
WHERE
|
||||
mf.type = 0 AND d.name != 'tempdb'
|
||||
`;
|
||||
const resSummaryAz = await azure.request().query(querySummaryAz);
|
||||
summaryAZ = resSummaryAz.recordset[0];
|
||||
|
||||
} catch (e: any) {
|
||||
console.error("Error loading Azure DB data:", e);
|
||||
errors.azure = `Error conectando a Azure: ${e.message}`;
|
||||
}
|
||||
|
||||
// --- 3. Load Secondary Data (Clients & Management) ---
|
||||
// Note: We need this connection to hydrate backup info and alerts too
|
||||
let secondary: any = null;
|
||||
try {
|
||||
secondary = await db.getSecondary();
|
||||
|
||||
// Clients Catalog
|
||||
const clientsQuery = `SELECT ID, Nombre, NodoSubNodo, CorreoNotificacion, Activo, BDName FROM BasesdeDatos`;
|
||||
// Assuming 'CONTROLDESK' db context is handled in connection string or default db
|
||||
const resClients = await secondary.request().query(clientsQuery);
|
||||
clientsData = resClients.recordset;
|
||||
|
||||
// DB Management List
|
||||
const basesQueries = `SELECT ID, NodoSubNodo, Activo, RFC, Nombre, Sucursal, CorreoNotificacion, ServerName, BDName FROM BasesDeDatos`;
|
||||
const resBases = await secondary.request().query(basesQueries);
|
||||
basesDeDatosList = resBases.recordset;
|
||||
|
||||
} catch (e: any) {
|
||||
console.error("Error loading Secondary DB data:", e);
|
||||
errors.secondary = `Error conectando al servidor Secundario (ControlDesk): ${e.message}`;
|
||||
}
|
||||
|
||||
// --- 4. Process Backups & Hydrate Alerts (Requires Secondary DB) ---
|
||||
try {
|
||||
let files: string[] = [];
|
||||
try {
|
||||
files = await fs.readdir(env.BACKUP_PATH);
|
||||
} catch (e) {
|
||||
files = [];
|
||||
errors.backups = "No se pudo acceder a la carpeta de respaldos.";
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (file === '.' || file === '..') continue;
|
||||
|
||||
const filePath = path.join(env.BACKUP_PATH, file);
|
||||
let stats;
|
||||
try {
|
||||
stats = await fs.stat(filePath);
|
||||
} catch { continue; }
|
||||
|
||||
const nodoName = path.parse(file).name;
|
||||
let clientData: any = null;
|
||||
|
||||
// Try to fetch metadata if secondary DB is available
|
||||
if (secondary) {
|
||||
try {
|
||||
const clientQuery = `
|
||||
SELECT ClienteAutoridad, Nombre, Usuario, BD_Shelter
|
||||
FROM [CONTROLDESK].[dbo].[Usuarios]
|
||||
WHERE Usuario = @nodoName
|
||||
`;
|
||||
const req = secondary.request();
|
||||
req.input('nodoName', nodoName);
|
||||
const res = await req.query(clientQuery);
|
||||
clientData = res.recordset[0];
|
||||
} catch {}
|
||||
}
|
||||
|
||||
backupFiles.push({
|
||||
name: file,
|
||||
nodo_name: nodoName,
|
||||
client_name: clientData?.Nombre ?? 'Cliente no identificado',
|
||||
client_authority: clientData?.ClienteAutoridad ?? 'N/A',
|
||||
bd_shelter: clientData?.BD_Shelter ?? 'N/A',
|
||||
date: stats.mtime,
|
||||
size: (stats.size / 1024 / 1024).toFixed(2) + " MB"
|
||||
});
|
||||
}
|
||||
|
||||
// Hydrate Alerts if possible
|
||||
if (secondary && alertsData.length > 0) {
|
||||
const hydratedAlerts = [];
|
||||
for (const alert of alertsData) {
|
||||
const nodoName = alert.visible_name;
|
||||
let cData: any = null;
|
||||
try {
|
||||
const req = secondary.request();
|
||||
req.input('nodoName', nodoName);
|
||||
const res = await req.query(`
|
||||
SELECT u.ClienteAutoridad, u.Nombre, u.Usuario, bd.CorreoNotificacion
|
||||
FROM [CONTROLDESK].[dbo].[Usuarios] AS u
|
||||
LEFT JOIN [CONTROLDESK].[dbo].[BasesDeDatos] AS bd ON u.Usuario = bd.NodoSubNodo
|
||||
WHERE u.Usuario = @nodoName
|
||||
`);
|
||||
cData = res.recordset[0];
|
||||
} catch {}
|
||||
|
||||
hydratedAlerts.push({ ...alert, clientData: cData });
|
||||
}
|
||||
alertsData = hydratedAlerts;
|
||||
}
|
||||
|
||||
} catch (e: any) {
|
||||
console.error("Error processing backups/alerts hydration:", e);
|
||||
}
|
||||
|
||||
return {
|
||||
databaseRows,
|
||||
summaryMain,
|
||||
restoredCount,
|
||||
notRestoredCount,
|
||||
|
||||
databaseRowsAZ,
|
||||
summaryAZ,
|
||||
|
||||
backupFiles,
|
||||
clientsData,
|
||||
alertsData,
|
||||
basesDeDatosList,
|
||||
|
||||
errors // Return the collected errors
|
||||
};
|
||||
};
|
||||
|
||||
import { db } from '$lib/server/db';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { verifyToken } from '$lib/server/auth';
|
||||
import { getUserById, filterDatabasesByUserPermissions } from '$lib/server/users';
|
||||
|
||||
// Helper to check disk space (Simple Windows implementation)
|
||||
// Note: In production, consider a specialized library
|
||||
async function getDiskSpace(drive: string) {
|
||||
try {
|
||||
// Using fs.statfs if available (Node 18.15+) or just mock for now
|
||||
// Implementing proper disk check via Powershell is safer
|
||||
return { free: 0, total: 0 };
|
||||
} catch {
|
||||
return { free: 0, total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
// 1. Auth Check - Verificar token JWT
|
||||
const token = cookies.get('session_token');
|
||||
if (!token) {
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
|
||||
const session = verifyToken(token);
|
||||
if (!session) {
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
|
||||
const currentUser = await getUserById(session.userId);
|
||||
if (!currentUser || !currentUser.activo) {
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
|
||||
// Initialize result containers
|
||||
let databaseRows: any[] = [];
|
||||
let summaryMain: any = null;
|
||||
let restoredCount = 0;
|
||||
let notRestoredCount = 0;
|
||||
|
||||
let databaseRowsAZ: any[] = [];
|
||||
let summaryAZ: any = null;
|
||||
|
||||
let backupFiles: any[] = [];
|
||||
let clientsData: any[] = [];
|
||||
let alertsData: any[] = [];
|
||||
let basesDeDatosList: any[] = [];
|
||||
let restoreHistory: Record<string, { restore_date: Date }[]> = {};
|
||||
let effectivenessByDb: Record<string, { month: string; effectiveness: number }[]> = {};
|
||||
|
||||
// Connection errors to be passed to UI
|
||||
let errors = {
|
||||
primary: null as string | null,
|
||||
secondary: null as string | null,
|
||||
azure: null as string | null,
|
||||
backups: null as string | null
|
||||
};
|
||||
|
||||
// --- 1. Load Primary Data (Servidor 152 - Bases Restauradas) ---
|
||||
try {
|
||||
// Usamos el servidor SECUNDARIO (152) como fuente del panel principal
|
||||
const primary = await db.getSecondary();
|
||||
|
||||
// Main Database Rows - Bases restauradas localmente
|
||||
const queryMain = `
|
||||
SELECT
|
||||
d.name AS visible_name,
|
||||
d.name AS original_name,
|
||||
CAST(SUM(mf.size * 8.0 / 1024) AS DECIMAL(10,2)) AS size_mb,
|
||||
CAST(SUM(mf.size * 8.0 / 1024 / 1024) AS DECIMAL(10,2)) AS total_size_gb,
|
||||
MAX(rh.restore_date) AS last_restore_date
|
||||
FROM
|
||||
sys.databases d
|
||||
LEFT JOIN
|
||||
sys.master_files mf ON d.database_id = mf.database_id AND mf.type = 0
|
||||
LEFT JOIN
|
||||
msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name
|
||||
WHERE
|
||||
d.name NOT IN ('master', 'tempdb', 'model', 'msdb')
|
||||
GROUP BY
|
||||
d.name
|
||||
ORDER BY
|
||||
d.name
|
||||
`;
|
||||
const resultMain = await primary.request().query(queryMain);
|
||||
databaseRows = resultMain.recordset;
|
||||
|
||||
// Summary Main
|
||||
const querySummary = `
|
||||
SELECT
|
||||
COUNT(DISTINCT d.database_id) AS total_databases,
|
||||
SUM(mf.size * 8 / 1024 / 1024) AS total_size_gb
|
||||
FROM
|
||||
sys.databases d
|
||||
LEFT JOIN
|
||||
sys.master_files mf ON d.database_id = mf.database_id
|
||||
WHERE
|
||||
mf.type = 0 AND d.name != 'tempdb'
|
||||
`;
|
||||
const resSummary = await primary.request().query(querySummary);
|
||||
summaryMain = resSummary.recordset[0];
|
||||
|
||||
// Alerts Data (bases sin restore reciente)
|
||||
const sqlAlerts = `
|
||||
SELECT
|
||||
d.name AS visible_name,
|
||||
MAX(rh.restore_date) AS last_restore_date
|
||||
FROM sys.databases d
|
||||
LEFT JOIN msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name
|
||||
WHERE d.name != 'tempdb'
|
||||
GROUP BY d.name
|
||||
HAVING MAX(rh.restore_date) < DATEADD(DAY, -2, GETDATE()) OR MAX(rh.restore_date) IS NULL
|
||||
`;
|
||||
const resAlerts = await primary.request().query(sqlAlerts);
|
||||
alertsData = resAlerts.recordset;
|
||||
|
||||
// Historial de restauraciones por base (últimos 60 días)
|
||||
const sqlHistory = `
|
||||
SELECT
|
||||
destination_database_name AS visible_name,
|
||||
restore_date
|
||||
FROM msdb.dbo.restorehistory
|
||||
WHERE restore_date >= DATEADD(DAY, -120, GETDATE())
|
||||
`;
|
||||
const resHistory = await primary.request().query(sqlHistory);
|
||||
for (const row of resHistory.recordset as any[]) {
|
||||
const name = String(row.visible_name ?? '').toLowerCase();
|
||||
if (!restoreHistory[name]) restoreHistory[name] = [];
|
||||
restoreHistory[name].push({ restore_date: row.restore_date });
|
||||
}
|
||||
|
||||
// Calcular efectividad mensual por base (1 restore esperado por día)
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonth = now.getMonth(); // 0-11
|
||||
|
||||
const monthsToInclude = [currentMonth, currentMonth - 1, currentMonth - 2].filter(
|
||||
(m) => m >= 0
|
||||
);
|
||||
|
||||
for (const [dbName, history] of Object.entries(restoreHistory)) {
|
||||
const monthly: { [monthKey: string]: { daysWithRestore: Set<string>; totalDays: number } } = {};
|
||||
|
||||
// inicializar meses con días del mes
|
||||
for (const m of monthsToInclude) {
|
||||
const monthKey = `${currentYear}-${String(m + 1).padStart(2, '0')}`;
|
||||
monthly[monthKey] = {
|
||||
daysWithRestore: new Set<string>(),
|
||||
totalDays: new Date(currentYear, m + 1, 0).getDate()
|
||||
};
|
||||
}
|
||||
|
||||
for (const h of history) {
|
||||
const d = new Date(h.restore_date);
|
||||
const y = d.getFullYear();
|
||||
const m = d.getMonth();
|
||||
if (y !== currentYear || !monthsToInclude.includes(m)) continue;
|
||||
const monthKey = `${y}-${String(m + 1).padStart(2, '0')}`;
|
||||
const dayKey = d.toISOString().slice(0, 10);
|
||||
monthly[monthKey]?.daysWithRestore.add(dayKey);
|
||||
}
|
||||
|
||||
effectivenessByDb[dbName] = Object.entries(monthly).map(
|
||||
([month, data]) => {
|
||||
const eff =
|
||||
data.totalDays === 0
|
||||
? 0
|
||||
: (data.daysWithRestore.size / data.totalDays) * 100;
|
||||
return { month, effectiveness: Number(eff.toFixed(1)) };
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
} catch (e: any) {
|
||||
console.error("Error loading Primary DB data:", e);
|
||||
errors.primary = `Error conectando al servidor Local (SQL Express): ${e.message}`;
|
||||
}
|
||||
|
||||
// --- 2. Load Secondary Data (Servidor con TODAS las bases de clientes en 202) ---
|
||||
try {
|
||||
// Aquí usamos el servidor PRIMARIO (202) para la segunda sección
|
||||
const secondary = await db.getPrimary();
|
||||
|
||||
// Consultar TODAS las bases de datos en el servidor secundario
|
||||
const queryMainSec = `
|
||||
SELECT
|
||||
d.name AS visible_name,
|
||||
d.name AS original_name,
|
||||
CAST(SUM(mf.size * 8.0 / 1024) AS DECIMAL(10,2)) AS size_mb,
|
||||
d.create_date,
|
||||
d.state_desc,
|
||||
d.recovery_model_desc
|
||||
FROM
|
||||
sys.databases d
|
||||
LEFT JOIN
|
||||
sys.master_files mf ON d.database_id = mf.database_id AND mf.type = 0
|
||||
WHERE
|
||||
d.name NOT IN ('master', 'tempdb', 'model', 'msdb')
|
||||
GROUP BY
|
||||
d.name, d.create_date, d.state_desc, d.recovery_model_desc
|
||||
ORDER BY
|
||||
d.name
|
||||
`;
|
||||
|
||||
const resultSecondary = await secondary.request().query(queryMainSec);
|
||||
databaseRowsAZ = resultSecondary.recordset;
|
||||
|
||||
const querySummarySec = `
|
||||
SELECT
|
||||
COUNT(d.database_id) AS total_databases,
|
||||
CAST(SUM(mf.size * 8.0 / 1024 / 1024) AS DECIMAL(10,2)) AS total_size_gb
|
||||
FROM
|
||||
sys.databases d
|
||||
LEFT JOIN
|
||||
sys.master_files mf ON d.database_id = mf.database_id AND mf.type = 0
|
||||
WHERE
|
||||
d.name NOT IN ('master', 'tempdb', 'model', 'msdb')
|
||||
`;
|
||||
const resSummarySec = await secondary.request().query(querySummarySec);
|
||||
summaryAZ = resSummarySec.recordset[0];
|
||||
|
||||
} catch (e: any) {
|
||||
console.error("Error loading Secondary DB data:", e);
|
||||
errors.secondary = `Error conectando al servidor Secundario (Todas las Bases): ${e.message}`;
|
||||
}
|
||||
|
||||
// --- 3. Load Azure/ControlDesk Data (Catálogo de Clientes) ---
|
||||
// Note: We need this connection to hydrate backup info and alerts too
|
||||
let azure: any = null;
|
||||
try {
|
||||
azure = await db.getAzure();
|
||||
|
||||
// Clients Catalog - desde CONTROLDESK en Azure
|
||||
const clientsQuery = `SELECT ID, Nombre, NodoSubNodo, CorreoNotificacion, Activo, BDName FROM [CONTROLDESK].[dbo].[BasesdeDatos]`;
|
||||
const resClients = await azure.request().query(clientsQuery);
|
||||
clientsData = resClients.recordset;
|
||||
|
||||
// DB Management List
|
||||
const basesQueries = `SELECT ID, NodoSubNodo, Activo, RFC, Nombre, Sucursal, CorreoNotificacion, ServerName, BDName FROM [CONTROLDESK].[dbo].[BasesDeDatos]`;
|
||||
const resBases = await azure.request().query(basesQueries);
|
||||
basesDeDatosList = resBases.recordset;
|
||||
|
||||
} catch (e: any) {
|
||||
console.error("Error loading Azure/ControlDesk data:", e);
|
||||
errors.azure = `Error conectando a Azure/ControlDesk: ${e.message}`;
|
||||
}
|
||||
|
||||
// --- 4. Process Backups, Hydrate Alerts & Enriquecer databaseRows con datos de BasesDeDatos ---
|
||||
try {
|
||||
let files: string[] = [];
|
||||
try {
|
||||
files = await fs.readdir(env.BACKUP_PATH);
|
||||
} catch (e) {
|
||||
files = [];
|
||||
errors.backups = "No se pudo acceder a la carpeta de respaldos.";
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (file === '.' || file === '..') continue;
|
||||
|
||||
const filePath = path.join(env.BACKUP_PATH, file);
|
||||
let stats;
|
||||
try {
|
||||
stats = await fs.stat(filePath);
|
||||
} catch { continue; }
|
||||
|
||||
const nodoName = path.parse(file).name;
|
||||
let clientData: any = null;
|
||||
|
||||
// Try to fetch metadata if azure DB is available
|
||||
if (azure) {
|
||||
try {
|
||||
const clientQuery = `
|
||||
SELECT TOP 1 Nombre, NodoSubNodo, RFC
|
||||
FROM [CONTROLDESK].[dbo].[BasesDeDatos]
|
||||
WHERE NodoSubNodo = @nodoName OR BDName = @nodoName
|
||||
`;
|
||||
const req = azure.request();
|
||||
req.input('nodoName', nodoName);
|
||||
const res = await req.query(clientQuery);
|
||||
clientData = res.recordset[0];
|
||||
} catch {}
|
||||
}
|
||||
|
||||
backupFiles.push({
|
||||
name: file,
|
||||
nodo_name: nodoName,
|
||||
client_name: clientData?.Nombre ?? 'Cliente no identificado',
|
||||
client_authority: clientData?.RFC ?? 'N/A',
|
||||
bd_shelter: 'N/A',
|
||||
date: stats.mtime,
|
||||
size: (stats.size / 1024 / 1024).toFixed(2) + " MB"
|
||||
});
|
||||
}
|
||||
|
||||
// Ordenar respaldos de más reciente a más antiguo por fecha de modificación
|
||||
backupFiles.sort((a, b) => {
|
||||
const da = new Date(a.date).getTime();
|
||||
const db = new Date(b.date).getTime();
|
||||
return db - da;
|
||||
});
|
||||
|
||||
// Hydrate Alerts if possible
|
||||
if (azure && alertsData.length > 0) {
|
||||
const hydratedAlerts = [];
|
||||
for (const alert of alertsData) {
|
||||
const nodoName = alert.visible_name;
|
||||
let cData: any = null;
|
||||
try {
|
||||
const req = azure.request();
|
||||
req.input('nodoName', nodoName);
|
||||
const res = await req.query(`
|
||||
SELECT u.ClienteAutoridad, u.Nombre, u.Usuario, bd.CorreoNotificacion
|
||||
FROM [CONTROLDESK].[dbo].[Usuarios] AS u
|
||||
LEFT JOIN [CONTROLDESK].[dbo].[BasesDeDatos] AS bd ON u.Usuario = bd.NodoSubNodo
|
||||
WHERE u.Usuario = @nodoName
|
||||
`);
|
||||
cData = res.recordset[0];
|
||||
} catch {}
|
||||
|
||||
hydratedAlerts.push({ ...alert, clientData: cData });
|
||||
}
|
||||
alertsData = hydratedAlerts;
|
||||
}
|
||||
|
||||
// Enriquecer databaseRows (servidor local restaurado) con NodoSubNodo y Nombre desde BasesDeDatos
|
||||
if (azure && databaseRows.length > 0) {
|
||||
try {
|
||||
const req = azure.request();
|
||||
const res = await req.query(`
|
||||
SELECT ID, NodoSubNodo, Activo, RFC, Nombre, Sucursal, CorreoNotificacion, ServerName, BDName
|
||||
FROM [CONTROLDESK].[dbo].[BasesDeDatos]
|
||||
`);
|
||||
const bases = res.recordset as any[];
|
||||
|
||||
const mapByBdName = new Map<string, any>();
|
||||
const mapByNodo = new Map<string, any>();
|
||||
for (const bd of bases) {
|
||||
if (bd.BDName) {
|
||||
mapByBdName.set(String(bd.BDName).toLowerCase(), bd);
|
||||
}
|
||||
if (bd.NodoSubNodo) {
|
||||
mapByNodo.set(String(bd.NodoSubNodo).toLowerCase(), bd);
|
||||
}
|
||||
}
|
||||
|
||||
databaseRows = databaseRows.map((row) => {
|
||||
const key = String(row.visible_name ?? row.original_name ?? '').toLowerCase();
|
||||
// Intentar match por BDName primero, luego por NodoSubNodo
|
||||
let match = mapByBdName.get(key);
|
||||
if (!match) {
|
||||
match = mapByNodo.get(key);
|
||||
}
|
||||
if (!match) {
|
||||
console.log(`No match found for database: ${key}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
NodoSubNodo: match.NodoSubNodo,
|
||||
client_name: match.Nombre,
|
||||
BDName: match.BDName
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error enriching databaseRows with BasesDeDatos info:', e);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: any) {
|
||||
console.error("Error processing backups/alerts hydration:", e);
|
||||
}
|
||||
|
||||
// CALCULAR MÉTRICAS DE RESTAURACIÓN basadas en backups reales (ANTES del filtro)
|
||||
restoredCount = 0;
|
||||
notRestoredCount = 0;
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
for (const db of databaseRows) {
|
||||
// Buscar backups de esta base de datos en las últimas 24 horas
|
||||
// Comparar usando NodoSubNodo (de la BD) con nodo_name (del archivo de backup)
|
||||
const recentBackups = backupFiles.filter(backup => {
|
||||
const backupNodo = (backup.nodo_name || '').toLowerCase();
|
||||
const dbNodo = (db.NodoSubNodo || '').toLowerCase();
|
||||
const backupDate = new Date(backup.date);
|
||||
|
||||
// Si no tiene NodoSubNodo, intentar con el nombre de la BD
|
||||
const hasMatch = dbNodo ? (backupNodo === dbNodo) :
|
||||
(backupNodo === (db.visible_name || '').toLowerCase());
|
||||
|
||||
return hasMatch && backupDate > oneDayAgo;
|
||||
});
|
||||
|
||||
if (recentBackups.length > 0) {
|
||||
restoredCount++;
|
||||
} else {
|
||||
notRestoredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Aplicar filtro de permisos de usuario (si no es admin)
|
||||
if (!currentUser.es_admin) {
|
||||
databaseRows = await filterDatabasesByUserPermissions(currentUser.id, databaseRows);
|
||||
alertsData = await filterDatabasesByUserPermissions(currentUser.id, alertsData);
|
||||
|
||||
// Filtrar backups según las bases de datos permitidas (usar NodoSubNodo)
|
||||
const allowedNodos = new Set(databaseRows.map(db => (db.NodoSubNodo || db.visible_name).toLowerCase()));
|
||||
backupFiles = backupFiles.filter(backup => {
|
||||
const backupNodo = (backup.nodo_name || '').toLowerCase();
|
||||
return allowedNodos.has(backupNodo);
|
||||
});
|
||||
|
||||
// RECALCULAR MÉTRICAS basadas en las bases de datos filtradas
|
||||
summaryMain.total_size_gb = databaseRows.reduce((sum, db) => sum + (db.total_size_gb || 0), 0);
|
||||
summaryMain.total_size_gb = Math.round(summaryMain.total_size_gb * 100) / 100;
|
||||
|
||||
restoredCount = 0;
|
||||
notRestoredCount = 0;
|
||||
for (const db of databaseRows) {
|
||||
const recentBackups = backupFiles.filter(backup => {
|
||||
const backupNodo = (backup.nodo_name || '').toLowerCase();
|
||||
const dbNodo = (db.NodoSubNodo || '').toLowerCase();
|
||||
const backupDate = new Date(backup.date);
|
||||
|
||||
// Si no tiene NodoSubNodo, intentar con el nombre de la BD
|
||||
const hasMatch = dbNodo ? (backupNodo === dbNodo) :
|
||||
(backupNodo === (db.visible_name || '').toLowerCase());
|
||||
|
||||
return hasMatch && backupDate > oneDayAgo;
|
||||
});
|
||||
|
||||
if (recentBackups.length > 0) {
|
||||
restoredCount++;
|
||||
} else {
|
||||
notRestoredCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
databaseRows,
|
||||
summaryMain,
|
||||
restoredCount,
|
||||
notRestoredCount,
|
||||
|
||||
databaseRowsAZ,
|
||||
summaryAZ,
|
||||
|
||||
backupFiles,
|
||||
clientsData,
|
||||
alertsData,
|
||||
basesDeDatosList,
|
||||
restoreHistory,
|
||||
effectivenessByDb,
|
||||
|
||||
errors, // Return the collected errors
|
||||
currentUser // Añadir usuario actual para la UI
|
||||
};
|
||||
};
|
||||
|
||||
// Acciones para actualizar estado de clientes (activar/desactivar) y editar nombre de base de datos
|
||||
export const actions: Actions = {
|
||||
toggleClient: async ({ request }) => {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const idRaw = formData.get('id');
|
||||
const activoRaw = formData.get('activo');
|
||||
|
||||
if (!idRaw || !activoRaw) {
|
||||
return { success: false, message: 'Par | ||||