[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:
@@ -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) => {
|
||||
|
||||
@@ -36,6 +36,7 @@ services:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./backend/src:/app/src
|
||||
- ../../../docs/proy:/app/docs/proy:ro
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
@@ -49,6 +50,7 @@ services:
|
||||
- api
|
||||
volumes:
|
||||
- ./frontend/src:/app/src
|
||||
- ./frontend/vite.config.ts:/app/vite.config.ts
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
@@ -46,6 +46,7 @@ CREATE TABLE bitacoras.entradas (
|
||||
estado VARCHAR(10) DEFAULT '⏳',
|
||||
modo CHAR(1) DEFAULT 'P' CHECK (modo IN ('P', 'R')),
|
||||
es_ia BOOLEAN DEFAULT false,
|
||||
doc_path TEXT,
|
||||
tema_id INTEGER REFERENCES bitacoras.temas(id) ON DELETE CASCADE,
|
||||
bitacora_id INTEGER REFERENCES bitacoras.bitacoras(id) ON DELETE CASCADE,
|
||||
nodo_id INTEGER REFERENCES bitacoras.nodos(id),
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useState, useEffect, useCallback } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import ProyectoDashboard from './pages/ProyectoDashboard';
|
||||
import AmbitoDashboard from './pages/AmbitoDashboard';
|
||||
|
||||
// Constantes estáticas para evitar parpadeos por cambio de referencia en cada render.
|
||||
const MARKDOWN_PLUGINS = [remarkGfm];
|
||||
@@ -51,6 +52,18 @@ interface Nodo {
|
||||
ip: string;
|
||||
tipo: string;
|
||||
descripcion: string;
|
||||
ambito_id: number | null;
|
||||
ambito_nombre?: string;
|
||||
activo: boolean;
|
||||
}
|
||||
|
||||
interface Ambito {
|
||||
id: number;
|
||||
nombre: string;
|
||||
descripcion: string;
|
||||
parent_id: number | null;
|
||||
parent_nombre?: string;
|
||||
count_nodos?: number;
|
||||
activo: boolean;
|
||||
}
|
||||
|
||||
@@ -75,7 +88,7 @@ function ConfirmModal({ show, title, message, onConfirm, onCancel }: {
|
||||
}
|
||||
|
||||
// ===== TABS =====
|
||||
type Tab = 'bitacora' | 'nodos' | 'proyecto';
|
||||
type Tab = 'bitacora' | 'nodos' | 'ambitos' | 'proyecto';
|
||||
|
||||
function getInitialTab(): Tab {
|
||||
const path = window.location.pathname.toLowerCase();
|
||||
@@ -87,17 +100,17 @@ function getInitialTab(): Tab {
|
||||
function App() {
|
||||
const [tab, setTab] = useState<Tab>(getInitialTab());
|
||||
|
||||
const changeTab = (t: Tab) => {
|
||||
const changeTab = React.useCallback((t: Tab) => {
|
||||
setTab(t);
|
||||
const base = '/bitacoras/';
|
||||
// Asegurar que las rutas incluyan el prefijo correcto para el ruteo de NGINX
|
||||
const paths: Record<Tab, string> = {
|
||||
bitacora: base,
|
||||
nodos: base + 'nodos',
|
||||
ambitos: base + 'ambitos',
|
||||
proyecto: base + 'p2601'
|
||||
};
|
||||
window.history.replaceState(null, '', paths[t]);
|
||||
};
|
||||
}, []);
|
||||
const [fecha, setFecha] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [bitacora, setBitacora] = useState<Bitacora | null>(null);
|
||||
const [nodos, setNodos] = useState<Nodo[]>([]);
|
||||
@@ -129,19 +142,73 @@ function App() {
|
||||
// Confirm delete
|
||||
const [confirmDelete, setConfirmDelete] = useState<{ type: string; id: number; name: string } | null>(null);
|
||||
|
||||
// Nodo form
|
||||
// Ámbitos
|
||||
const [ambitos, setAmbitos] = useState<Ambito[]>([]);
|
||||
|
||||
// Form states
|
||||
const [nodoFormVisible, setNodoFormVisible] = useState(false);
|
||||
const [editNodoId, setEditNodoId] = useState<number | null>(null);
|
||||
const [nodoNombre, setNodoNombre] = useState('');
|
||||
const [nodoIp, setNodoIp] = useState('');
|
||||
const [nodoTipo, setNodoTipo] = useState('servidor');
|
||||
const [nodoDesc, setNodoDesc] = useState('');
|
||||
const [nodoAmbitoId, setNodoAmbitoId] = useState<string>('');
|
||||
|
||||
const [ambitoFormVisible, setAmbitoFormVisible] = useState(false);
|
||||
const [editAmbitoId, setEditAmbitoId] = useState<number | null>(null);
|
||||
const [ambitoNombre, setAmbitoNombre] = useState('');
|
||||
const [ambitoDesc, setAmbitoDesc] = useState('');
|
||||
const [ambitoParentId, setAmbitoParentId] = useState<string>('');
|
||||
const [activeAmbitoId, setActiveAmbitoId] = useState<number | null>(null);
|
||||
const [globalStats, setGlobalStats] = useState<{ nombre: string, count_nodos: number, count_entradas: number }[]>([]);
|
||||
|
||||
// Doc viewer modal
|
||||
const [docModal, setDocModal] = useState<{ show: boolean; title: string; content: string; loading: boolean }>({ show: false, title: '', content: '', loading: false });
|
||||
|
||||
const openDocModal = React.useCallback(async (proyecto: string, archivo: string) => {
|
||||
setDocModal({ show: true, title: archivo.replace('.md', ''), content: '', loading: true });
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/docs/${proyecto}/${archivo}`);
|
||||
const data = await res.json();
|
||||
if (data.content) {
|
||||
setDocModal({ show: true, title: archivo.replace('.md', ''), content: data.content, loading: false });
|
||||
} else {
|
||||
setDocModal({ show: true, title: 'Error', content: `No se pudo cargar el archivo: ${data.error || 'desconocido'}`, loading: false });
|
||||
}
|
||||
} catch (err) {
|
||||
setDocModal({ show: true, title: 'Error', content: 'Error de conexión al cargar el documento', loading: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// === MARKDOWN COMPONENTS (MEMOIZED TO AVOID FLICKER) ===
|
||||
const markdownComponents = React.useMemo(() => ({
|
||||
a: ({ node, ...props }: any) => {
|
||||
const url = props.href || '';
|
||||
|
||||
// Si es un link a un .md en ../proy/, abrir modal de documento
|
||||
if (url.includes('../proy/') && url.endsWith('.md')) {
|
||||
const parts = url.split('/');
|
||||
const archivo = parts.pop() || '';
|
||||
const proyecto = parts.pop() || '';
|
||||
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
href="#"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openDocModal(proyecto, archivo);
|
||||
}}
|
||||
className="desc-link"
|
||||
title={`Ver ${archivo} en la web`}
|
||||
style={{ cursor: 'pointer', borderBottom: '1px dashed var(--primary)' }}
|
||||
>
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// Si es un link a un proyecto del ADN, interceptamos para navegación SPA
|
||||
if (url.startsWith('../proyectos/')) {
|
||||
const projectFile = url.split('/').pop() || '';
|
||||
@@ -153,7 +220,7 @@ function App() {
|
||||
<a
|
||||
{...props}
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setSelectedProyecto(projectCode);
|
||||
changeTab('proyecto');
|
||||
@@ -176,7 +243,7 @@ function App() {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="desc-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
{props.children}
|
||||
</a>
|
||||
@@ -205,7 +272,7 @@ function App() {
|
||||
</code>
|
||||
);
|
||||
}
|
||||
}), [changeTab, setSelectedProyecto]);
|
||||
}), []); // All deps (changeTab, setSelectedProyecto, openDocModal) are stable useCallbacks/setState
|
||||
|
||||
// === HELPERS ===
|
||||
const renderDescription = (text: string) => {
|
||||
@@ -233,12 +300,29 @@ function App() {
|
||||
setLoading(false);
|
||||
}, [fecha, bitacora]);
|
||||
|
||||
const fetchNodos = async () => {
|
||||
const fetchNodos = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/nodos`);
|
||||
setNodos(await res.json());
|
||||
} catch (err) { console.error('Error:', err); }
|
||||
};
|
||||
const data = await res.json();
|
||||
setNodos(data);
|
||||
} catch (err) { console.error('Error nodes:', err); }
|
||||
}, []);
|
||||
|
||||
const fetchAmbitos = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/ambitos`);
|
||||
const data = await res.json();
|
||||
setAmbitos(data);
|
||||
} catch (err) { console.error('Error ambitos:', err); }
|
||||
}, []);
|
||||
|
||||
const fetchGlobalStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/ambitos/stats/global`);
|
||||
const data = await res.json();
|
||||
setGlobalStats(data);
|
||||
} catch (err) { console.error('Error global stats:', err); }
|
||||
}, []);
|
||||
|
||||
const fetchFechasActivas = async () => {
|
||||
try {
|
||||
@@ -325,17 +409,23 @@ function App() {
|
||||
if (!newVersion) {
|
||||
fetchBitacora();
|
||||
fetchFechasActivas();
|
||||
fetchNodos();
|
||||
fetchAmbitos();
|
||||
fetchGlobalStats();
|
||||
return;
|
||||
}
|
||||
if (!lastVersion || newVersion.max_id !== lastVersion.max_id || Number(newVersion.count) !== Number(lastVersion.count)) {
|
||||
setLastVersion(newVersion);
|
||||
fetchBitacora();
|
||||
fetchFechasActivas();
|
||||
fetchNodos();
|
||||
fetchAmbitos();
|
||||
fetchGlobalStats();
|
||||
}
|
||||
}, [fetchBitacoraVersion, fetchBitacora, fetchFechasActivas, lastVersion]);
|
||||
}, [fetchBitacoraVersion, fetchBitacora, fetchFechasActivas, lastVersion, fetchNodos, fetchAmbitos, fetchGlobalStats]);
|
||||
|
||||
useEffect(() => { fetchBitacora(); }, [fetchBitacora]);
|
||||
useEffect(() => { fetchNodos(); fetchProyectos(); }, []);
|
||||
useEffect(() => { fetchNodos(); fetchProyectos(); fetchAmbitos(); fetchGlobalStats(); }, []);
|
||||
useEffect(() => { fetchFechasActivas(); }, [fetchFechasActivas]);
|
||||
|
||||
// Inicializar versión al cargar
|
||||
@@ -407,8 +497,8 @@ function App() {
|
||||
|
||||
const startEdit = (e: Entrada) => {
|
||||
setEditId(e.id);
|
||||
setEditInicio(e.inicio?.substring(0, 5) || '');
|
||||
setEditFin(e.fin ? e.fin.substring(0, 5) : '');
|
||||
setEditInicio(e.inicio?.substring(0, 8) || '');
|
||||
setEditFin(e.fin ? e.fin.substring(0, 8) : '');
|
||||
setEditDesc(e.descripcion);
|
||||
setEditEstado(e.estado);
|
||||
setEditModo(e.modo);
|
||||
@@ -444,13 +534,16 @@ function App() {
|
||||
} else if (type === 'nodo') {
|
||||
await fetch(`${API_URL}/nodos/${id}`, { method: 'DELETE' });
|
||||
fetchNodos();
|
||||
} else if (type === 'ambito') {
|
||||
await fetch(`${API_URL}/ambitos/${id}`, { method: 'DELETE' });
|
||||
fetchAmbitos();
|
||||
}
|
||||
setConfirmDelete(null);
|
||||
};
|
||||
|
||||
// === NODOS CRUD ===
|
||||
const resetNodoForm = () => {
|
||||
setEditNodoId(null); setNodoNombre(''); setNodoIp(''); setNodoTipo('servidor'); setNodoDesc('');
|
||||
setEditNodoId(null); setNodoNombre(''); setNodoIp(''); setNodoTipo('servidor'); setNodoDesc(''); setNodoAmbitoId('');
|
||||
setNodoFormVisible(false);
|
||||
};
|
||||
|
||||
@@ -458,8 +551,10 @@ function App() {
|
||||
if (nodo) {
|
||||
setEditNodoId(nodo.id); setNodoNombre(nodo.nombre); setNodoIp(nodo.ip || '');
|
||||
setNodoTipo(nodo.tipo || 'servidor'); setNodoDesc(nodo.descripcion || '');
|
||||
setNodoAmbitoId(nodo.ambito_id?.toString() || '');
|
||||
} else {
|
||||
setEditNodoId(null); setNodoNombre(''); setNodoIp(''); setNodoTipo('servidor'); setNodoDesc('');
|
||||
setNodoAmbitoId('');
|
||||
}
|
||||
setNodoFormVisible(true);
|
||||
};
|
||||
@@ -467,18 +562,22 @@ function App() {
|
||||
const saveNodo = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!nodoNombre) return;
|
||||
const payload = {
|
||||
nombre: nodoNombre, ip: nodoIp, tipo: nodoTipo, descripcion: nodoDesc,
|
||||
ambito_id: nodoAmbitoId ? parseInt(nodoAmbitoId) : null
|
||||
};
|
||||
if (editNodoId) {
|
||||
const existing = nodos.find(n => n.id === editNodoId);
|
||||
await fetch(`${API_URL}/nodos/${editNodoId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nombre: nodoNombre, ip: nodoIp, tipo: nodoTipo, descripcion: nodoDesc, activo: existing?.activo ?? true })
|
||||
body: JSON.stringify({ ...payload, activo: existing?.activo ?? true })
|
||||
});
|
||||
} else {
|
||||
await fetch(`${API_URL}/nodos`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nombre: nodoNombre, ip: nodoIp, tipo: nodoTipo, descripcion: nodoDesc })
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
resetNodoForm();
|
||||
@@ -489,6 +588,51 @@ function App() {
|
||||
setConfirmDelete({ type: 'nodo', id, name: nombre });
|
||||
};
|
||||
|
||||
// === AMBITOS CRUD ===
|
||||
const resetAmbitoForm = () => {
|
||||
setEditAmbitoId(null); setAmbitoNombre(''); setAmbitoDesc(''); setAmbitoParentId('');
|
||||
setAmbitoFormVisible(false);
|
||||
};
|
||||
|
||||
const openAmbitoForm = (ambito?: Ambito) => {
|
||||
if (ambito) {
|
||||
setEditAmbitoId(ambito.id); setAmbitoNombre(ambito.nombre); setAmbitoDesc(ambito.descripcion || '');
|
||||
setAmbitoParentId(ambito.parent_id?.toString() || '');
|
||||
} else {
|
||||
setEditAmbitoId(null); setAmbitoNombre(''); setAmbitoDesc(''); setAmbitoParentId('');
|
||||
}
|
||||
setAmbitoFormVisible(true);
|
||||
};
|
||||
|
||||
const saveAmbito = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!ambitoNombre) return;
|
||||
const payload = {
|
||||
nombre: ambitoNombre, descripcion: ambitoDesc,
|
||||
parent_id: ambitoParentId ? parseInt(ambitoParentId) : null
|
||||
};
|
||||
if (editAmbitoId) {
|
||||
await fetch(`${API_URL}/ambitos/${editAmbitoId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
} else {
|
||||
await fetch(`${API_URL}/ambitos`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
resetAmbitoForm();
|
||||
fetchAmbitos();
|
||||
fetchNodos(); // Los nodos pueden haber cambiado su ambito_nombre
|
||||
};
|
||||
|
||||
const requestDeleteAmbito = (id: number, nombre: string) => {
|
||||
setConfirmDelete({ type: 'ambito', id, name: nombre });
|
||||
};
|
||||
|
||||
// === STATS ===
|
||||
const totalEntradas = bitacora?.nodos?.reduce((sum, n) => sum + n.entradas.length, 0) || 0;
|
||||
const totalNodos = bitacora?.nodos?.length || 0;
|
||||
@@ -504,6 +648,31 @@ function App() {
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
|
||||
{/* Modal visor de documentos .md */}
|
||||
{docModal.show && (
|
||||
<div className="modal-overlay" onClick={() => setDocModal({ ...docModal, show: false })}>
|
||||
<div className="modal-box" onClick={e => e.stopPropagation()} style={{ maxWidth: '800px', maxHeight: '85vh', width: '90%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="modal-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span><i className="fas fa-file-alt"></i> {docModal.title}</span>
|
||||
<button className="btn btn-sm" onClick={() => setDocModal({ ...docModal, show: false })} style={{ background: 'transparent', color: 'var(--text-muted)', border: 'none', fontSize: '1.2rem' }}>
|
||||
<i className="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ overflow: 'auto', padding: '1.5rem', flex: 1 }}>
|
||||
{docModal.loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}><i className="fas fa-spinner fa-spin fa-2x"></i></div>
|
||||
) : (
|
||||
<div className="doc-viewer-content">
|
||||
<ReactMarkdown remarkPlugins={MARKDOWN_PLUGINS} components={markdownComponents as any}>
|
||||
{docModal.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav className="navbar">
|
||||
<div className="navbar-brand">
|
||||
<i className="fas fa-book-open"></i>
|
||||
@@ -526,6 +695,11 @@ function App() {
|
||||
onClick={() => changeTab('proyecto')}
|
||||
style={tab !== 'proyecto' ? { background: 'var(--bg-card)', color: 'var(--text)', border: '1px solid var(--border)' } : {}}
|
||||
><i className="fas fa-project-diagram"></i> Planes</button>
|
||||
<button
|
||||
className={`btn btn-sm ${tab === 'ambitos' ? 'btn-primary' : ''}`}
|
||||
onClick={() => changeTab('ambitos')}
|
||||
style={tab !== 'ambitos' ? { background: 'var(--bg-card)', color: 'var(--text)', border: '1px solid var(--border)' } : {}}
|
||||
><i className="fas fa-layer-group"></i> Ámbitos</button>
|
||||
{tab === 'bitacora' && (
|
||||
<a href={`${API_URL}/bitacoras/${fecha}/export`} target="_blank" rel="noopener"
|
||||
className="btn btn-sm btn-primary">
|
||||
@@ -539,6 +713,21 @@ function App() {
|
||||
{/* =================== TAB BITÁCORA =================== */}
|
||||
{tab === 'bitacora' && (
|
||||
<>
|
||||
{/* Global Stats Row */}
|
||||
{/* Global Stats Row */}
|
||||
{globalStats.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: '1rem', overflowX: 'auto', paddingBottom: '1rem', marginBottom: '1rem', borderBottom: '1px solid var(--border)' }}>
|
||||
{globalStats.map((s, i) => (
|
||||
<div key={i} className="card" style={{ padding: '0.6rem 1rem', minWidth: '150px', flexShrink: 0 }}>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', textTransform: 'uppercase', fontWeight: 600 }}>{s.nombre}</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginTop: '0.2rem' }}>
|
||||
<span style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--primary)' }}>{s.count_nodos} <small style={{ fontSize: '0.6rem', fontWeight: 400 }}>nodos</small></span>
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{s.count_entradas} <small style={{ fontSize: '0.6rem' }}>ev.</small></span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Date Selector con Mini Calendario */}
|
||||
<div className="date-selector">
|
||||
<button className="btn btn-sm btn-primary" onClick={() => {
|
||||
@@ -664,8 +853,8 @@ function App() {
|
||||
<div className="card" style={{ marginBottom: '2rem' }}>
|
||||
<div className="card-header"><i className="fas fa-plus-circle"></i> Nueva Entrada</div>
|
||||
<form className="form-inline" onSubmit={handleSubmit}>
|
||||
<input type="time" className="input-time" value={newInicio} onChange={e => setNewInicio(e.target.value)} placeholder="I" required />
|
||||
<input type="time" className="input-time" value={newFin} onChange={e => setNewFin(e.target.value)} placeholder="F" />
|
||||
<input type="time" className="input-time" value={newInicio} onChange={e => setNewInicio(e.target.value)} placeholder="I" required step="1" />
|
||||
<input type="time" className="input-time" value={newFin} onChange={e => setNewFin(e.target.value)} placeholder="F" step="1" />
|
||||
<input type="text" className="input-desc" value={newDesc} onChange={e => setNewDesc(e.target.value)} placeholder="Descripción..." required />
|
||||
<select value={newNodoId} onChange={e => setNewNodoId(e.target.value)} required>
|
||||
<option value="">Nodo...</option>
|
||||
@@ -688,98 +877,144 @@ function App() {
|
||||
{/* Loading */}
|
||||
{loading && <div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}><i className="fas fa-spinner fa-spin fa-2x"></i></div>}
|
||||
|
||||
{/* Entries by Node */}
|
||||
{bitacora?.nodos?.map((nodoGroup, idx) => (
|
||||
<div key={idx} className="nodo-section card" style={{ marginBottom: '1.5rem' }}>
|
||||
<div className="nodo-title">
|
||||
<i className="fas fa-server"></i>
|
||||
{nodoGroup.nodo}
|
||||
{nodoGroup.ip && <span className="nodo-ip">({nodoGroup.ip})</span>}
|
||||
<span className="badge badge-gradient" style={{ marginLeft: 'auto' }}>{nodoGroup.entradas.length}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0.8rem 1rem', background: 'rgba(102, 126, 234, 0.05)', borderBottom: '1px solid var(--border)', fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<i className="fas fa-quote-left" style={{ opacity: 0.5 }}></i>
|
||||
<div style={{ fontStyle: 'italic', flex: 1 }}>{nodoGroup.resumen || 'Sin resumen integral registrado.'}</div>
|
||||
<button className="btn btn-sm" onClick={() => updateResumenNodo(nodoGroup.nodo, nodoGroup.resumen || '')} style={{ padding: '0.1rem 0.4rem', fontSize: '0.7rem' }} title="Editar Resumen"><i className="fas fa-pen"></i></button>
|
||||
{/* Agrupar entradas por ÁMBITO en lugar de por nodo */}
|
||||
{(() => {
|
||||
// Agrupar por ámbito
|
||||
const porAmbito = {};
|
||||
bitacora?.nodos?.forEach(nodoGroup => {
|
||||
const ambito = nodoGroup.ambito_nombre || 'Sin Ámbito';
|
||||
if (!porAmbito[ambito]) {
|
||||
porAmbito[ambito] = {
|
||||
ambito: ambito,
|
||||
nodos: new Set(),
|
||||
entradas: [],
|
||||
resumenes: []
|
||||
};
|
||||
}
|
||||
porAmbito[ambito].entradas.push(...nodoGroup.entradas);
|
||||
porAmbito[ambito].nodos.add(nodoGroup.nodo);
|
||||
if (nodoGroup.resumen) {
|
||||
porAmbito[ambito].resumenes.push({ nodo: nodoGroup.nodo, resumen: nodoGroup.resumen });
|
||||
}
|
||||
});
|
||||
|
||||
return Object.values(porAmbito).map((ambitoGroup, idx) => (
|
||||
<div key={idx} className="nodo-section card" style={{ marginBottom: '1.5rem' }}>
|
||||
<div className="nodo-title">
|
||||
<i className="fas fa-layer-group" style={{ color: 'var(--primary)' }}></i>
|
||||
{ambitoGroup.ambito}
|
||||
<span className="badge badge-gradient" style={{ marginLeft: 'auto' }}>
|
||||
{ambitoGroup.nodos.size} nodos | {ambitoGroup.entradas.length} entradas
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Resúmenes por nodo dentro del ámbito */}
|
||||
{ambitoGroup.resumenes.length > 0 && ambitoGroup.resumenes.map((r, i) => (
|
||||
<div key={i} style={{ padding: '0.8rem 1rem', background: 'rgba(102, 126, 234, 0.05)', borderBottom: '1px solid var(--border)', fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: i > 0 ? '0.5rem' : 0 }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<i className="fas fa-quote-left" style={{ opacity: 0.5 }}></i>
|
||||
<div style={{ fontStyle: 'italic', flex: 1 }}>
|
||||
<strong>{r.nodo}:</strong> {r.resumen}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table-ifde">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '40px', color: 'var(--text-muted)', fontSize: '0.85rem' }}>ID</th>
|
||||
<th className="col-time">I</th>
|
||||
<th className="col-time">F</th>
|
||||
<th className="col-desc">Descripción</th>
|
||||
<th style={{ width: '90px', textAlign: 'center' }}>Nodo</th>
|
||||
<th className="col-j-header" style={{ width: '35px', textAlign: 'center' }}>J</th>
|
||||
<th className="col-status">E</th>
|
||||
<th style={{ width: '80px' }}>Acc.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ambitoGroup.entradas.sort((a, b) => b.inicio.localeCompare(a.inicio)).map(e => (
|
||||
editId === e.id ? (
|
||||
/* ==== FILA EN EDICIÓN ==== */
|
||||
<tr key={e.id} style={{ background: 'rgba(102, 126, 234, 0.1)' }}>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{e.id}</td>
|
||||
<td><input type="time" value={editInicio?.substring(0,5)} onChange={ev => setEditInicio(ev.target.value)} className="edit-input" step="1" /></td>
|
||||
<td><input type="time" value={editFin?.substring(0,5)} onChange={ev => setEditFin(ev.target.value)} className="edit-input" step="1" /></td>
|
||||
<td>
|
||||
<input type="text" value={editDesc} onChange={ev => setEditDesc(ev.target.value)} className="edit-input" style={{ width: '100%' }} />
|
||||
<label style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: '0.2rem', marginTop: '0.3rem' }}>
|
||||
<input type="checkbox" checked={editEsIa} onChange={ev => setEditEsIa(ev.target.checked)} /> IA
|
||||
</label>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', fontSize: '0.8rem', fontWeight: 600, color: 'var(--text-secondary)' }}>{e.nodo_nombre}</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<select value={editModo} onChange={ev => setEditModo(ev.target.value)} className="edit-input" style={{ width: '45px', padding: '0.35rem 0.2rem' }}>
|
||||
<option value="P">P</option><option value="R">R</option><option value="S">S</option>
|
||||
</select>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<select value={editEstado} onChange={ev => setEditEstado(ev.target.value)} className="edit-input" style={{ width: '45px', padding: '0.35rem 0.2rem' }}>
|
||||
<option value="⏳">⏳</option><option value="✅">✅</option>
|
||||
<option value="⚠️">⚠️</option><option value="❌">❌</option><option value="👁️">👁️</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '0.2rem' }}>
|
||||
<button className="btn btn-sm btn-success" onClick={saveEdit} title="Guardar"><i className="fas fa-check"></i></button>
|
||||
<button className="btn btn-sm" onClick={cancelEdit} title="Cancelar" style={{ background: 'var(--bg-card)', color: 'var(--text-muted)' }}><i className="fas fa-times"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
/* ==== FILA NORMAL ==== */
|
||||
<tr key={e.id}>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{e.id}</td>
|
||||
<td className="col-time">{e.inicio?.substring(0, 5)}</td>
|
||||
<td className="col-time">{e.fin ? e.fin.substring(0, 5) : '—'}</td>
|
||||
<td className="col-desc">
|
||||
{e.es_ia && <span className="ia-tag">IA</span>}
|
||||
{e.doc_path && (() => {
|
||||
const parts = e.doc_path.split('/');
|
||||
const archivo = parts.pop() || '';
|
||||
const proyecto = parts.pop() || '';
|
||||
const label = archivo.replace('.md', '').replace(/_/g, ' ').split(' ')[0];
|
||||
return (
|
||||
<button
|
||||
className="plan-link-btn"
|
||||
onClick={(ev) => { ev.stopPropagation(); openDocModal(proyecto, archivo); }}
|
||||
title={`Ver ${archivo}`}
|
||||
>
|
||||
<i className="fas fa-file-alt"></i> {label}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
{renderDescription(e.descripcion)}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', fontSize: '0.8rem', fontWeight: 600, color: 'var(--primary)', background: 'rgba(102, 126, 234, 0.05)' }}>{e.nodo_nombre}</td>
|
||||
<td style={{ textAlign: 'center', fontWeight: 600, color: 'var(--text-secondary)' }}>{e.modo}</td>
|
||||
<td className="col-status">{e.estado}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '0.2rem' }}>
|
||||
<button className="btn btn-sm" onClick={() => startEdit(e)} title="Editar"
|
||||
style={{ background: 'var(--info)', color: 'white' }}>
|
||||
<i className="fas fa-pen"></i>
|
||||
</button>
|
||||
<button className="btn btn-sm btn-danger" onClick={() => requestDeleteEntrada(e.id, e.descripcion)} title="Eliminar">
|
||||
<i className="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className="table-ifde">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '40px', color: 'var(--text-muted)', fontSize: '0.85rem' }}>ID</th>
|
||||
<th className="col-time">I</th>
|
||||
<th className="col-time">F</th>
|
||||
<th>Descripción</th>
|
||||
<th className="col-j-header" style={{ width: '35px', textAlign: 'center' }}>J</th>
|
||||
<th className="col-status">E</th>
|
||||
<th style={{ width: '80px' }}>Acc.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodoGroup.entradas.map(e => (
|
||||
editId === e.id ? (
|
||||
/* ==== FILA EN EDICIÓN ==== */
|
||||
<tr key={e.id} style={{ background: 'rgba(102, 126, 234, 0.1)' }}>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{e.id}</td>
|
||||
<td><input type="time" value={editInicio} onChange={ev => setEditInicio(ev.target.value)} className="edit-input" /></td>
|
||||
<td><input type="time" value={editFin} onChange={ev => setEditFin(ev.target.value)} className="edit-input" /></td>
|
||||
<td>
|
||||
<input type="text" value={editDesc} onChange={ev => setEditDesc(ev.target.value)} className="edit-input" style={{ width: '100%' }} />
|
||||
<label style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: '0.2rem', marginTop: '0.3rem' }}>
|
||||
<input type="checkbox" checked={editEsIa} onChange={ev => setEditEsIa(ev.target.checked)} /> IA
|
||||
</label>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<select value={editModo} onChange={ev => setEditModo(ev.target.value)} className="edit-input" style={{ width: '45px', padding: '0.35rem 0.2rem' }}>
|
||||
<option value="P">P</option><option value="R">R</option><option value="S">S</option>
|
||||
</select>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<select value={editEstado} onChange={ev => setEditEstado(ev.target.value)} className="edit-input" style={{ width: '45px', padding: '0.35rem 0.2rem' }}>
|
||||
<option value="⏳">⏳</option><option value="✅">✅</option>
|
||||
<option value="⚠️">⚠️</option><option value="❌">❌</option><option value="👁️">👁️</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '0.2rem' }}>
|
||||
<button className="btn btn-sm btn-success" onClick={saveEdit} title="Guardar"><i className="fas fa-check"></i></button>
|
||||
<button className="btn btn-sm" onClick={cancelEdit} title="Cancelar" style={{ background: 'var(--bg-card)', color: 'var(--text-muted)' }}><i className="fas fa-times"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
/* ==== FILA NORMAL ==== */
|
||||
<tr key={e.id}>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{e.id}</td>
|
||||
<td className="col-time">{e.inicio?.substring(0, 5)}</td>
|
||||
<td className="col-time">{e.fin ? e.fin.substring(0, 5) : '—'}</td>
|
||||
<td className="col-desc">
|
||||
{e.es_ia && <span className="ia-tag">IA</span>}
|
||||
{renderDescription(e.descripcion)}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', fontWeight: 600, color: 'var(--text-secondary)' }}>{e.modo}</td>
|
||||
<td className="col-status">{e.estado}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '0.2rem' }}>
|
||||
<button className="btn btn-sm" onClick={() => startEdit(e)} title="Editar"
|
||||
style={{ background: 'var(--info)', color: 'white' }}>
|
||||
<i className="fas fa-pen"></i>
|
||||
</button>
|
||||
<button className="btn btn-sm btn-danger" onClick={() => requestDeleteEntrada(e.id, e.descripcion)} title="Eliminar">
|
||||
<i className="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
));
|
||||
})()}
|
||||
|
||||
{!loading && totalEntradas === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
|
||||
@@ -817,6 +1052,10 @@ function App() {
|
||||
<option value="switch">Switch</option>
|
||||
<option value="otro">Otro</option>
|
||||
</select>
|
||||
<select value={nodoAmbitoId} onChange={e => setNodoAmbitoId(e.target.value)}>
|
||||
<option value="">Sin Ámbito</option>
|
||||
{ambitos.map(a => <option key={a.id} value={a.id}>{a.nombre}</option>)}
|
||||
</select>
|
||||
<input type="text" className="input-desc" value={nodoDesc} onChange={e => setNodoDesc(e.target.value)} placeholder="Descripción..." />
|
||||
<button type="submit" className="btn btn-success"><i className="fas fa-save"></i> {editNodoId ? 'Actualizar' : 'Crear'}</button>
|
||||
<button type="button" className="btn btn-sm" onClick={resetNodoForm} style={{ background: 'var(--bg-card)', color: 'var(--text-muted)', border: '1px solid var(--border)' }}>
|
||||
@@ -835,6 +1074,7 @@ function App() {
|
||||
<th>Nombre</th>
|
||||
<th>IP</th>
|
||||
<th>Tipo</th>
|
||||
<th>Ámbito</th>
|
||||
<th>Descripción</th>
|
||||
<th style={{ width: '80px' }}>Acc.</th>
|
||||
</tr>
|
||||
@@ -846,6 +1086,7 @@ function App() {
|
||||
<td style={{ fontWeight: 600 }}>{n.nombre}</td>
|
||||
<td style={{ fontFamily: 'monospace', color: 'var(--text-secondary)' }}>{n.ip || '—'}</td>
|
||||
<td><span className="badge" style={{ background: 'rgba(102, 126, 234, 0.15)', color: 'var(--primary)' }}>{n.tipo || 'N/A'}</span></td>
|
||||
<td><span className="badge" style={{ background: 'var(--bg-tag)', color: 'var(--text-secondary)', border: '1px solid var(--border)' }}>{n.ambito_nombre || '—'}</span></td>
|
||||
<td style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>{n.descripcion || '—'}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '0.2rem' }}>
|
||||
@@ -866,10 +1107,96 @@ function App() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* =================== TAB ÁMBITOS =================== */}
|
||||
{tab === 'ambitos' && (
|
||||
<>
|
||||
{activeAmbitoId ? (
|
||||
<AmbitoDashboard id={activeAmbitoId} onBack={() => setActiveAmbitoId(null)} />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||
<h2 style={{ fontSize: '1.3rem', fontWeight: 600 }}><i className="fas fa-layer-group" style={{ color: 'var(--primary)', marginRight: '0.5rem' }}></i>Gestión de Ámbitos</h2>
|
||||
<button className="btn btn-primary" onClick={() => openAmbitoForm()}>
|
||||
<i className="fas fa-plus"></i> Nuevo Ámbito
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Ámbito Form */}
|
||||
{ambitoFormVisible && (
|
||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<div className="card-header">
|
||||
<i className={editAmbitoId ? 'fas fa-pen' : 'fas fa-plus-circle'}></i>
|
||||
{editAmbitoId ? 'Editar Ámbito' : 'Nuevo Ámbito'}
|
||||
</div>
|
||||
<form className="form-inline" onSubmit={saveAmbito}>
|
||||
<input type="text" value={ambitoNombre} onChange={e => setAmbitoNombre(e.target.value)} placeholder="Nombre (ej: dtic-DASUTEN)" required style={{ minWidth: '180px' }} />
|
||||
<select value={ambitoParentId} onChange={e => setAmbitoParentId(e.target.value)}>
|
||||
<option value="">Ámbito Raíz (Sin Padre)</option>
|
||||
{ambitos.filter(a => a.id !== editAmbitoId).map(a => (
|
||||
<option key={a.id} value={a.id}>{a.nombre}</option>
|
||||
))}
|
||||
</select>
|
||||
<input type="text" className="input-desc" value={ambitoDesc} onChange={e => setAmbitoDesc(e.target.value)} placeholder="Descripción..." style={{ flex: 1 }} />
|
||||
<button type="submit" className="btn btn-success"><i className="fas fa-save"></i> {editAmbitoId ? 'Actualizar' : 'Crear'}</button>
|
||||
<button type="button" className="btn btn-sm" onClick={resetAmbitoForm} style={{ background: 'var(--bg-card)', color: 'var(--text-muted)', border: '1px solid var(--border)' }}>
|
||||
<i className="fas fa-times"></i> Cancelar
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ámbitos Table */}
|
||||
<div className="card">
|
||||
<table className="table-ifde">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nombre</th>
|
||||
<th>Padre</th>
|
||||
<th>Nodos</th>
|
||||
<th>Descripción</th>
|
||||
<th style={{ width: '120px' }}>Acc.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ambitos.map(a => (
|
||||
<tr key={a.id} style={!a.activo ? { opacity: 0.5, textDecoration: 'line-through' } : {}}>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{a.id}</td>
|
||||
<td style={{ fontWeight: 600 }}>{a.nombre}</td>
|
||||
<td style={{ fontStyle: 'italic', color: 'var(--text-secondary)' }}>{a.parent_nombre || '—'}</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<span className="badge badge-gradient">{a.count_nodos || 0}</span>
|
||||
</td>
|
||||
<td style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>{a.descripcion || '—'}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '0.2rem' }}>
|
||||
<button className="btn btn-sm" onClick={() => setActiveAmbitoId(a.id)} title="Ver Dashboard"
|
||||
style={{ background: 'var(--primary)', color: 'white' }}>
|
||||
<i className="fas fa-chart-line"></i>
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={() => openAmbitoForm(a)} title="Editar"
|
||||
style={{ background: 'var(--info)', color: 'white' }}>
|
||||
<i className="fas fa-pen"></i>
|
||||
</button>
|
||||
<button className="btn btn-sm btn-danger" onClick={() => requestDeleteAmbito(a.id, a.nombre)} title="Eliminar">
|
||||
<i className="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* =================== TAB PROYECTO =================== */}
|
||||
{tab === 'proyecto' && (
|
||||
<>
|
||||
<div className="date-selector" style={{ marginBottom: '1.5rem', justifyContent: 'center' }}>
|
||||
<div className="date-selector" style={{ marginBottom: '1.5rem', justifyContent: 'center' }}>
|
||||
<button className="btn btn-sm btn-primary" onClick={() => navigateProyecto(-1)}>
|
||||
<i className="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
|
||||
@@ -129,6 +129,20 @@ body {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
/* Contenedor responsivo para tablas */
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Columna Descripción con wrap */
|
||||
.table-ifde .col-desc {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.table-ifde th {
|
||||
@@ -155,8 +169,11 @@ body {
|
||||
|
||||
.table-ifde .col-time {
|
||||
width: 60px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.table-ifde th.col-time {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -196,6 +213,37 @@ body {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Plan link button (inside descriptions) */
|
||||
.plan-link-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: rgba(102, 126, 234, 0.15);
|
||||
color: var(--primary);
|
||||
border: 1px solid rgba(102, 126, 234, 0.3);
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-right: 0.4rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.plan-link-btn:hover {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.plan-link-btn i {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Nodo section */
|
||||
.nodo-section {
|
||||
margin-bottom: 2rem;
|
||||
@@ -553,4 +601,122 @@ body {
|
||||
.edit-input:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
/* Doc viewer modal content */
|
||||
.doc-viewer-content {
|
||||
color: var(--text);
|
||||
line-height: 1.7;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.doc-viewer-content h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--border);
|
||||
background: var(--gradient);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.doc-viewer-content h2 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin: 1.5rem 0 0.75rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.doc-viewer-content h3 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
margin: 1.2rem 0 0.5rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.doc-viewer-content p {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.doc-viewer-content ul, .doc-viewer-content ol {
|
||||
padding-left: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.doc-viewer-content li {
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.doc-viewer-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.doc-viewer-content th {
|
||||
background: rgba(102, 126, 234, 0.15);
|
||||
color: var(--primary);
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.doc-viewer-content td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.doc-viewer-content tr:hover td {
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
}
|
||||
|
||||
.doc-viewer-content code {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.doc-viewer-content pre {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.doc-viewer-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.doc-viewer-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.doc-viewer-content strong {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.doc-viewer-content blockquote {
|
||||
border-left: 3px solid var(--primary);
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0.75rem 0;
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
/* Doc modal header override (gradient instead of red) */
|
||||
.modal-box .modal-header:has(+ div .doc-viewer-content),
|
||||
.modal-box:has(.doc-viewer-content) .modal-header {
|
||||
background: var(--gradient);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/// <reference types="vite/client" />
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/bitacoras/api';
|
||||
|
||||
interface Nodo {
|
||||
id: number;
|
||||
nombre: string;
|
||||
ip: string;
|
||||
tipo: string;
|
||||
descripcion: string;
|
||||
}
|
||||
|
||||
interface Entrada {
|
||||
id: number;
|
||||
inicio: string;
|
||||
fin: string | null;
|
||||
descripcion: string;
|
||||
estado: string;
|
||||
modo: string;
|
||||
nodo_nombre: string;
|
||||
}
|
||||
|
||||
interface Gestion {
|
||||
id: number;
|
||||
tipo: string;
|
||||
detalle: string;
|
||||
estado: string;
|
||||
nodo_nombre: string;
|
||||
}
|
||||
|
||||
interface AmbitoInfo {
|
||||
id: number;
|
||||
nombre: string;
|
||||
descripcion: string;
|
||||
}
|
||||
|
||||
interface DashboardData {
|
||||
ambito: AmbitoInfo;
|
||||
nodos: Nodo[];
|
||||
entradas: Entrada[];
|
||||
gestion: Gestion[];
|
||||
}
|
||||
|
||||
export default function AmbitoDashboard({ id, onBack }: { id: number; onBack: () => void }) {
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDashboard = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/ambitos/${id}/dashboard`);
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
} catch (err) {
|
||||
console.error('Error dashboard:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchDashboard();
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <div className="loading-state">Cargando dashboard de ámbito...</div>;
|
||||
if (!data) return <div className="error-state">No se pudo cargar la información del ámbito.</div>;
|
||||
|
||||
const { ambito, nodos, entradas, gestion } = data;
|
||||
|
||||
return (
|
||||
<div className="ambito-dashboard fade-in">
|
||||
<div className="dashboard-header" style={{ marginBottom: '2rem', display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<button className="btn btn-sm" onClick={onBack} style={{ background: 'var(--bg-card)', color: 'var(--text)' }}>
|
||||
<i className="fas fa-arrow-left"></i> Volver
|
||||
</button>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: '1.6rem', fontWeight: 700 }}>{ambito.nombre}</h2>
|
||||
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.9rem' }}>{ambito.descripcion || 'Sin descripción'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
|
||||
{/* Columna Izquierda: Nodos y Gestión */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
<div className="card">
|
||||
<div className="card-header"><i className="fas fa-server"></i> Nodos del Ámbito ({nodos.length})</div>
|
||||
<div style={{ padding: '1rem' }}>
|
||||
{nodos.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>No hay nodos asignados a este ámbito.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{nodos.map(n => (
|
||||
<div key={n.id} className="badge" style={{ padding: '0.5rem 0.8rem', background: 'var(--bg-tag)', border: '1px solid var(--border)' }}>
|
||||
<div style={{ fontWeight: 600 }}>{n.nombre}</div>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{n.ip || 'Sin IP'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header"><i className="fas fa-clipboard-list"></i> Gestión Pendiente ({gestion.length})</div>
|
||||
<div style={{ padding: '0' }}>
|
||||
{gestion.length === 0 ? (
|
||||
<p style={{ padding: '1rem', color: 'var(--text-muted)', fontSize: '0.85rem' }}>No hay tareas pendientes en este ámbito.</p>
|
||||
) : (
|
||||
<table className="table-ifde" style={{ fontSize: '0.85rem' }}>
|
||||
<tbody>
|
||||
{gestion.map(g => (
|
||||
<tr key={g.id}>
|
||||
<td style={{ width: '100px', fontWeight: 600 }}>{g.nodo_nombre}</td>
|
||||
<td>{g.detalle}</td>
|
||||
<td style={{ width: '40px', textAlign: 'center' }}>{g.estado}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Columna Derecha: Entradas Recientes */}
|
||||
<div className="card">
|
||||
<div className="card-header"><i className="fas fa-history"></i> Actividad Reciente</div>
|
||||
<div style={{ padding: '0' }}>
|
||||
{entradas.length === 0 ? (
|
||||
<p style={{ padding: '1rem', color: 'var(--text-muted)', fontSize: '0.85rem' }}>Sin actividad reciente.</p>
|
||||
) : (
|
||||
<table className="table-ifde" style={{ fontSize: '0.85rem' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>I</th>
|
||||
<th>F</th>
|
||||
<th>Nodo</th>
|
||||
<th>Descripción</th>
|
||||
<th>E</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entradas.map(e => (
|
||||
<tr key={e.id}>
|
||||
<td className="col-time">{e.inicio?.substring(0, 5)}</td>
|
||||
<td className="col-time">{e.fin ? e.fin.substring(0, 5) : '—'}</td>
|
||||
<td style={{ fontWeight: 600, width: '100px' }}>{e.nodo_nombre}</td>
|
||||
<td>{e.descripcion.substring(0, 100)}{e.descripcion.length > 100 ? '...' : ''}</td>
|
||||
<td style={{ width: '40px', textAlign: 'center' }}>{e.estado}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
cors: true,
|
||||
allowedHosts: ['ns8.frlr.utn.edu.ar', 'localhost'],
|
||||
allowedHosts: ['ns8.frlr.utn.edu.ar', 'localhost', 'srv-ns8', 'srv-ns8.tail.dasuten', 'srv-ns8.tail.rmonla', '100.111.195.4'],
|
||||
proxy: {
|
||||
'/bitacoras/api': {
|
||||
target: 'http://api:3001',
|
||||
|
||||
Reference in New Issue
Block a user