[P2606] WebServer NGINX: plan actualizado, ficha nodo, HTTPS operativo

- P2606: Plan v1.1 EN EJECUCIÓN. Fases 1-3 completadas.
- Ficha nodo srvv-nginx-rm.md. P2606 en adn/07_proyectos.md.
- Incluye cambios acumulados de sesiones anteriores.
This commit is contained in:
Ricardo Monla
2026-03-28 12:18:42 -03:00
parent 97d9b777b4
commit 160da47f96
78 changed files with 6461 additions and 624 deletions
@@ -0,0 +1,143 @@
module.exports = (pool) => {
const router = require('express').Router();
// GET /api/ambitos — Listar ámbitos
router.get('/', async (req, res) => {
try {
const { rows } = await pool.query(`
SELECT a.*,
(SELECT COUNT(*) FROM bitacoras.nodos WHERE ambito_id = a.id) as count_nodos
FROM bitacoras.ambitos a
ORDER BY a.parent_id NULLS FIRST, a.nombre ASC
`);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/ambitos — Crear ámbito
router.post('/', async (req, res) => {
const { nombre, descripcion, parent_id, activo } = req.body;
try {
const { rows } = await pool.query(
'INSERT INTO bitacoras.ambitos (nombre, descripcion, parent_id, activo) VALUES ($1, $2, $3, $4) RETURNING *',
[nombre, descripcion, parent_id || null, activo ?? true]
);
res.status(201).json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/ambitos/estadisticas — Métricas por ámbito
router.get('/stats/global', async (req, res) => {
try {
const { rows } = await pool.query(`
SELECT a.nombre, a.id,
(SELECT COUNT(*) FROM bitacoras.nodos WHERE ambito_id = a.id) as nodos_count,
(SELECT COUNT(*) FROM bitacoras.entradas WHERE ambito_id = a.id) as entradas_count
FROM bitacoras.ambitos a
WHERE a.activo = true
ORDER BY nodos_count DESC
`);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/ambitos/:id — Info de ámbito
router.get('/:id', async (req, res) => {
try {
const { rows } = await pool.query(`
SELECT a.*, p.nombre as parent_nombre,
(SELECT COUNT(*) FROM bitacoras.nodos WHERE ambito_id = a.id) as count_nodos
FROM bitacoras.ambitos a
LEFT JOIN bitacoras.ambitos p ON a.parent_id = p.id
WHERE a.id = $1
`, [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Ámbito no encontrado' });
res.json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// PUT /api/ambitos/:id — Actualizar ámbito
router.put('/:id', async (req, res) => {
const { nombre, descripcion, parent_id, activo } = req.body;
try {
const { rows } = await pool.query(
'UPDATE bitacoras.ambitos SET nombre=$1, descripcion=$2, parent_id=$3, activo=$4 WHERE id=$5 RETURNING *',
[nombre, descripcion, parent_id || null, activo ?? true, req.params.id]
);
if (rows.length === 0) return res.status(404).json({ error: 'Ámbito no encontrado' });
res.json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// DELETE /api/ambitos/:id — Eliminar ámbito
router.delete('/:id', async (req, res) => {
try {
const { rowCount } = await pool.query('DELETE FROM bitacoras.ambitos WHERE id = $1', [req.params.id]);
if (rowCount === 0) return res.status(404).json({ error: 'Ámbito no encontrado' });
res.json({ message: 'Ámbito eliminado' });
} catch (err) {
if (err.code === '23503') { // Foreign key constraint violation
await pool.query('UPDATE bitacoras.ambitos SET activo=false WHERE id=$1', [req.params.id]);
return res.json({ message: 'Ámbito marcado inactivo por poseer nodos/entradas relacionados' });
}
res.status(500).json({ error: err.message });
}
});
// GET /api/ambitos/:id/nodos — Nodos del ámbito
router.get('/:id/nodos', async (req, res) => {
try {
const { rows } = await pool.query('SELECT * FROM bitacoras.nodos WHERE ambito_id = $1 ORDER BY nombre', [req.params.id]);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/ambitos/:id/dashboard — Resumen de salud/estado del ámbito
router.get('/:id/dashboard', async (req, res) => {
const { id } = req.params;
try {
const ambito = await pool.query('SELECT * FROM bitacoras.ambitos WHERE id = $1', [id]);
if (ambito.rows.length === 0) return res.status(404).json({ error: 'Ámbito no encontrado' });
const nodos = await pool.query('SELECT * FROM bitacoras.nodos WHERE ambito_id = $1 AND activo = true', [id]);
const entradasRecientes = await pool.query(`
SELECT e.*, n.nombre as nodo_nombre
FROM bitacoras.entradas e
JOIN bitacoras.nodos n ON e.nodo_id = n.id
WHERE n.ambito_id = $1
ORDER BY e.inicio DESC LIMIT 10
`, [id]);
const gestionPendiente = await pool.query(`
SELECT g.*, n.nombre as nodo_nombre
FROM bitacoras.gestion g
JOIN bitacoras.nodos n ON g.nodo_id = n.id
WHERE n.ambito_id = $1 AND g.activo = true AND g.tipo = 'pendiente'
`, [id]);
res.json({
ambito: ambito.rows[0],
nodos: nodos.rows,
entradas: entradasRecientes.rows,
gestion: gestionPendiente.rows
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
return router;
};
@@ -59,37 +59,42 @@ module.exports = (pool) => {
bitacora = bResult.rows[0];
}
// Obtener entradas con datos del nodo
// Obtener entradas con datos del nodo y ámbito
const { rows: entradas } = await pool.query(`
SELECT e.*, n.nombre as nodo_nombre, n.ip as nodo_ip
FROM bitacoras.entradas e
LEFT JOIN bitacoras.nodos n ON e.nodo_id = n.id
WHERE e.bitacora_id = $1
ORDER BY e.inicio DESC
`, [bitacora.id]);
SELECT e.*, n.nombre as nodo_nombre, n.ip as nodo_ip,
COALESCE(a_evt.nombre, a_nodo.nombre) as ambito_nombre
FROM bitacoras.entradas e
LEFT JOIN bitacoras.nodos n ON e.nodo_id = n.id
LEFT JOIN bitacoras.ambitos a_evt ON e.ambito_id = a_evt.id
LEFT JOIN bitacoras.ambitos a_nodo ON n.ambito_id = a_nodo.id
WHERE e.bitacora_id = $1
ORDER BY e.inicio DESC
`, [bitacora.id]);
// Obtener gestión asociada a esta bitácora (fija) + activos globales (rollover)
const { rows: gestion } = await pool.query(`
SELECT g.*, n.nombre as nodo_nombre
FROM bitacoras.gestion g
LEFT JOIN bitacoras.nodos n ON g.nodo_id = n.id
WHERE g.bitacora_id = $1 OR g.activo = true
ORDER BY g.tipo, g.orden
`, [bitacora.id]);
SELECT g.*, n.nombre as nodo_nombre, a.nombre as ambito_nombre
FROM bitacoras.gestion g
LEFT JOIN bitacoras.nodos n ON g.nodo_id = n.id
LEFT JOIN bitacoras.ambitos a ON n.ambito_id = a.id
WHERE g.bitacora_id = $1 OR g.activo = true
ORDER BY g.tipo, g.orden
`, [bitacora.id]);
// Obtener resúmenes por nodo
const { rows: resúmenes } = await pool.query(`
SELECT r.*, n.nombre as nodo_nombre
FROM bitacoras.resumen_nodos r
JOIN bitacoras.nodos n ON r.nodo_id = n.id
WHERE r.bitacora_id = $1
`, [bitacora.id]);
SELECT r.*, n.nombre as nodo_nombre, a.nombre as ambito_nombre
FROM bitacoras.resumen_nodos r
JOIN bitacoras.nodos n ON r.nodo_id = n.id
LEFT JOIN bitacoras.ambitos a ON n.ambito_id = a.id
WHERE r.bitacora_id = $1
`, [bitacora.id]);
// Agrupar por nodo
// Agrupar por nodo e incluir el ambito en el agrupador
const porNodo = {};
entradas.forEach(e => {
const nodo = e.nodo_nombre || 'sin-nodo';
if (!porNodo[nodo]) porNodo[nodo] = { nodo: nodo, ip: e.nodo_ip, entradas: [], resumen: '' };
if (!porNodo[nodo]) porNodo[nodo] = { nodo: nodo, ip: e.nodo_ip, ambito_nombre: e.ambito_nombre, entradas: [], resumen: '' };
porNodo[nodo].entradas.push(e);
});
@@ -97,8 +102,9 @@ module.exports = (pool) => {
resúmenes.forEach(r => {
if (porNodo[r.nodo_nombre]) {
porNodo[r.nodo_nombre].resumen = r.resumen;
porNodo[r.nodo_nombre].ambito_nombre = r.ambito_nombre || porNodo[r.nodo_nombre].ambito_nombre;
} else {
porNodo[r.nodo_nombre] = { nodo: r.nodo_nombre, entradas: [], resumen: r.resumen };
porNodo[r.nodo_nombre] = { nodo: r.nodo_nombre, ambito_nombre: r.ambito_nombre, entradas: [], resumen: r.resumen };
}
});
@@ -0,0 +1,66 @@
const fs = require('fs');
const path = require('path');
module.exports = () => {
const router = require('express').Router();
const DOCS_BASE = path.resolve('/app/docs/proy');
// GET /api/docs/:proyecto/:archivo — Servir .md crudo
// Ejemplo: /api/docs/P2601_Dasuten/P2601.09_DASUTEN-sin-DC.md
router.get('/:proyecto/:archivo', (req, res) => {
const { proyecto, archivo } = req.params;
// Sanitizar: solo permitir caracteres seguros
if (!/^[A-Za-z0-9._-]+$/.test(proyecto) || !/^[A-Za-z0-9._-]+$/.test(archivo)) {
return res.status(400).json({ error: 'Nombre de archivo inválido' });
}
// Solo permitir .md
if (!archivo.endsWith('.md')) {
return res.status(400).json({ error: 'Solo se permiten archivos .md' });
}
const filePath = path.join(DOCS_BASE, proyecto, archivo);
// Verificar que no escapa del directorio base
if (!filePath.startsWith(DOCS_BASE)) {
return res.status(403).json({ error: 'Acceso denegado' });
}
// Leer y devolver contenido
fs.readFile(filePath, 'utf8', (err, content) => {
if (err) {
if (err.code === 'ENOENT') {
return res.status(404).json({ error: 'Archivo no encontrado' });
}
return res.status(500).json({ error: 'Error al leer archivo' });
}
res.json({
proyecto,
archivo,
content,
lastModified: fs.statSync(filePath).mtime.toISOString()
});
});
});
// GET /api/docs — Listar proyectos disponibles
router.get('/', (req, res) => {
try {
const dirs = fs.readdirSync(DOCS_BASE, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => {
const files = fs.readdirSync(path.join(DOCS_BASE, d.name))
.filter(f => f.endsWith('.md'));
return { proyecto: d.name, archivos: files };
});
res.json(dirs);
} catch (err) {
res.status(500).json({ error: 'Error listando docs' });
}
});
return router;
};
@@ -1,39 +1,77 @@
module.exports = (pool) => {
const router = require('express').Router();
// GET /api/entradas — Listar entradas (filtro por bitacora_id o nodo_id)
// GET /api/entradas — Listar entradas (filtro por bitacora_id o nodo_id o ambito_id)
router.get('/', async (req, res) => {
const { bitacora_id, nodo_id } = req.query;
let query = 'SELECT e.*, n.nombre as nodo_nombre FROM bitacoras.entradas e LEFT JOIN bitacoras.nodos n ON e.nodo_id = n.id WHERE 1=1';
const { bitacora_id, nodo_id, ambito_id } = req.query;
let query = `
SELECT e.*, n.nombre as nodo_nombre, a.nombre as ambito_nombre
FROM bitacoras.entradas e
LEFT JOIN bitacoras.nodos n ON e.nodo_id = n.id
LEFT JOIN bitacoras.ambitos a ON e.ambito_id = a.id
WHERE 1=1
`;
const params = [];
if (bitacora_id) { params.push(bitacora_id); query += ` AND e.bitacora_id = $${params.length}`; }
if (nodo_id) { params.push(nodo_id); query += ` AND e.nodo_id = $${params.length}`; }
if (ambito_id) { params.push(ambito_id); query += ` AND e.ambito_id = $${params.length}`; }
query += ' ORDER BY e.inicio DESC';
const { rows } = await pool.query(query, params);
res.json(rows);
try {
const { rows } = await pool.query(query, params);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/entradas — Crear entrada
router.post('/', async (req, res) => {
const { inicio, fin, descripcion, estado, modo, es_ia, bitacora_id, nodo_id } = req.body;
const { rows } = await pool.query(`
INSERT INTO bitacoras.entradas (inicio, fin, descripcion, estado, modo, es_ia, bitacora_id, nodo_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *
`, [inicio, fin || null, descripcion, estado || '⏳', modo || 'P', es_ia || false, bitacora_id, nodo_id]);
res.status(201).json(rows[0]);
const { inicio, fin, descripcion, estado, modo, es_ia, bitacora_id, nodo_id, ambito_id, doc_path } = req.body;
try {
const { rows } = await pool.query(`
INSERT INTO bitacoras.entradas (inicio, fin, descripcion, estado, modo, es_ia, bitacora_id, nodo_id, ambito_id, doc_path)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *
`, [inicio, fin || null, descripcion, estado || '⏳', modo || 'P', es_ia || false, bitacora_id, nodo_id || null, ambito_id || null, doc_path || null]);
res.status(201).json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// PUT /api/entradas/:id — Actualizar entrada
router.put('/:id', async (req, res) => {
const { id } = req.params;
const { inicio, fin, descripcion, estado, modo, es_ia } = req.body;
const { rows } = await pool.query(`
UPDATE bitacoras.entradas SET inicio=$1, fin=$2, descripcion=$3, estado=$4, modo=$5, es_ia=$6
WHERE id=$7 RETURNING *
`, [inicio, fin, descripcion, estado, modo, es_ia, id]);
if (rows.length === 0) return res.status(404).json({ error: 'Entrada no encontrada' });
res.json(rows[0]);
const { inicio, fin, descripcion, estado, modo, es_ia, nodo_id, ambito_id, doc_path } = req.body;
try {
// Construir SET dinámico para evitar sobreescribir nulos si no vienen en la petición
let updateFields = [];
let params = [];
let pIdx = 1;
if (inicio !== undefined) { updateFields.push(`inicio=$${pIdx++}`); params.push(inicio); }
if (fin !== undefined) { updateFields.push(`fin=$${pIdx++}`); params.push(fin); }
if (descripcion !== undefined) { updateFields.push(`descripcion=$${pIdx++}`); params.push(descripcion); }
if (estado !== undefined) { updateFields.push(`estado=$${pIdx++}`); params.push(estado); }
if (modo !== undefined) { updateFields.push(`modo=$${pIdx++}`); params.push(modo); }
if (es_ia !== undefined) { updateFields.push(`es_ia=$${pIdx++}`); params.push(es_ia); }
if (nodo_id !== undefined) { updateFields.push(`nodo_id=$${pIdx++}`); params.push(nodo_id); }
if (ambito_id !== undefined) { updateFields.push(`ambito_id=$${pIdx++}`); params.push(ambito_id); }
if (doc_path !== undefined) { updateFields.push(`doc_path=$${pIdx++}`); params.push(doc_path); }
if (updateFields.length === 0) return res.status(400).json({ error: 'No data to update' });
params.push(id);
const { rows } = await pool.query(`
UPDATE bitacoras.entradas SET ${updateFields.join(', ')}
WHERE id=$${pIdx} RETURNING *
`, params);
if (rows.length === 0) return res.status(404).json({ error: 'Entrada no encontrada' });
res.json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// DELETE /api/entradas/:id — Eliminar entrada
@@ -2,17 +2,30 @@
module.exports = (pool) => {
const router = require('express').Router();
// GET /api/gestion — Listar activos (para rollover) o por bitácora
// GET /api/gestion — Listar activos (para rollover) o por bitácora (opcional filtro ámbito)
router.get('/', async (req, res) => {
const { bitacora_id, activos } = req.query;
let query = 'SELECT g.*, n.nombre as nodo_nombre FROM bitacoras.gestion g LEFT JOIN bitacoras.nodos n ON g.nodo_id = n.id';
const { bitacora_id, activos, ambito_id } = req.query;
let query = `
SELECT g.*, n.nombre as nodo_nombre, a.nombre as ambito_nombre
FROM bitacoras.gestion g
LEFT JOIN bitacoras.nodos n ON g.nodo_id = n.id
LEFT JOIN bitacoras.ambitos a ON n.ambito_id = a.id
`;
const params = [];
let whereUsed = false;
if (activos === 'true') {
query += ' WHERE g.activo = true';
whereUsed = true;
} else if (bitacora_id) {
query += ' WHERE g.bitacora_id = $1';
params.push(bitacora_id);
whereUsed = true;
}
if (ambito_id) {
params.push(ambito_id);
query += (whereUsed ? ' AND' : ' WHERE') + ` (n.ambito_id = $${params.length})`;
}
query += ' ORDER BY g.tipo, g.orden, g.created_at DESC';
@@ -3,30 +3,56 @@ module.exports = (pool) => {
// GET /api/nodos — Listar todos los nodos
router.get('/', async (req, res) => {
const { rows } = await pool.query('SELECT * FROM bitacoras.nodos WHERE activo = true ORDER BY nombre');
res.json(rows);
const { ambito_id } = req.query;
let query = `
SELECT n.*, a.nombre as ambito_nombre
FROM bitacoras.nodos n
LEFT JOIN bitacoras.ambitos a ON n.ambito_id = a.id
WHERE n.activo = true
`;
const params = [];
if (ambito_id) {
params.push(ambito_id);
query += ` AND n.ambito_id = $${params.length}`;
}
query += ' ORDER BY n.nombre';
try {
const { rows } = await pool.query(query, params);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/nodos — Crear nodo
router.post('/', async (req, res) => {
const { nombre, ip, tipo, descripcion } = req.body;
const { rows } = await pool.query(
'INSERT INTO bitacoras.nodos (nombre, ip, tipo, descripcion) VALUES ($1, $2, $3, $4) RETURNING *',
[nombre, ip, tipo, descripcion]
);
res.status(201).json(rows[0]);
const { nombre, ip, tipo, descripcion, ambito_id } = req.body;
try {
const { rows } = await pool.query(
'INSERT INTO bitacoras.nodos (nombre, ip, tipo, descripcion, ambito_id) VALUES ($1, $2, $3, $4, $5) RETURNING *',
[nombre, ip, tipo, descripcion, ambito_id || null]
);
res.status(201).json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// PUT /api/nodos/:id — Actualizar nodo
router.put('/:id', async (req, res) => {
const { id } = req.params;
const { nombre, ip, tipo, descripcion, activo } = req.body;
const { rows } = await pool.query(
'UPDATE bitacoras.nodos SET nombre=$1, ip=$2, tipo=$3, descripcion=$4, activo=$5 WHERE id=$6 RETURNING *',
[nombre, ip, tipo, descripcion, activo, id]
);
if (rows.length === 0) return res.status(404).json({ error: 'Nodo no encontrado' });
res.json(rows[0]);
const { nombre, ip, tipo, descripcion, activo, ambito_id } = req.body;
try {
const { rows } = await pool.query(
'UPDATE bitacoras.nodos SET nombre=$1, ip=$2, tipo=$3, descripcion=$4, activo=$5, ambito_id=$6 WHERE id=$7 RETURNING *',
[nombre, ip, tipo, descripcion, activo, ambito_id || null, id]
);
if (rows.length === 0) return res.status(404).json({ error: 'Nodo no encontrado' });
res.json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// DELETE /api/nodos/:id — Eliminar nodo (lógico o físico si no tiene entradas asociadas)
@@ -25,12 +25,14 @@ app.get('/health', async (req, res) => {
});
// Routes
app.use('/api/ambitos', require('./routes/ambitos')(pool));
app.use('/api/nodos', require('./routes/nodos')(pool));
app.use('/api/bitacoras', require('./routes/bitacoras')(pool));
app.use('/api/entradas', require('./routes/entradas')(pool));
app.use('/api/proyectos', require('./routes/proyectos')(pool));
app.use('/api/gestion', require('./routes/gestion')(pool));
app.use('/api/resumen', require('./routes/resumen')(pool));
app.use('/api/docs', require('./routes/docs')());
// Error handler
app.use((err, req, res, next) => {