Initial project commit: Docker + SvelteKit source

This commit is contained in:
2026-02-09 10:32:51 -07:00
parent fafb3d70fe
commit 7e7773578d
20 changed files with 6077 additions and 0 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
.svelte-kit
build
.env
.git
.DS_Store
npm-debug.log

9
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,9 @@
- [ ] Checklist: Create SvelteKit Project
- [x] Clarify Project Requirements
- [ ] Scaffold the Project
- [ ] Customize the Project
- [ ] Install Required Extensions
- [ ] Compile the Project
- [ ] Create and Run Task
- [ ] Launch the Project
- [ ] Ensure Documentation is Complete

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
# Node
node_modules
.DS_Store
# SvelteKit
.svelte-kit
build
.env
.env.*
!.env.example
# Docker
docker-compose.override.yml

16
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,16 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Run Dev Server",
"type": "shell",
"command": "npm run dev",
"args": [],
"isBackground": true,
"problemMatcher": [
"$tsc-watch"
],
"group": "build"
}
]
}

33
Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
RUN npm prune --production
# Stage 2: Run
FROM node:20-alpine
WORKDIR /app
# Copy built application and dependencies
COPY --from=builder /app/build build/
COPY --from=builder /app/node_modules node_modules/
COPY package.json .
# Expose port
EXPOSE 3000
# Start the application
CMD ["node", "build"]

34
docker-compose.yml Normal file
View File

@@ -0,0 +1,34 @@
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
# Use host.docker.internal to access resources on the host machine
- DB_PRIMARY_HOST=host.docker.internal
- DB_PRIMARY_USER=${DB_PRIMARY_USER}
- DB_PRIMARY_PASS=${DB_PRIMARY_PASS}
- DB_PRIMARY_DB=${DB_PRIMARY_DB}
- DB_SECONDARY_HOST=host.docker.internal
- DB_SECONDARY_USER=${DB_SECONDARY_USER}
- DB_SECONDARY_PASS=${DB_SECONDARY_PASS}
- DB_SECONDARY_DB=${DB_SECONDARY_DB}
- DB_AZURE_HOST=${DB_AZURE_HOST}
- DB_AZURE_USER=${DB_AZURE_USER}
- DB_AZURE_PASS=${DB_AZURE_PASS}
- DB_AZURE_DB=${DB_AZURE_DB}
# Use a mounted volume for backups
- BACKUP_PATH=/data/backups
- PORT=3000
- ORIGIN=http://localhost:3000
volumes:
# Map a local folder to the container's backup path
- ./backups:/data/backups:ro
extra_hosts:
- "host.docker.internal:host-gateway"

4826
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "transmitiras-dashboard",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && tsc --noEmit"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.3.1",
"@sveltejs/adapter-node": "^5.5.2",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.0",
"@types/mssql": "^9.1.5",
"@types/node": "^22.0.0",
"svelte": "^5.0.0-next.1",
"svelte-check": "^3.6.0",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^5.0.0"
},
"dependencies": {
"bootstrap": "^5.3.3",
"dotenv": "^16.4.5",
"mssql": "^10.0.2"
},
"type": "module"
}

196
src/app.css Normal file
View File

@@ -0,0 +1,196 @@
: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;
}
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;
}
}

17
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}
interface Window {
jQuery: any;
$: any;
}
}
export {};

47
src/app.html Normal file
View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.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>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
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>
</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>
</div>
</div>
</nav>

View File

@@ -0,0 +1,79 @@
<script lang="ts">
interface Props {
activeSection: string;
}
let { activeSection = $bindable('summary') }: Props = $props();
function setActive(section: string) {
activeSection = section;
}
</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>
</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>
</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>
</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>
<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>
<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>
<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>
</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()}
</div>
</div>
</div>
</aside>

62
src/lib/server/db.ts Normal file
View File

