From 0bf9c9e68dad2f8057ee3dac2460154b27284b17 Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Tue, 14 Apr 2026 14:16:11 -0500 Subject: [PATCH] Refactor database configuration and user management - Updated .env.example to consolidate SQL Server credentials under PANEL_MSSQL_* variables. - Removed deprecated docker-compose.postgres.yml file. - Adjusted docker-compose.yml to utilize new SQL Server credential structure. - Enhanced README.md with Docker build and push instructions. - Refined database schema in schema.sql to align with new user and permission structures. - Updated init-database.js to reflect changes in user and session table names. - Modified user management functions in users.ts to accommodate new database schema. - Streamlined API routes to utilize PostgreSQL for user and database management. - Improved error handling and logging in various server routes. --- .env.example | 25 +- README.md | 14 + database/schema.sql | 96 +++-- docker-compose.postgres.yml | 52 --- docker-compose.yml | 23 +- scripts/init-database.js | 29 +- src/lib/server/controldesk-pg.ts | 335 ++++++++++++++++ src/lib/server/dashboard-pg.ts | 21 + src/lib/server/db.ts | 58 +-- src/lib/server/mssql-nodes.ts | 288 ++++++++++++++ src/lib/server/users.ts | 155 +++++--- src/routes/+page.server.ts | 473 ++++++----------------- src/routes/+page.svelte | 12 +- src/routes/api/dashboard.json/+server.ts | 55 +-- src/routes/api/health/+server.ts | 9 +- src/routes/reportes/+page.server.ts | 11 +- src/routes/reportes/excel/+server.ts | 30 +- src/routes/usuarios/+page.server.ts | 66 +--- 18 files changed, 1004 insertions(+), 748 deletions(-) delete mode 100644 docker-compose.postgres.yml create mode 100644 src/lib/server/controldesk-pg.ts create mode 100644 src/lib/server/dashboard-pg.ts create mode 100644 src/lib/server/mssql-nodes.ts diff --git a/.env.example b/.env.example index 0019580..4d2bfcb 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,11 @@ -# SQL Server - Servidor Primary (.202) -DB_PRIMARY_HOST=104.197.7.202 -DB_PRIMARY_USER=sa -DB_PRIMARY_PASS=Clave.2025 -DB_PRIMARY_DB=master +# Credenciales SQL Server (mismo usuario en todos los nodos salvo sql_password en database_nodes). +# Si no defines PANEL_MSSQL_*, se usan DB_PRIMARY_* / DB_SECONDARY_* como respaldo. +PANEL_MSSQL_USER=sa +PANEL_MSSQL_PASSWORD=Clave.2025 +# En contenedor Docker: reescribe localhost en server_name → host.docker.internal +# PANEL_MSSQL_DOCKER=true -# SQL Server - Servidor Secondary (.152) -DB_SECONDARY_HOST=104.192.7.152 -DB_SECONDARY_USER=sa -DB_SECONDARY_PASS=Clave.2025 -DB_SECONDARY_DB=master - -# SQL Server - Azure/CONTROLDESK -DB_AZURE_HOST=104.192.7.152 -DB_AZURE_USER=sa -DB_AZURE_PASS=Clave.2025 -DB_AZURE_DB=CONTROLDESK - -# PostgreSQL - Base de datos de usuarios (Docker local) +# PostgreSQL - Usuarios del panel + catálogo ControlDesk (tablas a24c.* las crea otra app) DB_POSTGRES_HOST=localhost DB_POSTGRES_PORT=5432 DB_POSTGRES_USER=postgres diff --git a/README.md b/README.md index 2a3ebcc..930a5ec 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,20 @@ Este proyecto es una migración moderna del panel de control legacy `TransmitirA - **Node.js**: v18 o superior. - **SQL Server**: Acceso a las instancias de base de datos definidas en `.env`. +```bash +docker build \ + -t dev.aduanasoft.com:8443/databases_a24c/frontend:latest \ + -f ./Dockerfile \ + ./ +``` + +Subirlas a harbor + +``` +docker login dev.aduanasoft.com:8443 +docker push dev.aduanasoft.com:8443/databases_a24c/frontend:latest +``` + ## Configuración 1. **Instalar dependencias**: diff --git a/database/schema.sql b/database/schema.sql index cd414e7..6ab2ca0 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -1,55 +1,58 @@ --- Schema para sistema de usuarios y permisos --- Ejecutar en PostgreSQL (base de datos CONTROLDESK) +-- Panel Transmitiras: usuarios, permisos y sesiones (mismo modelo que +-- ~/dev/a24c/backend/api/v1/modules/dashboard — tablas en inglés, esquema a24c). +-- Ejecutar en PostgreSQL (misma base donde está el catálogo ControlDesk, p. ej. CONTROLDESK). --- Tabla de usuarios -CREATE TABLE IF NOT EXISTS usuarios ( - id SERIAL PRIMARY KEY, - username VARCHAR(50) UNIQUE NOT NULL, - email VARCHAR(100) UNIQUE NOT NULL, - password_hash VARCHAR(255) NOT NULL, - nombre_completo VARCHAR(100), - activo BOOLEAN DEFAULT true, - es_admin BOOLEAN DEFAULT false, - fecha_creacion TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - ultimo_acceso TIMESTAMP, - CONSTRAINT chk_username_length CHECK (char_length(username) >= 3) +CREATE SCHEMA IF NOT EXISTS a24c; + +CREATE TABLE IF NOT EXISTS a24c.dashboard_users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + full_name VARCHAR(100), + is_active BOOLEAN NOT NULL DEFAULT true, + is_admin BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_access_at TIMESTAMPTZ, + CONSTRAINT chk_dashboard_users_username_length CHECK (char_length(username) >= 3) ); --- Tabla de permisos de usuario a bases de datos -CREATE TABLE IF NOT EXISTS usuario_base_datos ( - id SERIAL PRIMARY KEY, - usuario_id INTEGER NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE, - base_datos_nombre VARCHAR(255) NOT NULL, -- Nombre de la base de datos (visible_name) - puede_ver BOOLEAN DEFAULT true, - puede_descargar_backup BOOLEAN DEFAULT false, - puede_restaurar BOOLEAN DEFAULT false, - fecha_asignacion TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(usuario_id, base_datos_nombre) +CREATE TABLE IF NOT EXISTS a24c.dashboard_user_database_permissions ( + id SERIAL PRIMARY KEY, + dashboard_user_id INTEGER NOT NULL REFERENCES a24c.dashboard_users (id) ON DELETE CASCADE, + database_name VARCHAR(255) NOT NULL, + can_view BOOLEAN NOT NULL DEFAULT true, + can_download_backup BOOLEAN NOT NULL DEFAULT false, + can_restore BOOLEAN NOT NULL DEFAULT false, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (dashboard_user_id, database_name) ); --- Tabla de sesiones -CREATE TABLE IF NOT EXISTS sesiones ( - id SERIAL PRIMARY KEY, - usuario_id INTEGER NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE, - token VARCHAR(255) UNIQUE NOT NULL, - ip_address VARCHAR(50), - user_agent TEXT, - fecha_creacion TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - fecha_expiracion TIMESTAMP NOT NULL, - activa BOOLEAN DEFAULT true +CREATE TABLE IF NOT EXISTS a24c.dashboard_sessions ( + id SERIAL PRIMARY KEY, + dashboard_user_id INTEGER NOT NULL REFERENCES a24c.dashboard_users (id) ON DELETE CASCADE, + token VARCHAR(255) UNIQUE NOT NULL, + ip_address VARCHAR(50), + user_agent TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true ); --- Índices para mejor rendimiento -CREATE INDEX IF NOT EXISTS idx_usuario_base_datos_usuario ON usuario_base_datos(usuario_id); -CREATE INDEX IF NOT EXISTS idx_usuario_base_datos_nombre ON usuario_base_datos(base_datos_nombre); -CREATE INDEX IF NOT EXISTS idx_usuarios_activo ON usuarios(activo); -CREATE INDEX IF NOT EXISTS idx_sesiones_token ON sesiones(token); -CREATE INDEX IF NOT EXISTS idx_sesiones_usuario ON sesiones(usuario_id); +CREATE INDEX IF NOT EXISTS idx_a24c_dashboard_user_db_perm_user + ON a24c.dashboard_user_database_permissions (dashboard_user_id); +CREATE INDEX IF NOT EXISTS idx_a24c_dashboard_user_db_perm_dbname + ON a24c.dashboard_user_database_permissions (database_name); +CREATE INDEX IF NOT EXISTS idx_a24c_dashboard_users_active ON a24c.dashboard_users (is_active); +CREATE INDEX IF NOT EXISTS idx_a24c_dashboard_sessions_token ON a24c.dashboard_sessions (token); +CREATE INDEX IF NOT EXISTS idx_a24c_dashboard_sessions_user ON a24c.dashboard_sessions (dashboard_user_id); --- Insertar usuario admin por defecto --- Password: Admin123! --- Nota: Este hash es temporal, debes cambiarlo después del primer inicio de sesión -INSERT INTO usuarios (username, email, password_hash, nombre_completo, es_admin, activo) +COMMENT ON TABLE a24c.dashboard_users IS 'Operadores del panel Transmitiras (JWT/bcrypt). Paridad api/v1/modules/dashboard/users.'; +COMMENT ON TABLE a24c.dashboard_user_database_permissions IS 'Permisos por base de datos. Paridad dashboard/user_database_permissions.'; +COMMENT ON TABLE a24c.dashboard_sessions IS 'Sesiones persistidas (opcional; el panel usa JWT en cookie). Paridad dashboard/sessions.'; + +-- Usuario admin por defecto (cambiar password_hash tras primer arranque; ver scripts/generate-password-hash.js) +INSERT INTO a24c.dashboard_users (username, email, password_hash, full_name, is_admin, is_active) VALUES ( 'admin', 'admin@aduanasoft.com', @@ -59,8 +62,3 @@ VALUES ( true ) ON CONFLICT (username) DO NOTHING; - --- Comentarios en tablas -COMMENT ON TABLE usuarios IS 'Usuarios del sistema de gestión de bases de datos'; -COMMENT ON TABLE usuario_base_datos IS 'Permisos de acceso de usuarios a bases de datos específicas'; -COMMENT ON TABLE sesiones IS 'Sesiones activas de usuarios'; diff --git a/docker-compose.postgres.yml b/docker-compose.postgres.yml deleted file mode 100644 index 82a2fe3..0000000 --- a/docker-compose.postgres.yml +++ /dev/null @@ -1,52 +0,0 @@ -version: '3.8' - -services: - postgres: - image: postgres:15-alpine - container_name: aduanasoft-postgres - restart: unless-stopped - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: Control. - POSTGRES_DB: CONTROLDESK - PGDATA: /var/lib/postgresql/data/pgdata - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - - ./database/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 10s - timeout: 5s - retries: 5 - networks: - - aduanasoft-network - - # Tu aplicación Node.js (opcional, comentado por ahora) - # app: - # build: . - # container_name: aduanasoft-app - # restart: unless-stopped - # ports: - # - "3000:3000" - # environment: - # - NODE_ENV=production - # - DB_POSTGRES_HOST=postgres - # - DB_POSTGRES_PORT=5432 - # - DB_POSTGRES_USER=postgres - # - DB_POSTGRES_PASS=Control. - # - DB_POSTGRES_DB=CONTROLDESK - # depends_on: - # postgres: - # condition: service_healthy - # networks: - # - aduanasoft-network - -volumes: - postgres_data: - driver: local - -networks: - aduanasoft-network: - driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index ded764b..3b078be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,27 +5,18 @@ services: - "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} + # SQL Server: credenciales globales (server_name / database_name vienen de a24c.database_nodes en Postgres) + - PANEL_MSSQL_USER=${PANEL_MSSQL_USER} + - PANEL_MSSQL_PASSWORD=${PANEL_MSSQL_PASSWORD} + - PANEL_MSSQL_DOCKER=${PANEL_MSSQL_DOCKER:-true} + - IN_DOCKER=true - - 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} - - # PostgreSQL connection (connect to postgres service) + # PostgreSQL: catálogo ControlDesk (a24c.*) + usuarios del panel - DB_POSTGRES_HOST=postgres - DB_POSTGRES_PORT=5432 - DB_POSTGRES_USER=${DB_POSTGRES_USER} - DB_POSTGRES_PASS=${DB_POSTGRES_PASS} - - DB_POSTGRES_DB=${DB_POSTGRES_DB} + - DB_POSTGRES_DB=${DB_POSTGRES_DB} # JWT Secret - JWT_SECRET=${JWT_SECRET} diff --git a/scripts/init-database.js b/scripts/init-database.js index 7d5bfd2..331ce27 100644 --- a/scripts/init-database.js +++ b/scripts/init-database.js @@ -41,17 +41,21 @@ async function initDatabase() { // Verificar las tablas creadas console.log('4. Verificando tablas creadas...'); const result = await client.query(` - SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name IN ('usuarios', 'usuario_base_datos', 'sesiones') + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'a24c' + AND table_name IN ( + 'dashboard_users', + 'dashboard_user_database_permissions', + 'dashboard_sessions' + ) ORDER BY table_name; `); - + if (result.rows.length === 3) { - console.log(' ✓ Tablas verificadas:'); + console.log(' ✓ Tablas verificadas (esquema a24c):'); result.rows.forEach(row => { - console.log(` - ${row.table_name}`); + console.log(` - a24c.${row.table_name}`); }); } else { console.log(' ⚠ No todas las tablas fueron creadas'); @@ -59,13 +63,16 @@ async function initDatabase() { // Verificar usuario admin console.log('\n5. Verificando usuario admin...'); - const userResult = await client.query('SELECT username, email, es_admin FROM usuarios WHERE username = $1', ['admin']); - + const userResult = await client.query( + 'SELECT username, email, is_admin FROM a24c.dashboard_users WHERE username = $1', + ['admin'] + ); + if (userResult.rows.length > 0) { console.log(' ✓ Usuario admin creado:'); console.log(` - Username: ${userResult.rows[0].username}`); console.log(` - Email: ${userResult.rows[0].email}`); - console.log(` - Es Admin: ${userResult.rows[0].es_admin}`); + console.log(` - Es Admin: ${userResult.rows[0].is_admin}`); } else { console.log(' ⚠ Usuario admin no encontrado'); } @@ -79,7 +86,7 @@ async function initDatabase() { console.log('1. Ejecuta: node scripts/generate-password-hash.js'); console.log('2. Copia el hash generado'); console.log('3. Ejecuta este SQL en PostgreSQL:'); - console.log(' UPDATE usuarios SET password_hash = \'TU_HASH_AQUI\' WHERE username = \'admin\';'); + console.log(' UPDATE a24c.dashboard_users SET password_hash = \'TU_HASH_AQUI\' WHERE username = \'admin\';'); console.log('\n4. Luego inicia el servidor: npm run dev'); console.log('5. Ve a: http://localhost:5173/login'); console.log(' Usuario: admin'); diff --git a/src/lib/server/controldesk-pg.ts b/src/lib/server/controldesk-pg.ts new file mode 100644 index 0000000..b6885b4 --- /dev/null +++ b/src/lib/server/controldesk-pg.ts @@ -0,0 +1,335 @@ +/** + * Catálogo ControlDesk en PostgreSQL (esquema a24c; DDL lo aprovisiona otra aplicación). + * Devuelve columnas con alias en español para compatibilidad con la UI existente. + */ +import { env } from '$env/dynamic/private'; +import { pgPool } from './db'; + +function schemaName(): string { + const s = 'a24c'; + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(s)) return 'a24c'; + return s; +} + +function qNodes(): string { + const s = schemaName(); + return `"${s.replace(/"/g, '""')}"."database_nodes"`; +} + +function qUsers(): string { + const s = schemaName(); + return `"${s.replace(/"/g, '""')}"."portal_users"`; +} + +const ROW_DATABASE_NODE = ` + id AS "ID", + node_subnode_key AS "NodoSubNodo", + is_active AS "Activo", + rfc AS "RFC", + legal_name AS "Nombre", + branch_name AS "Sucursal", + notification_email AS "CorreoNotificacion", + server_name AS "ServerName", + database_name AS "BDName" +`; + +const ROW_PORTAL_USER = ` + id AS "ID", + database_node_id AS "IDNodoSubNodo", + is_authority_client AS "ClienteAutoridad", + full_name AS "Nombre", + username AS "Usuario", + bd_shelter AS "BD_Shelter" +`; + +export async function listDatabaseNodes(): Promise { + const sql = `SELECT ${ROW_DATABASE_NODE} FROM ${qNodes()}`; + const r = await pgPool.query(sql); + return r.rows; +} + +export async function listDatabaseNodesActive(): Promise { + const sql = ` + SELECT ${ROW_DATABASE_NODE} + FROM ${qNodes()} + WHERE is_active = 1 + AND database_name IS NOT NULL + AND TRIM(database_name) <> '' + ORDER BY node_subnode_key + `; + const r = await pgPool.query(sql); + return r.rows; +} + +/** Nodos activos con `sql_password` para conectar a SQL Server (no exponer al cliente). */ +export async function listDatabaseNodesForMssql(): Promise { + const where = ` + WHERE is_active = 1 + AND database_name IS NOT NULL + AND TRIM(database_name) <> '' + ORDER BY node_subnode_key + `; + try { + const r = await pgPool.query( + `SELECT ${ROW_DATABASE_NODE}, sql_password AS "sql_password" FROM ${qNodes()} ${where}` + ); + return r.rows; + } catch (e: any) { + if (e?.code === '42703') { + const r = await pgPool.query(`SELECT ${ROW_DATABASE_NODE} FROM ${qNodes()} ${where}`); + return r.rows.map((row: any) => ({ ...row, sql_password: null })); + } + throw e; + } +} + +export async function listClientsCatalog(): Promise { + const sql = ` + SELECT + id AS "ID", + legal_name AS "Nombre", + node_subnode_key AS "NodoSubNodo", + notification_email AS "CorreoNotificacion", + is_active AS "Activo", + database_name AS "BDName" + FROM ${qNodes()} + `; + const r = await pgPool.query(sql); + return r.rows; +} + +export async function listPortalUsers(): Promise { + const sql = `SELECT ${ROW_PORTAL_USER} FROM ${qUsers()} ORDER BY id`; + const r = await pgPool.query(sql); + return r.rows; +} + +export async function lookupNodeByNodoOrBdName(nodoName: string): Promise { + const sql = ` + SELECT + legal_name AS "Nombre", + node_subnode_key AS "NodoSubNodo", + rfc AS "RFC" + FROM ${qNodes()} + WHERE LOWER(TRIM(node_subnode_key)) = LOWER(TRIM($1::text)) + OR LOWER(TRIM(database_name)) = LOWER(TRIM($1::text)) + LIMIT 1 + `; + const r = await pgPool.query(sql, [nodoName]); + return r.rows[0] ?? null; +} + +/** + * Asocia el nombre de archivo de respaldo (sin extensión) a una fila de `database_nodes`. + * Cubre: igualdad sin mayúsculas y prefijos tipo `NODO001_full_20240414` → nodo `NODO001`. + */ +export function matchNodeRowFromBackupStem(stem: string, nodes: any[]): any | null { + const raw = String(stem ?? '').trim(); + if (!raw || !nodes?.length) return null; + const key = raw.toLowerCase(); + + const nodoOf = (row: any) => String(row.NodoSubNodo ?? '').trim().toLowerCase(); + const bdOf = (row: any) => String(row.BDName ?? '').trim().toLowerCase(); + + for (const row of nodes) { + const n = nodoOf(row); + const b = bdOf(row); + if (n && n === key) return row; + if (b && b === key) return row; + } + + type Cand = { row: any; len: number }; + const cands: Cand[] = []; + for (const row of nodes) { + const n = nodoOf(row); + const b = bdOf(row); + if (n && (key === n || key.startsWith(`${n}_`) || key.startsWith(`${n}-`))) { + cands.push({ row, len: n.length }); + } + if (b && b !== n && (key === b || key.startsWith(`${b}_`) || key.startsWith(`${b}-`))) { + cands.push({ row, len: b.length }); + } + } + if (!cands.length) return null; + cands.sort((a, b) => b.len - a.len); + return cands[0].row; +} + +/** Datos de contacto para alertas (equivalente a la consulta previa sobre Usuarios/BasesDeDatos). */ +export async function lookupAlertClientData(nodoName: string): Promise { + const sql = ` + SELECT + pu.is_authority_client AS "ClienteAutoridad", + pu.full_name AS "Nombre", + pu.username AS "Usuario", + dn.notification_email AS "CorreoNotificacion" + FROM ${qUsers()} pu + LEFT JOIN ${qNodes()} dn ON pu.database_node_id = dn.id + WHERE pu.username = $1 + OR dn.database_name = $1 + OR dn.node_subnode_key = $1 + LIMIT 1 + `; + const r = await pgPool.query(sql, [nodoName]); + return r.rows[0] ?? null; +} + +export async function updateNodeActive(id: number, activo: boolean): Promise { + await pgPool.query(`UPDATE ${qNodes()} SET is_active = $1 WHERE id = $2`, [activo ? 1 : 0, id]); +} + +export async function updateNodeLegalName(id: number, nombre: string): Promise { + await pgPool.query(`UPDATE ${qNodes()} SET legal_name = $1 WHERE id = $2`, [nombre, id]); +} + +export async function insertDatabaseNode(row: { + nodoSubNodo: string; + rfc: string; + nombre: string; + sucursal: string; + correo: string; + serverName: string; + bdName: string; + activo: number; +}): Promise { + await pgPool.query( + ` + INSERT INTO ${qNodes()} ( + node_subnode_key, rfc, legal_name, branch_name, + notification_email, server_name, database_name, is_active + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, + [ + row.nodoSubNodo, + row.rfc, + row.nombre, + row.sucursal, + row.correo, + row.serverName, + row.bdName, + row.activo + ] + ); +} + +export async function updateDatabaseNode( + id: number, + row: { + nodoSubNodo: string; + rfc: string; + nombre: string; + sucursal: string; + correo: string; + serverName: string; + bdName: string; + activo: number; + } +): Promise { + await pgPool.query( + ` + UPDATE ${qNodes()} SET + node_subnode_key = $1, + rfc = $2, + legal_name = $3, + branch_name = $4, + notification_email = $5, + server_name = $6, + database_name = $7, + is_active = $8 + WHERE id = $9 + `, + [ + row.nodoSubNodo, + row.rfc, + row.nombre, + row.sucursal, + row.correo, + row.serverName, + row.bdName, + row.activo, + id + ] + ); +} + +export async function deleteDatabaseNode(id: number): Promise { + await pgPool.query(`DELETE FROM ${qNodes()} WHERE id = $1`, [id]); +} + +export async function insertPortalUser(row: { + databaseNodeId: number; + isAuthorityClient: number; + fullName: string; + username: string; + passwordHash: string; + bdShelter: string | null; +}): Promise { + await pgPool.query( + ` + INSERT INTO ${qUsers()} ( + database_node_id, is_authority_client, full_name, username, password_hash, bd_shelter + ) VALUES ($1, $2, $3, $4, $5, $6) + `, + [ + row.databaseNodeId, + row.isAuthorityClient, + row.fullName, + row.username, + row.passwordHash, + row.bdShelter + ] + ); +} + +export async function updatePortalUser( + id: number, + row: { + databaseNodeId: number; + isAuthorityClient: number; + fullName: string; + username: string; + bdShelter: string | null; + passwordHash?: string; + } +): Promise { + if (row.passwordHash !== undefined) { + await pgPool.query( + ` + UPDATE ${qUsers()} SET + database_node_id = $1, + is_authority_client = $2, + full_name = $3, + username = $4, + password_hash = $5, + bd_shelter = $6 + WHERE id = $7 + `, + [ + row.databaseNodeId, + row.isAuthorityClient, + row.fullName, + row.username, + row.passwordHash, + row.bdShelter, + id + ] + ); + } else { + await pgPool.query( + ` + UPDATE ${qUsers()} SET + database_node_id = $1, + is_authority_client = $2, + full_name = $3, + username = $4, + bd_shelter = $5 + WHERE id = $6 + `, + [row.databaseNodeId, row.isAuthorityClient, row.fullName, row.username, row.bdShelter, id] + ); + } +} + +export async function deletePortalUser(id: number): Promise { + await pgPool.query(`DELETE FROM ${qUsers()} WHERE id = $1`, [id]); +} diff --git a/src/lib/server/dashboard-pg.ts b/src/lib/server/dashboard-pg.ts new file mode 100644 index 0000000..f0e328c --- /dev/null +++ b/src/lib/server/dashboard-pg.ts @@ -0,0 +1,21 @@ +/** + * Tablas del panel Transmitiras en PostgreSQL (esquema a24c), alineadas con + * ~/dev/a24c/backend/api/v1/modules/dashboard (inglés). + */ +import { env } from '$env/dynamic/private'; + +export function dashboardSchema(): string { + const s = env.DB_CONTROLDESK_SCHEMA || 'a24c'; + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(s)) return 'a24c'; + return s; +} + +function qTable(table: string): string { + const s = dashboardSchema().replace(/"/g, '""'); + const t = table.replace(/"/g, '""'); + return `"${s}"."${t}"`; +} + +export const tableDashboardUsers = () => qTable('dashboard_users'); +export const tableDashboardUserDbPerms = () => qTable('dashboard_user_database_permissions'); +export const tableDashboardSessions = () => qTable('dashboard_sessions'); diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index bbed80b..552dacf 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -1,4 +1,3 @@ -import sql from 'mssql'; import pkg from 'pg'; const { Pool } = pkg; import { env } from '$env/dynamic/private'; @@ -15,66 +14,11 @@ const pgPool = new Pool({ 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, - trustServerCertificate: true - } -}; - -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 { - if (this.primaryPool?.connected) return this.primaryPool; - this.primaryPool = await new sql.ConnectionPool(primaryConfig).connect(); - return this.primaryPool; - } - - async getSecondary(): Promise { - if (this.secondaryPool?.connected) return this.secondaryPool; - this.secondaryPool = await new sql.ConnectionPool(secondaryConfig).connect(); - return this.secondaryPool; - } - - async getAzure(): Promise { - if (this.azurePool?.connected) return this.azurePool; - this.azurePool = await new sql.ConnectionPool(azureConfig).connect(); - return this.azurePool; - } - async getPostgres() { return pgPool.connect(); } } export const db = new Database(); -export { sql, pgPool }; +export { pgPool }; diff --git a/src/lib/server/mssql-nodes.ts b/src/lib/server/mssql-nodes.ts new file mode 100644 index 0000000..279b935 --- /dev/null +++ b/src/lib/server/mssql-nodes.ts @@ -0,0 +1,288 @@ +/** + * Conexiones SQL Server por nodo (paridad con a24c: server_name / database_name en database_nodes, + * usuario global PANEL_MSSQL_USER, contraseña por nodo sql_password o PANEL_MSSQL_PASSWORD). + */ +import sql from 'mssql'; +import { env } from '$env/dynamic/private'; + +const MAX_POOLS = 16; +const poolMap = new Map(); + +export function adjustMssqlServerForDocker(serverName: string): string { + const docker = + String(env.PANEL_MSSQL_DOCKER || '') + .trim() + .toLowerCase() === 'true' || + String(env.IN_DOCKER || '') + .trim() + .toLowerCase() === 'true'; + if (!docker) return serverName.trim(); + const parts = serverName.trim().split(',', 2); + const host = (parts[0] || '').trim().toLowerCase(); + if (host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0') { + const port = parts[1]?.trim(); + return port ? `host.docker.internal,${port}` : 'host.docker.internal'; + } + return serverName.trim(); +} + +export function resolveMssqlUser(): string { + return ( + String(env.PANEL_MSSQL_USER || '').trim() + ); +} + +/** Contraseña SQL: columna sql_password del nodo (texto plano) o variable global. */ +export function resolveNodeSqlPassword(nodeSqlPassword: string | null | undefined): string { + const raw = nodeSqlPassword != null ? String(nodeSqlPassword).trim() : ''; + if (raw) return raw; + return String( + env.PANEL_MSSQL_PASSWORD || '' + ).trim(); +} + +function poolCacheKey(server: string, user: string, password: string): string { + return `${server}\t${user}\t${password}`; +} + +async function evictPoolIfNeeded(): Promise { + while (poolMap.size >= MAX_POOLS) { + const first = poolMap.keys().next().value as string | undefined; + if (!first) break; + const old = poolMap.get(first); + poolMap.delete(first); + try { + await old?.close(); + } catch { + /* ignore */ + } + } +} + +/** + * Pool conectado a `master` en el servidor del nodo (permite consultar cualquier BD con nombre de tres partes). + */ +export async function getMssqlPoolMaster(serverHost: string, password: string): Promise { + const user = resolveMssqlUser(); + if (!user || !password) { + throw new Error( + 'Falta PANEL_MSSQL_USER / PANEL_MSSQL_PASSWORD (o sql_password en database_nodes).' + ); + } + const server = adjustMssqlServerForDocker(serverHost); + const key = poolCacheKey(server, user, password); + const existing = poolMap.get(key); + if (existing?.connected) return existing; + + await evictPoolIfNeeded(); + + const cfg: sql.config = { + user, + password, + server, + database: 'master', + options: { + encrypt: true, + trustServerCertificate: true, + connectTimeout: 30000 + } + }; + const pool = await new sql.ConnectionPool(cfg).connect(); + poolMap.set(key, pool); + return pool; +} + +export type CatalogNodeRow = { + ID: number; + ServerName: string; + BDName: string; + NodoSubNodo: string; + Nombre: string; + Activo?: number; + sql_password?: string | null; +}; + +/** Métricas de una base en un servidor (conexión a master). */ +export async function queryDatabaseMetricsOnServer( + pool: sql.ConnectionPool, + databaseName: string +): Promise { + const req = pool.request(); + req.input('dbname', sql.VarChar(100), databaseName); + // No unir restorehistory en el mismo SELECT que agrega mf.size: cada fila de + // restorehistory duplicaría los archivos y SUM inflaría el tamaño (p. ej. cientos de TB). + const result = await req.query(` + SELECT + d.name AS visible_name, + d.name AS original_name, + CAST(( + SELECT SUM(mf2.size) * 8.0 / 1024 + FROM sys.master_files mf2 + WHERE mf2.database_id = d.database_id + ) AS DECIMAL(10,2)) AS size_mb, + CAST(( + SELECT SUM(mf2.size) * 8.0 / 1024 / 1024 + FROM sys.master_files mf2 + WHERE mf2.database_id = d.database_id + ) AS DECIMAL(10,2)) AS total_size_gb, + ( + SELECT MAX(rh.restore_date) + FROM msdb.dbo.restorehistory rh + WHERE rh.destination_database_name = d.name + ) AS last_restore_date, + d.create_date, + d.state_desc, + d.recovery_model_desc + FROM sys.databases d + WHERE d.name = @dbname + `); + const row = result.recordset?.[0]; + return row ?? null; +} + +/** Alertas: misma regla que antes (sin restore reciente o null). */ +export async function queryDatabaseAlertRow( + pool: sql.ConnectionPool, + databaseName: string +): Promise<{ visible_name: string; last_restore_date: Date | null } | null> { + const req = pool.request(); + req.input('dbname', sql.VarChar(100), databaseName); + const result = await req.query(` + 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 = @dbname + GROUP BY d.name + HAVING MAX(rh.restore_date) < DATEADD(DAY, -2, GETDATE()) OR MAX(rh.restore_date) IS NULL + `); + const row = result.recordset?.[0]; + return row + ? { + visible_name: String(row.visible_name), + last_restore_date: row.last_restore_date ?? null + } + : null; +} + +export async function queryRestoreHistoryForDatabase( + pool: sql.ConnectionPool, + databaseName: string +): Promise<{ restore_date: Date }[]> { + const req = pool.request(); + req.input('dbname', sql.VarChar(100), databaseName); + const result = await req.query(` + SELECT restore_date + FROM msdb.dbo.restorehistory + WHERE destination_database_name = @dbname + AND restore_date >= DATEADD(DAY, -120, GETDATE()) + `); + return (result.recordset as any[]).map((r) => ({ restore_date: r.restore_date })); +} + +function computeEffectivenessFromHistory( + restoreHistory: Record +): Record { + const effectivenessByDb: Record = {}; + const now = new Date(); + const currentYear = now.getFullYear(); + const currentMonth = now.getMonth(); + 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; totalDays: number }; + } = {}; + for (const m of monthsToInclude) { + const monthKey = `${currentYear}-${String(m + 1).padStart(2, '0')}`; + monthly[monthKey] = { + daysWithRestore: new Set(), + 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)) }; + }); + } + return effectivenessByDb; +} + +export type SqlDashboardBundle = { + databaseRows: any[]; + summaryMain: { total_databases: number; total_size_gb: number }; + alertsData: any[]; + restoreHistory: Record; + effectivenessByDb: Record; +}; + +/** + * Recorre nodos activos del catálogo y consulta SQL Server en server_name (BD = database_name). + */ +export async function loadSqlDashboardFromNodes(nodes: CatalogNodeRow[]): Promise { + const databaseRows: any[] = []; + const alertsData: any[] = []; + const restoreHistory: Record = {}; + let totalSizeGb = 0; + + for (const node of nodes) { + const dbn = String(node.BDName || '').trim(); + if (!dbn) continue; + const pwd = resolveNodeSqlPassword(node.sql_password); + try { + const pool = await getMssqlPoolMaster(String(node.ServerName || '').trim(), pwd); + const row = await queryDatabaseMetricsOnServer(pool, dbn); + if (!row) continue; + + const visible = String(row.visible_name ?? dbn); + const keyLower = visible.toLowerCase(); + + databaseRows.push({ + ...row, + visible_name: visible, + original_name: row.original_name ?? visible, + NodoSubNodo: node.NodoSubNodo, + client_name: node.Nombre, + BDName: dbn, + _node_id: node.ID, + _server: adjustMssqlServerForDocker(String(node.ServerName || '').trim()) + }); + + totalSizeGb += Number(row.total_size_gb) || 0; + + const alertRow = await queryDatabaseAlertRow(pool, dbn); + if (alertRow) alertsData.push(alertRow); + + const hist = await queryRestoreHistoryForDatabase(pool, dbn); + if (!restoreHistory[keyLower]) restoreHistory[keyLower] = []; + for (const h of hist) { + restoreHistory[keyLower].push(h); + } + } catch (e) { + console.error( + `SQL Server nodo id=${node.ID} server=${node.ServerName} db=${dbn}:`, + e + ); + } + } + + const summaryMain = { + total_databases: databaseRows.length, + total_size_gb: Math.round(totalSizeGb * 100) / 100 + }; + + const effectivenessByDb = computeEffectivenessFromHistory(restoreHistory); + + return { databaseRows, summaryMain, alertsData, restoreHistory, effectivenessByDb }; +} diff --git a/src/lib/server/users.ts b/src/lib/server/users.ts index 1872b3b..5062b1f 100644 --- a/src/lib/server/users.ts +++ b/src/lib/server/users.ts @@ -1,15 +1,48 @@ import { pgPool } from './db'; import { hashPassword, verifyPassword, type Usuario } from './auth'; -import type { PoolClient } from 'pg'; +import { + tableDashboardUsers, + tableDashboardUserDbPerms +} from './dashboard-pg'; + +/** Mapea fila SQL (alias español opcional) al tipo UI. */ +function rowToUsuario(row: Record): Usuario { + return { + id: row.id as number, + username: row.username as string, + email: (row.email as string) ?? '', + nombre_completo: (row.nombre_completo ?? row.full_name ?? '') as string, + activo: (row.activo ?? row.is_active) as boolean, + es_admin: (row.es_admin ?? row.is_admin) as boolean + }; +} + +/** Solo para login (incluye hash). */ +const SELECT_USER_LOGIN = ` + id, username, email, password_hash, + full_name AS nombre_completo, + is_active AS activo, + is_admin AS es_admin +`; + +const SELECT_USER_PUBLIC = ` + id, username, email, + full_name AS nombre_completo, + is_active AS activo, + is_admin AS es_admin +`; /** * Autenticar usuario por username y password */ export async function authenticateUser(username: string, password: string): Promise { const client = await pgPool.connect(); + const t = tableDashboardUsers(); 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', + `SELECT ${SELECT_USER_LOGIN} + FROM ${t} + WHERE username = $1 AND is_active = true`, [username] ); @@ -24,20 +57,11 @@ export async function authenticateUser(username: string, password: string): Prom return null; } - // Actualizar último acceso - await client.query( - 'UPDATE usuarios SET ultimo_acceso = CURRENT_TIMESTAMP WHERE id = $1', - [user.id] - ); + await client.query(`UPDATE ${t} SET last_access_at = 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 - }; + return rowToUsuario(user); } finally { client.release(); } @@ -48,9 +72,10 @@ export async function authenticateUser(username: string, password: string): Prom */ export async function getUserById(userId: number): Promise { const client = await pgPool.connect(); + const t = tableDashboardUsers(); try { const result = await client.query( - 'SELECT id, username, email, nombre_completo, activo, es_admin FROM usuarios WHERE id = $1', + `SELECT ${SELECT_USER_PUBLIC} FROM ${t} WHERE id = $1`, [userId] ); @@ -58,7 +83,7 @@ export async function getUserById(userId: number): Promise { return null; } - return result.rows[0]; + return rowToUsuario(result.rows[0]); } finally { client.release(); } @@ -75,17 +100,24 @@ export async function createUser(data: { es_admin?: boolean; }): Promise { const client = await pgPool.connect(); + const t = tableDashboardUsers(); 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] + `INSERT INTO ${t} (username, email, password_hash, full_name, is_admin, is_active) + VALUES ($1, $2, $3, $4, $5, true) + RETURNING ${SELECT_USER_PUBLIC}`, + [ + data.username, + data.email, + passwordHash, + data.nombre_completo || '', + data.es_admin || false + ] ); - return result.rows[0]; + return rowToUsuario(result.rows[0]); } finally { client.release(); } @@ -105,9 +137,10 @@ export async function updateUser( } ): Promise { const client = await pgPool.connect(); + const t = tableDashboardUsers(); try { const updates: string[] = []; - const values: any[] = []; + const values: unknown[] = []; let paramIndex = 1; if (data.email !== undefined) { @@ -116,17 +149,17 @@ export async function updateUser( } if (data.nombre_completo !== undefined) { - updates.push(`nombre_completo = $${paramIndex++}`); + updates.push(`full_name = $${paramIndex++}`); values.push(data.nombre_completo); } if (data.activo !== undefined) { - updates.push(`activo = $${paramIndex++}`); + updates.push(`is_active = $${paramIndex++}`); values.push(data.activo); } if (data.es_admin !== undefined) { - updates.push(`es_admin = $${paramIndex++}`); + updates.push(`is_admin = $${paramIndex++}`); values.push(data.es_admin); } @@ -143,14 +176,14 @@ export async function updateUser( values.push(userId); const result = await client.query( - `UPDATE usuarios + `UPDATE ${t} SET ${updates.join(', ')} WHERE id = $${paramIndex} - RETURNING id, username, email, nombre_completo, activo, es_admin`, + RETURNING ${SELECT_USER_PUBLIC}`, values ); - return result.rows[0] || null; + return result.rows[0] ? rowToUsuario(result.rows[0]) : null; } finally { client.release(); } @@ -161,8 +194,9 @@ export async function updateUser( */ export async function deleteUser(userId: number): Promise { const client = await pgPool.connect(); + const t = tableDashboardUsers(); try { - const result = await client.query('DELETE FROM usuarios WHERE id = $1', [userId]); + const result = await client.query(`DELETE FROM ${t} WHERE id = $1`, [userId]); return result.rowCount ? result.rowCount > 0 : false; } finally { client.release(); @@ -174,11 +208,12 @@ export async function deleteUser(userId: number): Promise { */ export async function listUsers(): Promise { const client = await pgPool.connect(); + const t = tableDashboardUsers(); try { const result = await client.query( - 'SELECT id, username, email, nombre_completo, activo, es_admin FROM usuarios ORDER BY id DESC' + `SELECT ${SELECT_USER_PUBLIC} FROM ${t} ORDER BY id DESC` ); - return result.rows; + return result.rows.map(rowToUsuario); } finally { client.release(); } @@ -189,12 +224,13 @@ export async function listUsers(): Promise { */ export async function getUserDatabasePermissions(userId: number): Promise { const client = await pgPool.connect(); + const t = tableDashboardUserDbPerms(); try { const result = await client.query( - 'SELECT base_datos_nombre FROM usuario_base_datos WHERE usuario_id = $1 AND puede_ver = true', + `SELECT database_name FROM ${t} WHERE dashboard_user_id = $1 AND can_view = true`, [userId] ); - return result.rows.map(row => row.base_datos_nombre); + return result.rows.map((row) => row.database_name as string); } finally { client.release(); } @@ -213,15 +249,17 @@ export async function assignDatabaseToUser( } = {} ): Promise { const client = await pgPool.connect(); + const t = tableDashboardUserDbPerms(); 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) + `INSERT INTO ${t} ( + dashboard_user_id, database_name, can_view, can_download_backup, can_restore + ) VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (dashboard_user_id, database_name) DO UPDATE SET - puede_ver = $3, - puede_descargar_backup = $4, - puede_restaurar = $5`, + can_view = EXCLUDED.can_view, + can_download_backup = EXCLUDED.can_download_backup, + can_restore = EXCLUDED.can_restore`, [ userId, baseDatosNombre, @@ -240,9 +278,10 @@ export async function assignDatabaseToUser( */ export async function removeDatabaseFromUser(userId: number, baseDatosNombre: string): Promise { const client = await pgPool.connect(); + const t = tableDashboardUserDbPerms(); try { await client.query( - 'DELETE FROM usuario_base_datos WHERE usuario_id = $1 AND base_datos_nombre = $2', + `DELETE FROM ${t} WHERE dashboard_user_id = $1 AND database_name = $2`, [userId, baseDatosNombre] ); } finally { @@ -255,24 +294,21 @@ export async function removeDatabaseFromUser(userId: number, baseDatosNombre: st */ export async function userCanViewDatabase(userId: number, baseDatosNombre: string): Promise { const client = await pgPool.connect(); + const tu = tableDashboardUsers(); + const tp = tableDashboardUserDbPerms(); try { - // Los admins pueden ver todo - const userResult = await client.query( - 'SELECT es_admin FROM usuarios WHERE id = $1', - [userId] - ); + const userResult = await client.query(`SELECT is_admin FROM ${tu} WHERE id = $1`, [userId]); - if (userResult.rows.length > 0 && userResult.rows[0].es_admin) { + if (userResult.rows.length > 0 && userResult.rows[0].is_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', + `SELECT can_view FROM ${tp} WHERE dashboard_user_id = $1 AND database_name = $2`, [userId, baseDatosNombre] ); - return permResult.rows.length > 0 && permResult.rows[0].puede_ver; + return permResult.rows.length > 0 && permResult.rows[0].can_view; } finally { client.release(); } @@ -286,26 +322,25 @@ export async function filterDatabasesByUserPermissions { const client = await pgPool.connect(); + const tu = tableDashboardUsers(); + const tp = tableDashboardUserDbPerms(); try { - // Los admins ven todo - const userResult = await client.query( - 'SELECT es_admin FROM usuarios WHERE id = $1', - [userId] - ); + const userResult = await client.query(`SELECT is_admin FROM ${tu} WHERE id = $1`, [userId]); - if (userResult.rows.length > 0 && userResult.rows[0].es_admin) { + if (userResult.rows.length > 0 && userResult.rows[0].is_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', + `SELECT database_name FROM ${tp} WHERE dashboard_user_id = $1 AND can_view = true`, [userId] ); - const allowedDatabases = new Set(permResult.rows.map(row => row.base_datos_nombre.toLowerCase())); + const allowedDatabases = new Set( + permResult.rows.map((row) => String(row.database_name).toLowerCase()) + ); - return databases.filter(db => allowedDatabases.has(db.visible_name.toLowerCase())); + return databases.filter((db) => allowedDatabases.has(db.visible_name.toLowerCase())); } finally { client.release(); } diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 8b5af20..82c39c8 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,4 +1,3 @@ -import { db } from '$lib/server/db'; import { env } from '$env/dynamic/private'; import fs from 'node:fs/promises'; import path from 'node:path'; @@ -7,6 +6,28 @@ import { redirect } from '@sveltejs/kit'; import type { PageServerLoad, Actions } from './$types'; import { verifyToken } from '$lib/server/auth'; import { getUserById, filterDatabasesByUserPermissions } from '$lib/server/users'; +import { loadSqlDashboardFromNodes, type CatalogNodeRow } from '$lib/server/mssql-nodes'; +import { + listClientsCatalog, + listDatabaseNodes, + listDatabaseNodesForMssql, + listPortalUsers, + matchNodeRowFromBackupStem, + lookupAlertClientData, + updateNodeActive, + updateNodeLegalName, + insertDatabaseNode, + updateDatabaseNode, + deleteDatabaseNode, + insertPortalUser, + updatePortalUser, + deletePortalUser +} from '$lib/server/controldesk-pg'; + +function parseActivoField(formData: FormData): number { + const v = formData.get('Activo'); + return v === 'true' || v === '1' ? 1 : 0; +} // Helper to check disk space (Simple Windows implementation) // Note: In production, consider a specialized library @@ -40,7 +61,7 @@ export const load: PageServerLoad = async ({ cookies }) => { // Initialize result containers let databaseRows: any[] = []; - let summaryMain: any = null; + let summaryMain: any = { total_databases: 0, total_size_gb: 0 }; let restoredCount = 0; let notRestoredCount = 0; @@ -63,198 +84,39 @@ export const load: PageServerLoad = async ({ cookies }) => { backups: null as string | null }; - // --- 1. Load Primary Data (Servidor 152 - Bases Restauradas) --- + // --- 1. SQL Server: una conexión por servidor (master) según a24c.database_nodes --- + let nodesForSql: CatalogNodeRow[] = []; 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; 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(), - 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)) }; - } - ); - } - + nodesForSql = (await listDatabaseNodesForMssql()) as CatalogNodeRow[]; } catch (e: any) { - console.error("Error loading Primary DB data:", e); - errors.primary = `Error conectando al servidor Local (SQL Express): ${e.message}`; + console.error('Error leyendo database_nodes para SQL Server:', e); + errors.primary = `PostgreSQL / database_nodes: ${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]; - + const bundle = await loadSqlDashboardFromNodes(nodesForSql); + databaseRows = bundle.databaseRows; + summaryMain = bundle.summaryMain; + alertsData = bundle.alertsData; + restoreHistory = bundle.restoreHistory; + effectivenessByDb = bundle.effectivenessByDb; + databaseRowsAZ = [...bundle.databaseRows]; + summaryAZ = { ...bundle.summaryMain }; } catch (e: any) { - console.error("Error loading Secondary DB data:", e); - errors.secondary = `Error conectando al servidor Secundario (Todas las Bases): ${e.message}`; + console.error('Error métricas SQL Server por nodo:', e); + errors.primary = `${errors.primary ? errors.primary + ' · ' : ''}SQL Server (nodos): ${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; + // --- 3. Catálogo ControlDesk (PostgreSQL, esquema a24c) --- + let controlDeskOk = false; 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; - - // Usuarios de la BD MSSQL (tabla CONTROLDESK.dbo.Usuarios) - const usuariosQuery = `SELECT ID, IDNodoSubNodo, ClienteAutoridad, Nombre, Usuario, BD_Shelter FROM [CONTROLDESK].[dbo].[Usuarios] ORDER BY ID`; - const resUsuarios = await azure.request().query(usuariosQuery); - usuariosList = resUsuarios.recordset; - + clientsData = await listClientsCatalog(); + basesDeDatosList = await listDatabaseNodes(); + usuariosList = await listPortalUsers(); + controlDeskOk = true; } catch (e: any) { - console.error("Error loading Azure/ControlDesk data:", e); - errors.azure = `Error conectando a Azure/ControlDesk: ${e.message}`; + console.error('Error loading ControlDesk (PostgreSQL):', e); + errors.azure = `Error conectando al catálogo ControlDesk (PostgreSQL): ${e.message}`; } // --- 4. Process Backups, Hydrate Alerts & Enriquecer databaseRows con datos de BasesDeDatos --- @@ -279,24 +141,17 @@ export const load: PageServerLoad = async ({ cookies }) => { const nodoName = path.parse(file).name; let clientData: any = null; - // Try to fetch metadata if azure DB is available - if (azure) { + if (controlDeskOk && basesDeDatosList.length) { 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 {} + clientData = matchNodeRowFromBackupStem(nodoName, basesDeDatosList); + } catch { + /* ignore */ + } } backupFiles.push({ name: file, - nodo_name: nodoName, + nodo_name: (clientData?.NodoSubNodo as string | undefined) || nodoName, client_name: clientData?.Nombre ?? 'Cliente no identificado', client_authority: clientData?.RFC ?? 'N/A', bd_shelter: 'N/A', @@ -312,38 +167,26 @@ export const load: PageServerLoad = async ({ cookies }) => { return db - da; }); - // Hydrate Alerts if possible - if (azure && alertsData.length > 0) { + if (controlDeskOk && 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 {} - + cData = await lookupAlertClientData(String(nodoName)); + } catch { + /* ignore */ + } + hydratedAlerts.push({ ...alert, clientData: cData }); } alertsData = hydratedAlerts; } - // Enriquecer databaseRows (servidor local restaurado) con NodoSubNodo y Nombre desde BasesDeDatos - if (azure && databaseRows.length > 0) { + // Enriquecer databaseRows con catálogo ControlDesk (PostgreSQL) + if (controlDeskOk && 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 bases = (await listDatabaseNodes()) as any[]; const mapByBdName = new Map(); const mapByNodo = new Map(); @@ -467,22 +310,13 @@ export const actions: Actions = { const activoRaw = formData.get('activo'); if (!idRaw || !activoRaw) { - return { success: false, message: 'Pare1metros incompletos' }; + return { success: false, message: 'Parámetros incompletos' }; } const id = Number(idRaw); const activo = activoRaw === 'true'; - const azure = await db.getAzure(); - const req = azure.request(); - req.input('id', id); - req.input('activo', activo ? 1 : 0); - - await req.query(` - UPDATE [CONTROLDESK].[dbo].[BasesdeDatos] - SET Activo = @activo - WHERE ID = @id - `); + await updateNodeActive(id, activo); return { success: true }; } catch (e: any) { @@ -498,26 +332,17 @@ export const actions: Actions = { const nombreRaw = formData.get('nombre'); if (!idRaw || !nombreRaw) { - return { success: false, message: 'Pare1metros incompletos' }; + return { success: false, message: 'Parámetros incompletos' }; } const id = Number(idRaw); const nombre = String(nombreRaw).trim(); if (!nombre) { - return { success: false, message: 'El nombre no puede estar vacedo' }; + return { success: false, message: 'El nombre no puede estar vacío' }; } - const azure = await db.getAzure(); - const req = azure.request(); - req.input('id', id); - req.input('nombre', nombre); - - await req.query(` - UPDATE [CONTROLDESK].[dbo].[BasesdeDatos] - SET Nombre = @nombre - WHERE ID = @id - `); + await updateNodeLegalName(id, nombre); return { success: true }; } catch (e: any) { @@ -536,28 +361,22 @@ export const actions: Actions = { const correo = String(formData.get('CorreoNotificacion') || '').trim(); const serverName = String(formData.get('ServerName') || '').trim(); const bdName = String(formData.get('BDName') || '').trim(); - const activo = formData.get('Activo') === 'true' ? 1 : 0; + const activo = parseActivoField(formData); if (!nodoSubNodo || !rfc || !nombre || !sucursal || !correo || !serverName || !bdName) { return { success: false, message: 'Todos los campos son requeridos' }; } - const azure = await db.getAzure(); - const req = azure.request(); - req.input('nodoSubNodo', nodoSubNodo); - req.input('rfc', rfc); - req.input('nombre', nombre); - req.input('sucursal', sucursal); - req.input('correo', correo); - req.input('serverName', serverName); - req.input('bdName', bdName); - req.input('activo', activo); - - await req.query(` - INSERT INTO [CONTROLDESK].[dbo].[BasesDeDatos] - (NodoSubNodo, RFC, Nombre, Sucursal, CorreoNotificacion, ServerName, BDName, Activo) - VALUES (@nodoSubNodo, @rfc, @nombre, @sucursal, @correo, @serverName, @bdName, @activo) - `); + await insertDatabaseNode({ + nodoSubNodo, + rfc, + nombre, + sucursal, + correo, + serverName, + bdName, + activo + }); return { success: true }; } catch (e: any) { @@ -577,36 +396,22 @@ export const actions: Actions = { const correo = String(formData.get('CorreoNotificacion') || '').trim(); const serverName = String(formData.get('ServerName') || '').trim(); const bdName = String(formData.get('BDName') || '').trim(); - const activo = formData.get('Activo') === 'true' ? 1 : 0; + const activo = parseActivoField(formData); if (!id || !nodoSubNodo || !rfc || !nombre || !sucursal || !correo || !serverName || !bdName) { return { success: false, message: 'Todos los campos son requeridos' }; } - const azure = await db.getAzure(); - const req = azure.request(); - req.input('id', id); - req.input('nodoSubNodo', nodoSubNodo); - req.input('rfc', rfc); - req.input('nombre', nombre); - req.input('sucursal', sucursal); - req.input('correo', correo); - req.input('serverName', serverName); - req.input('bdName', bdName); - req.input('activo', activo); - - await req.query(` - UPDATE [CONTROLDESK].[dbo].[BasesDeDatos] - SET NodoSubNodo = @nodoSubNodo, - RFC = @rfc, - Nombre = @nombre, - Sucursal = @sucursal, - CorreoNotificacion = @correo, - ServerName = @serverName, - BDName = @bdName, - Activo = @activo - WHERE ID = @id - `); + await updateDatabaseNode(id, { + nodoSubNodo, + rfc, + nombre, + sucursal, + correo, + serverName, + bdName, + activo + }); return { success: true }; } catch (e: any) { @@ -624,14 +429,7 @@ export const actions: Actions = { return { success: false, message: 'ID requerido' }; } - const azure = await db.getAzure(); - const req = azure.request(); - req.input('id', id); - - await req.query(` - DELETE FROM [CONTROLDESK].[dbo].[BasesDeDatos] - WHERE ID = @id - `); + await deleteDatabaseNode(id); return { success: true }; } catch (e: any) { @@ -640,21 +438,24 @@ export const actions: Actions = { } }, - // ---- Acciones para CONTROLDESK.dbo.Usuarios ---- + // ---- Acciones para a24c.portal_users (antes CONTROLDESK.dbo.Usuarios) ---- createUsuario: async ({ request }) => { try { const formData = await request.formData(); const idNodoSubNodo = Number(formData.get('IDNodoSubNodo')); - const clienteAutoridad = Number(formData.get('ClienteAutoridad')); + const clienteAutoridad = Number(formData.get('ClienteAutoridad') ?? 0); const nombre = String(formData.get('Nombre') || '').trim(); const usuario = String(formData.get('Usuario') || '').trim(); const password = String(formData.get('Password') || ''); const bdShelter = String(formData.get('BD_Shelter') || '').trim() || null; - if (!idNodoSubNodo || !clienteAutoridad || !nombre || !usuario || !password) { + if (!Number.isFinite(idNodoSubNodo) || idNodoSubNodo <= 0 || !nombre || !usuario || !password) { return { success: false, message: 'Todos los campos obligatorios deben completarse' }; } + if (!Number.isFinite(clienteAutoridad)) { + return { success: false, message: 'Cliente autoridad inválido' }; + } // Hash WinDev-compatible: SHA-512(password + "soluciones"), hex UPPERCASE const passwordHash = crypto @@ -663,21 +464,14 @@ export const actions: Actions = { .digest('hex') .toUpperCase(); - const azure = await db.getAzure(); - const req = azure.request(); - req.input('idNodo', idNodoSubNodo); - req.input('clienteAut', clienteAutoridad); - req.input('nombre', nombre); - req.input('usuario', usuario); - req.input('passwordHash', passwordHash); - req.input('bdShelter', bdShelter); - - await req.query(` - INSERT INTO [CONTROLDESK].[dbo].[Usuarios] - (IDNodoSubNodo, ClienteAutoridad, Nombre, Usuario, [Password], BD_Shelter) - VALUES - (@idNodo, @clienteAut, @nombre, @usuario, @passwordHash, @bdShelter) - `); + await insertPortalUser({ + databaseNodeId: idNodoSubNodo, + isAuthorityClient: clienteAutoridad, + fullName: nombre, + username: usuario, + passwordHash, + bdShelter + }); return { success: true }; } catch (e: any) { @@ -691,53 +485,41 @@ export const actions: Actions = { const formData = await request.formData(); const id = Number(formData.get('ID')); const idNodoSubNodo = Number(formData.get('IDNodoSubNodo')); - const clienteAutoridad = Number(formData.get('ClienteAutoridad')); + const clienteAutoridad = Number(formData.get('ClienteAutoridad') ?? 0); const nombre = String(formData.get('Nombre') || '').trim(); const usuario = String(formData.get('Usuario') || '').trim(); const password = String(formData.get('Password') || ''); const bdShelter = String(formData.get('BD_Shelter') || '').trim() || null; - if (!id || !idNodoSubNodo || !clienteAutoridad || !nombre || !usuario) { + if (!id || !Number.isFinite(idNodoSubNodo) || idNodoSubNodo <= 0 || !nombre || !usuario) { return { success: false, message: 'Todos los campos obligatorios deben completarse' }; } - - const azure = await db.getAzure(); - const req = azure.request(); - req.input('id', id); - req.input('idNodo', idNodoSubNodo); - req.input('clienteAut', clienteAutoridad); - req.input('nombre', nombre); - req.input('usuario', usuario); - req.input('bdShelter', bdShelter); + if (!Number.isFinite(clienteAutoridad)) { + return { success: false, message: 'Cliente autoridad inválido' }; + } if (password) { - // Hash WinDev-compatible: SHA-512(password + "soluciones"), hex UPPERCASE const passwordHash = crypto .createHash('sha512') .update(password + 'soluciones', 'utf8') .digest('hex') .toUpperCase(); - req.input('passwordHash', passwordHash); - await req.query(` - UPDATE [CONTROLDESK].[dbo].[Usuarios] - SET IDNodoSubNodo = @idNodo, - ClienteAutoridad = @clienteAut, - Nombre = @nombre, - Usuario = @usuario, - [Password] = @passwordHash, - BD_Shelter = @bdShelter - WHERE ID = @id - `); + await updatePortalUser(id, { + databaseNodeId: idNodoSubNodo, + isAuthorityClient: clienteAutoridad, + fullName: nombre, + username: usuario, + bdShelter, + passwordHash + }); } else { - await req.query(` - UPDATE [CONTROLDESK].[dbo].[Usuarios] - SET IDNodoSubNodo = @idNodo, - ClienteAutoridad = @clienteAut, - Nombre = @nombre, - Usuario = @usuario, - BD_Shelter = @bdShelter - WHERE ID = @id - `); + await updatePortalUser(id, { + databaseNodeId: idNodoSubNodo, + isAuthorityClient: clienteAutoridad, + fullName: nombre, + username: usuario, + bdShelter + }); } return { success: true }; @@ -756,14 +538,7 @@ export const actions: Actions = { return { success: false, message: 'ID requerido' }; } - const azure = await db.getAzure(); - const req = azure.request(); - req.input('id', id); - - await req.query(` - DELETE FROM [CONTROLDESK].[dbo].[Usuarios] - WHERE ID = @id - `); + await deletePortalUser(id); return { success: true }; } catch (e: any) { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 30b8f82..5ae9bec 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -83,7 +83,7 @@ // Sub-tab dentro de "Gestión de Bases de Datos" let databasesSubTab = $state<'bases' | 'usuarios'>('bases'); - // ---- Gestión de Usuarios (tabla MSSQL CONTROLDESK.dbo.Usuarios) ---- + // ---- Gestión de usuarios portal (PostgreSQL a24c.portal_users) ---- let usuariosList = $state(untrack(() => (data.usuariosList ?? []) as any[])); let usuariosSearch = $state(''); let pageUsuarios = $state(1); @@ -237,7 +237,7 @@ if (!res.ok) return; const payload = await res.json(); - // No tocar databaseRowsLive aquí para no perder el mapeo a Azure (client_name, NodoSubNodo) + // No tocar databaseRowsLive aquí para no perder el mapeo al catálogo (client_name, NodoSubNodo) if (payload?.summaryMain) { summaryMainLive = payload.summaryMain; const raw = summaryMainLive?.total_size_gb ?? summaryMainLive?.TOTAL_SIZE_GB ?? 0; @@ -1059,7 +1059,9 @@

Almacenamiento Utilizado

{totalSizeGB} GB

-

Tamaño agregado de archivos de datos

+

+ Suma del tamaño asignado en SQL Server (datos, registro y demás archivos de cada base) +

@@ -2360,7 +2362,7 @@ {/if} - + {#if showUsuarioModal}
{/if}
- +