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.
This commit is contained in:
335
src/lib/server/controldesk-pg.ts
Normal file
335
src/lib/server/controldesk-pg.ts
Normal file
@@ -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<any[]> {
|
||||
const sql = `SELECT ${ROW_DATABASE_NODE} FROM ${qNodes()}`;
|
||||
const r = await pgPool.query(sql);
|
||||
return r.rows;
|
||||
}
|
||||
|
||||
export async function listDatabaseNodesActive(): Promise<any[]> {
|
||||
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<any[]> {
|
||||
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<any[]> {
|
||||
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<any[]> {
|
||||
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<any | null> {
|
||||
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<any | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await pgPool.query(`DELETE FROM ${qUsers()} WHERE id = $1`, [id]);
|
||||
}
|
||||
21
src/lib/server/dashboard-pg.ts
Normal file
21
src/lib/server/dashboard-pg.ts
Normal file
@@ -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');
|
||||
@@ -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<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;
|
||||
}
|
||||
|
||||
async getPostgres() {
|
||||
return pgPool.connect();
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new Database();
|
||||
export { sql, pgPool };
|
||||
export { pgPool };
|
||||
|
||||
288
src/lib/server/mssql-nodes.ts
Normal file
288
src/lib/server/mssql-nodes.ts
Normal file
@@ -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<string, sql.ConnectionPool>();
|
||||
|
||||
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<void> {
|
||||
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<sql.ConnectionPool> {
|
||||
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<any | null> {
|
||||
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<string, { restore_date: Date }[]>
|
||||
): Record<string, { month: string; effectiveness: number }[]> {
|
||||
const effectivenessByDb: Record<string, { month: string; effectiveness: number }[]> = {};
|
||||
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<string>; totalDays: number };
|
||||
} = {};
|
||||
for (const m of monthsToInclude) {
|
||||
const monthKey = `${currentYear}-${String(m + 1).padStart(2, '0')}`;
|
||||
monthly[monthKey] = {
|
||||
daysWithRestore: new Set<string>(),
|
||||
totalDays: new Date(currentYear, m + 1, 0).getDate()
|
||||
};
|
||||
}
|
||||
for (const h of history) {
|
||||
const d = new Date(h.restore_date);
|
||||
const y = d.getFullYear();
|
||||
const m = d.getMonth();
|
||||
if (y !== currentYear || !monthsToInclude.includes(m)) continue;
|
||||
const monthKey = `${y}-${String(m + 1).padStart(2, '0')}`;
|
||||
const dayKey = d.toISOString().slice(0, 10);
|
||||
monthly[monthKey]?.daysWithRestore.add(dayKey);
|
||||
}
|
||||
effectivenessByDb[dbName] = Object.entries(monthly).map(([month, data]) => {
|
||||
const eff =
|
||||
data.totalDays === 0 ? 0 : (data.daysWithRestore.size / data.totalDays) * 100;
|
||||
return { month, effectiveness: Number(eff.toFixed(1)) };
|
||||
});
|
||||
}
|
||||
return effectivenessByDb;
|
||||
}
|
||||
|
||||
export type SqlDashboardBundle = {
|
||||
databaseRows: any[];
|
||||
summaryMain: { total_databases: number; total_size_gb: number };
|
||||
alertsData: any[];
|
||||
restoreHistory: Record<string, { restore_date: Date }[]>;
|
||||
effectivenessByDb: Record<string, { month: string; effectiveness: number }[]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recorre nodos activos del catálogo y consulta SQL Server en server_name (BD = database_name).
|
||||
*/
|
||||
export async function loadSqlDashboardFromNodes(nodes: CatalogNodeRow[]): Promise<SqlDashboardBundle> {
|
||||
const databaseRows: any[] = [];
|
||||
const alertsData: any[] = [];
|
||||
const restoreHistory: Record<string, { restore_date: Date }[]> = {};
|
||||
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 };
|
||||
}
|
||||
@@ -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<string, unknown>): 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<Usuario | null> {
|
||||
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<Usuario | null> {
|
||||
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<Usuario | null> {
|
||||
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<Usuario> {
|
||||
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<Usuario | null> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
*/
|
||||
export async function listUsers(): Promise<Usuario[]> {
|
||||
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<Usuario[]> {
|
||||
*/
|
||||
export async function getUserDatabasePermissions(userId: number): Promise<string[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<T extends { visible_name:
|
||||
databases: T[]
|
||||
): Promise<T[]> {
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user