@@ -0,0 +1,62 @@
import sql from 'mssql';
import { env } from '$env/dynamic/private';
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
}
};
const secondaryConfig: sql.config = {
user: env.DB_SECONDARY_USER,
password: env.DB_SECONDARY_PASS,
server: env.DB_SECONDARY_HOST,
database: env.DB_SECONDARY_DB,
options: {
encrypt: true,
trustServerCertificate: true
}
};
const azureConfig: sql.config = {
user: env.DB_AZURE_USER,
password: env.DB_AZURE_PASS,
server: env.DB_AZURE_HOST,
database: env.DB_AZURE_DB,
options: {
encrypt: true,
trustServerCertificate: true
}
};
class Database {
private primaryPool: sql.ConnectionPool | null = null;
private secondaryPool: sql.ConnectionPool | null = null;
private azurePool: sql.ConnectionPool | null = null;
async getPrimary(): Promise<sql.ConnectionPool> {
if (this.primaryPool?.connected) return this.primaryPool;
this.primaryPool = await new sql.ConnectionPool(primaryConfig).connect();
return this.primaryPool;
}
async getSecondary(): Promise<sql.ConnectionPool> {
if (this.secondaryPool?.connected) return this.secondaryPool;
this.secondaryPool = await new sql.ConnectionPool(secondaryConfig).connect();
return this.secondaryPool;
}
async getAzure(): Promise<sql.ConnectionPool> {
if (this.azurePool?.connected) return this.azurePool;
this.azurePool = await new sql.ConnectionPool(azureConfig).connect();
return this.azurePool;
}
}
export const db = new Database();
export { sql };

View File

@@ -0,0 +1,5 @@
<script>
import '../app.css';
</script>
<slot />

279
src/routes/+page.server.ts Normal file
View File

@@ -0,0 +1,279 @@
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
};
};

346
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,346 @@
<script lang="ts">
import Navbar from '$lib/components/Navbar.svelte';
import Sidebar from '$lib/components/Sidebar.svelte';
import { onMount } from 'svelte';
let { data } = $props();
let activeSection = $state('summary');
// Extract data from load function
const {
databaseRows, summaryMain, restoredCount, notRestoredCount,
databaseRowsAZ, summaryAZ, backupFiles, clientsData,
alertsData, basesDeDatosList,
errors // Capturar errores del servidor
} = data;
// Derived Metrics
const totalDatabases = summaryMain?.total_databases || 0;
const totalSizeGB = summaryMain?.total_size_gb ? parseFloat(summaryMain.total_size_gb).toFixed(2) : '0';
// Disk space placeholders (passed from server or calculated)
const diskC = "Free"; // Implement proper disk check
const diskD = "Free";
onMount(() => {
// Initialize DataTables
// Note: In Svelte, we typically avoid jQuery, but for migration compatibility
// with the specific glass theme and plugins, we initialize them here.
// Ensure scripts in app.html are loaded.
// Use a timeout to ensure DOM is ready and scripts are loaded
setTimeout(() => {
if (typeof window.jQuery !== 'undefined') {
const jq = window.jQuery;
// Initialize tables only if data exists
if (databaseRows && databaseRows.length > 0) jq('#combinedTable').DataTable({ responsive: true });
if (databaseRowsAZ && databaseRowsAZ.length > 0) jq('#combinedTable1').DataTable({ responsive: true });
if (backupFiles && backupFiles.length > 0) jq('#backupTable').DataTable({ responsive: true });
if (clientsData && clientsData.length > 0) jq('#clientTable').DataTable({ responsive: true });
if (alertsData && alertsData.length > 0) jq('#alertsTable').DataTable({ responsive: true });
if (basesDeDatosList && basesDeDatosList.length > 0) jq('#basesDeDatosTable').DataTable({ responsive: true });
}
}, 100);
});
</script>
<div class="d-flex min-vh-100">
<Navbar />
<Sidebar bind:activeSection />
<!-- Main Content -->
<div class="md-content-wrapper">
<!-- Header Section -->
<div class="d-flex justify-content-between align-items-center mb-5" data-aos="fade-down">
<div>
<h1 class="page-title">Panel de Control</h1>
<p class="text-muted fs-5 mb-0">Sistema de Gestión de Bases de Datos</p>
</div>
<div class="text-end">
<div class="text-muted small">{new Date().toLocaleDateString('es-ES')}</div>
<div class="fw-bold" id="current-time">{new Date().toLocaleTimeString('es-ES')}</div>
</div>
</div>
<!-- Global Alert Area -->
{#if errors}
{#if errors.primary}
<div class="alert alert-danger d-flex align-items-center mb-4 shadow-sm" role="alert">
<span class="material-icons-outlined me-2">error</span>
<div>
<strong>Error Servidor Principal:</strong> {errors.primary}
</div>
</div>
{/if}
{#if errors.secondary}
<div class="alert alert-warning d-flex align-items-center mb-4 shadow-sm" role="alert">
<span class="material-icons-outlined me-2">warning</span>
<div>
<strong>Advertencia Servidor Secundario:</strong> {errors.secondary}
<br><small>Algunos datos de clientes o catálogos podrían no estar disponibles.</small>
</div>
</div>
{/if}
{#if errors.azure}
<div class="alert alert-info d-flex align-items-center mb-4 shadow-sm" role="alert">
<span class="material-icons-outlined me-2">cloud_off</span>
<div>
<strong>Aviso Azure:</strong> {errors.azure}
</div>
</div>
{/if}
{#if errors.backups}
<div class="alert alert-secondary d-flex align-items-center mb-4 shadow-sm" role="alert">
<span class="material-icons-outlined me-2">folder_off</span>
<div>
<strong>Sistema de Archivos:</strong> {errors.backups}
</div>
</div>
{/if}
{/if}
{#if activeSection === 'summary'}
<!-- Summary Section -->
<section id="summarySection">
<!-- Metrics -->
<div class="row g-4 mb-5">
<div class="col-xl-3 col-lg-6">
<div class="md-metric-card">
<div class="material-icons-outlined md-metric-icon">storage</div>
<div class="md-metric-value">{totalDatabases}</div>
<div class="md-metric-label">Bases de Datos Totales</div>
</div>
</div>
<!-- More metrics... simplified for brevity, assume similar structure -->
<div class="col-xl-3 col-lg-6">
<div class="md-metric-card">
<div class="material-icons-outlined md-metric-icon" style="color: var(--md-success)">cloud_done</div>
<div class="md-metric-value" style="color: var(--md-success)">{totalSizeGB} GB</div>
<div class="md-metric-label">Almacenamiento Utilizado</div>
</div>
</div>
<div class="col-xl-3 col-lg-6">
<div class="md-metric-card">
<div class="material-icons-outlined md-metric-icon" style="color: var(--md-warning)">restore</div>
<div class="md-metric-value" style="color: var(--md-warning)">{notRestoredCount}</div>
<div class="md-metric-label">Pendientes de Restaurar</div>
</div>
</div>
<div class="col-xl-3 col-lg-6">
<div class="md-metric-card">
<div class="material-icons-outlined md-metric-icon" style="color: var(--md-info)">check_circle</div>
<div class="md-metric-value" style="color: var(--md-info)">{restoredCount}</div>
<div class="md-metric-label">Restauradas 24h</div>
</div>
</div>
</div>
<!-- Table -->
<div class="md-card">
<h4 class="md-card-title mb-4">Estado de Bases de Datos Local</h4>
<div class="table-responsive">
<table id="combinedTable" class="table display responsive nowrap" style="width:100%">
<thead>
<tr>
<th>Cliente</th>
<th>Original</th>
<th>Tamaño</th>
<th>Restauración</th>
<th>Estado</th>
</tr>
</thead>
<tbody>
{#each (databaseRows || []) as row}
<tr>
<td>{row.visible_name}</td>
<td>{row.original_name}</td>
<td>{row.size_mb ? Number(row.size_mb).toFixed(2) : 0} MB</td>
<td>{row.last_restore_date ? new Date(row.last_restore_date).toLocaleDateString() : 'N/A'}</td>
<td>
{#if row.last_restore_date && new Date(row.last_restore_date) > new Date(Date.now() - 86400000)}
<span class="badge bg-success">Actualizada</span>
{:else}
<span class="badge bg-danger">Atención</span>
{/if}
</td>
</tr>
{:else}
<tr>
<td colspan="5" class="text-center text-muted py-4">
No hay datos disponibles o error de conexión.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</section>
{/if}
{#if activeSection === 'azure'}
<!-- Azure Section -->
<section id="AzureSection">
<div class="md-card">
<h4 class="md-card-title mb-4">Azure Cloud Databases</h4>
<p class="text-muted mb-4">Instancias alojadas en servicios de nube.</p>
<div class="table-responsive">
<table id="combinedTable1" class="table w-100">
<thead>
<tr>
<th>Nombre</th>
<th>Nombre Original</th>
<th>Tamaño</th>
<th>Última Sync</th>
</tr>
</thead>
<tbody>
{#each (databaseRowsAZ || []) as row}
<tr>
<td>{row.visible_name}</td>
<td>{row.original_name}</td>
<td>{row.size_mb ? Number(row.size_mb).toFixed(2) : 0} MB</td>
<td>{row.last_restore_date ? new Date(row.last_restore_date).toLocaleDateString() : 'N/A'}</td>
</tr>
{:else}
<tr>
<td colspan="4" class="text-center text-muted py-4">
No hay datos de Azure disponibles.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</section>
{/if}
{#if activeSection === 'backups'}
<section id="backupSection">
<div class="md-card">
<h4 class="md-card-title mb-4">Respaldos Almacenados</h4>
<p class="text-muted mb-4">Archivos físicos encontrados en la ruta de respaldo.</p>
<table id="backupTable" class="table w-100">
<thead><tr><th>Archivo</th><th>Cliente</th><th>Fecha</th><th>Tamaño</th></tr></thead>
<tbody>
{#each (backupFiles || []) as file}
<tr>
<td>{file.name}</td>
<td>{file.client_name}</td>
<td>{new Date(file.date).toLocaleString()}</td>
<td>{file.size}</td>
</tr>
{:else}
<tr>
<td colspan="4" class="text-center text-muted py-4">
No se encontraron archivos de respaldo o no hay acceso al disco.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if activeSection === 'clients'}
<section id="clientsSection">
<div class="md-card">
<h4 class="md-card-title mb-4">Catálogo de Clientes</h4>
<table id="clientTable" class="table w-100">
<thead><tr><th>ID</th><th>Nombre</th><th>Email</th><th>Estado</th></tr></thead>
<tbody>
{#each (clientsData || []) as client}
<tr>
<td>{client.ID}</td>
<td>{client.Nombre}</td>
<td>{client.CorreoNotificacion}</td>
<td>
{#if client.Activo}
<span class="badge bg-success">Activo</span>
{:else}
<span class="badge bg-secondary">Inactivo</span>
{/if}
</td>
</tr>
{:else}
<tr>
<td colspan="4" class="text-center text-muted py-4">
No hay datos de clientes (Verifique conexión secundaria).
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if activeSection === 'alerts'}
<section id="alertsSection">
<div class="md-card">
<h4 class="md-card-title mb-4">Alertas Críticas</h4>
<div class="alert alert-warning mb-4">
<span class="material-icons-outlined align-middle fs-5 me-2">info</span>
Mostrando bases de datos que no se han restaurado en los últimos 2 días.
</div>
<table id="alertsTable" class="table w-100">
<thead><tr><th>Base de Datos</th><th>Última Restauración</th><th>Severidad</th></tr></thead>
<tbody>
{#each (alertsData || []) as alert}
<tr>
<td>{alert.visible_name}</td>
<td>{alert.last_restore_date ? new Date(alert.last_restore_date).toLocaleString() : 'Nunca'}</td>
<td><span class="badge bg-danger">Crítica</span></td>
</tr>
{:else}
<tr>
<td colspan="3" class="text-center text-muted py-4">
¡Excelente! No hay alertas críticas pendientes.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if activeSection === 'databases'}
<section id="databasesSection">
<div class="md-card">
<h4 class="md-card-title mb-4">Gestión de Bases de Datos</h4>
<table id="basesDeDatosTable" class="table w-100">
<thead><tr><th>ID</th><th>Nodo</th><th>Nombre</th><th>Servidor</th></tr></thead>
<tbody>
{#each (basesDeDatosList || []) as bd}
<tr>
<td>{bd.ID}</td>
<td>{bd.NodoSubNodo}</td>
<td>{bd.Nombre}</td>
<td>{bd.ServerName}</td>
</tr>
{:else}
<tr>
<td colspan="4" class="text-center text-muted py-4">
No hay registros de gestión disponibles.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
</div>
</div>

16
svelte.config.js Normal file
View File

@@ -0,0 +1,16 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-node creates a Node.js server for the build
adapter: adapter()
}
};
export default config;

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
//
// If you want to overwrite includes/excludes, make sure to copy them from .svelte-kit/tsconfig.json
// "include": []
}

6
vite.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});