Reestructuración modular integral de servicios, reorganización de gestión en docs/ y limpieza profunda del repositorio

This commit is contained in:
Ricardo Monla
2026-03-06 18:03:07 -03:00
parent 15725e0b3c
commit ac9bdf516b
286 changed files with 36187 additions and 112775 deletions
+12
View File
@@ -0,0 +1,12 @@
# Archivo: apps.conf
# Ubicación original centralizada: /home/rmonla/Documentos/GitHub/dtic-srv-NS8/servicios/nginx/conf.d/apps.conf
# Este bloque de NGINX maneja el servicio de APPs (TinyFileManager)
location /apps/ {
client_max_body_size 20G;
proxy_pass http://127.0.0.1:8082/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
+24 -15
View File
@@ -1,17 +1,26 @@
server {
listen 80;
server_name bitacoras.midominio.com; # <--- CAMBIAR POR DOMINIO REAL
# Archivo: bitacoras.conf
# Ubicación original centralizada: /home/rmonla/Documentos/GitHub/dtic-srv-NS8/servicios/nginx/conf.d/bitacoras.conf
# Este bloque de NGINX maneja todos los endpoints de dtic-BITACORAs
# Frontend
location / {
proxy_pass http://127.0.0.1:5173;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Backend API (Opcional: usar /api o subdominio api.bitacoras...)
location /api/ {
proxy_pass http://127.0.0.1:3001/;
proxy_set_header Host $host;
}
# P2603 - dtic-BITACORAs API
location /bitacoras/api/ {
proxy_pass http://127.0.0.1:3002/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# P2603 - dtic-BITACORAs Frontend
location /bitacoras/ {
proxy_pass http://127.0.0.1:5174/bitacoras/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
+16
View File
@@ -0,0 +1,16 @@
# Archivo: homepage.conf
# Ubicación original centralizada: /home/rmonla/Documentos/GitHub/dtic-srv-NS8/servicios/nginx/conf.d/homepage.conf
# Este bloque de NGINX maneja el sitio principal (Homepage)
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Soporte para WebSockets si es necesario por Homepage
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
@@ -0,0 +1,7 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3001
CMD ["npm", "run", "dev"]
@@ -0,0 +1,19 @@
{
"name": "dtic-bitacoras-api",
"version": "1.0.0",
"description": "API REST para dtic-BITACORAs",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2",
"helmet": "^7.1.0",
"pg": "^8.11.3"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}
@@ -0,0 +1,122 @@
module.exports = (pool) => {
const router = require('express').Router();
// GET /api/bitacoras — Listar bitácoras (opcionalmente por fecha)
router.get('/', async (req, res) => {
const { fecha } = req.query;
if (fecha) {
const { rows } = await pool.query('SELECT * FROM bitacoras.bitacoras WHERE fecha = $1', [fecha]);
if (rows.length === 0) {
// Auto-crear bitácora del día
const insert = await pool.query(
'INSERT INTO bitacoras.bitacoras (fecha) VALUES ($1) RETURNING *', [fecha]
);
return res.json(insert.rows[0]);
}
return res.json(rows[0]);
}
const { rows } = await pool.query('SELECT * FROM bitacoras.bitacoras ORDER BY fecha DESC LIMIT 30');
res.json(rows);
});
// GET /api/bitacoras/:fecha/completa — Bitácora completa con entradas agrupadas por nodo
router.get('/:fecha/completa', async (req, res) => {
const { fecha } = req.params;
// Obtener o crear bitácora
let bitacora;
const bResult = await pool.query('SELECT * FROM bitacoras.bitacoras WHERE fecha = $1', [fecha]);
if (bResult.rows.length === 0) {
const insert = await pool.query('INSERT INTO bitacoras.bitacoras (fecha) VALUES ($1) RETURNING *', [fecha]);
bitacora = insert.rows[0];
} else {
bitacora = bResult.rows[0];
}
// Obtener entradas con datos del nodo
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]);
// Agrupar por nodo
const porNodo = {};
entradas.forEach(e => {
const nodo = e.nodo_nombre || 'sin-nodo';
if (!porNodo[nodo]) porNodo[nodo] = { nodo: nodo, ip: e.nodo_ip, entradas: [] };
porNodo[nodo].entradas.push(e);
});
res.json({ ...bitacora, nodos: Object.values(porNodo) });
});
// GET /api/bitacoras/:fecha/export — Exportar a Markdown
router.get('/:fecha/export', async (req, res) => {
const { fecha } = req.params;
const bResult = await pool.query('SELECT * FROM bitacoras.bitacoras WHERE fecha = $1', [fecha]);
if (bResult.rows.length === 0) return res.status(404).json({ error: 'Bitácora no encontrada' });
const bitacora = bResult.rows[0];
const { rows: entradas } = await pool.query(`
SELECT e.*, n.nombre as nodo_nombre
FROM bitacoras.entradas e
LEFT JOIN bitacoras.nodos n ON e.nodo_id = n.id
WHERE e.bitacora_id = $1
ORDER BY n.nombre, e.inicio DESC
`, [bitacora.id]);
// Generar Markdown compatible con ADN
const [y, m, d] = fecha.split('-');
let md = `# Bitácora de Operaciones - ${d}/${m}/${y}\n\n`;
md += `## 📂 Actividades Detalladas\n\n`;
const porNodo = {};
entradas.forEach(e => {
const nodo = e.nodo_nombre || 'sin-nodo';
if (!porNodo[nodo]) porNodo[nodo] = [];
porNodo[nodo].push(e);
});
Object.entries(porNodo).forEach(([nodo, entries]) => {
md += `### ${nodo}\n\n`;
md += `| I | F | Descripción | E |\n`;
md += `| :--- | :--- | :--- | :--- |\n`;
entries.forEach(e => {
const inicio = e.inicio ? e.inicio.substring(0, 5) : '-';
const fin = e.fin ? e.fin.substring(0, 5) : '-';
const ia = e.es_ia ? '(IA) ' : '';
const estado = `[${e.modo}] ${e.estado}`;
md += `| ${inicio} | ${fin} | ${ia}${e.descripcion} | ${estado} |\n`;
});
md += '\n';
});
res.set('Content-Type', 'text/markdown');
res.send(md);
});
// PUT /api/bitacoras/:id — Actualizar resumen de bitácora
router.put('/:id', async (req, res) => {
const { id } = req.params;
const { resumen } = req.body;
const { rows } = await pool.query(
'UPDATE bitacoras.bitacoras SET resumen=$1 WHERE id=$2 RETURNING *',
[resumen, id]
);
if (rows.length === 0) return res.status(404).json({ error: 'Bitácora no encontrada' });
res.json(rows[0]);
});
// DELETE /api/bitacoras/:id — Eliminar bitácora y sus entradas
router.delete('/:id', async (req, res) => {
await pool.query('DELETE FROM bitacoras.entradas WHERE bitacora_id = $1', [req.params.id]);
const { rowCount } = await pool.query('DELETE FROM bitacoras.bitacoras WHERE id = $1', [req.params.id]);
if (rowCount === 0) return res.status(404).json({ error: 'Bitácora no encontrada' });
res.json({ message: 'Bitácora eliminada' });
});
return router;
};
@@ -0,0 +1,46 @@
module.exports = (pool) => {
const router = require('express').Router();
// GET /api/entradas — Listar entradas (filtro por bitacora_id o nodo_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 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}`; }
query += ' ORDER BY e.inicio DESC';
const { rows } = await pool.query(query, params);
res.json(rows);
});
// 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]);
});
// 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]);
});
// DELETE /api/entradas/:id — Eliminar entrada
router.delete('/:id', async (req, res) => {
const { rowCount } = await pool.query('DELETE FROM bitacoras.entradas WHERE id = $1', [req.params.id]);
if (rowCount === 0) return res.status(404).json({ error: 'Entrada no encontrada' });
res.json({ message: 'Entrada eliminada' });
});
return router;
};
@@ -0,0 +1,49 @@
module.exports = (pool) => {
const router = require('express').Router();
// 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);
});
// 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]);
});
// 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]);
});
// DELETE /api/nodos/:id — Eliminar nodo (lógico o físico si no tiene entradas asociadas)
router.delete('/:id', async (req, res) => {
try {
const { rowCount } = await pool.query('DELETE FROM bitacoras.nodos WHERE id = $1', [req.params.id]);
if (rowCount === 0) return res.status(404).json({ error: 'Nodo no encontrado' });
res.json({ message: 'Nodo eliminado' });
} catch (err) {
// Si hay foreign key violation, podemos marcarlo como inactivo
if (err.code === '23503') {
await pool.query('UPDATE bitacoras.nodos SET activo=false WHERE id = $1', [req.params.id]);
return res.json({ message: 'Nodo marcado como inactivo por tener entradas asociadas' });
}
res.status(500).json({ error: err.message });
}
});
return router;
};
@@ -0,0 +1,118 @@
module.exports = (pool) => {
const router = require('express').Router();
// GET /api/proyectos — Listar proyectos
router.get('/', async (req, res) => {
const { rows } = await pool.query('SELECT * FROM bitacoras.proyectos ORDER BY codigo');
res.json(rows);
});
// GET /api/proyectos/:codigo — Detalle con fases, hitos y métricas
router.get('/:codigo', async (req, res) => {
const { codigo } = req.params;
const pResult = await pool.query('SELECT * FROM bitacoras.proyectos WHERE codigo = $1', [codigo]);
if (pResult.rows.length === 0) return res.status(404).json({ error: 'Proyecto no encontrado' });
const proyecto = pResult.rows[0];
// Fases con hitos y nodo
const { rows: fases } = await pool.query(`
SELECT f.*, json_agg(
json_build_object(
'id', h.id,
'id_hito', h.id_hito,
'titulo', h.titulo,
'descripcion', h.descripcion,
'estado', h.estado,
'fecha', h.fecha,
'horas_presencial', h.horas_presencial,
'horas_remoto', h.horas_remoto,
'nodo_nombre', n.nombre,
'nodo_ip', n.ip
) ORDER BY h.fecha NULLS LAST, h.id
) FILTER (WHERE h.id IS NOT NULL) AS hitos
FROM bitacoras.fases f
LEFT JOIN bitacoras.hitos h ON h.fase_id = f.id
LEFT JOIN bitacoras.nodos n ON h.nodo_id = n.id
WHERE f.proyecto_id = $1
GROUP BY f.id
ORDER BY f.numero
`, [proyecto.id]);
// Métricas globales
const { rows: [metricas] } = await pool.query(`
SELECT
COUNT(*) FILTER (WHERE h.estado = '✅') AS hitos_completados,
COUNT(*) FILTER (WHERE h.estado != '✅') AS hitos_pendientes,
COUNT(*) AS hitos_total,
COALESCE(SUM(h.horas_presencial), 0) AS total_presencial,
COALESCE(SUM(h.horas_remoto), 0) AS total_remoto,
COALESCE(SUM(h.horas_presencial + h.horas_remoto), 0) AS total_horas
FROM bitacoras.hitos h
JOIN bitacoras.fases f ON h.fase_id = f.id
WHERE f.proyecto_id = $1
`, [proyecto.id]);
// Nodos únicos involucrados
const { rows: nodos } = await pool.query(`
SELECT DISTINCT n.id, n.nombre, n.ip, n.tipo, n.descripcion
FROM bitacoras.hitos h
JOIN bitacoras.fases f ON h.fase_id = f.id
JOIN bitacoras.nodos n ON h.nodo_id = n.id
WHERE f.proyecto_id = $1
ORDER BY n.nombre
`, [proyecto.id]);
res.json({ ...proyecto, fases, metricas, nodos });
});
// POST /api/proyectos/:codigo/hitos — Crear hito
router.post('/:codigo/hitos', async (req, res) => {
const { codigo } = req.params;
const { fase_numero, id_hito, titulo, descripcion, estado, fecha, horas_presencial, horas_remoto, nodo_nombre } = req.body;
const pResult = await pool.query('SELECT id FROM bitacoras.proyectos WHERE codigo = $1', [codigo]);
if (pResult.rows.length === 0) return res.status(404).json({ error: 'Proyecto no encontrado' });
const fResult = await pool.query(
'SELECT id FROM bitacoras.fases WHERE proyecto_id = $1 AND numero = $2',
[pResult.rows[0].id, fase_numero]
);
if (fResult.rows.length === 0) return res.status(404).json({ error: 'Fase no encontrada' });
let nodo_id = null;
if (nodo_nombre) {
const nResult = await pool.query('SELECT id FROM bitacoras.nodos WHERE nombre = $1', [nodo_nombre]);
if (nResult.rows.length > 0) nodo_id = nResult.rows[0].id;
}
const { rows } = await pool.query(
`INSERT INTO bitacoras.hitos (fase_id, id_hito, titulo, descripcion, estado, fecha, horas_presencial, horas_remoto, nodo_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
[fResult.rows[0].id, id_hito, titulo, descripcion || '', estado || '⏳', fecha || null,
horas_presencial || 0, horas_remoto || 0, nodo_id]
);
res.status(201).json(rows[0]);
});
// PUT /api/proyectos/hitos/:id — Actualizar hito
router.put('/hitos/:id', async (req, res) => {
const { id } = req.params;
const { titulo, descripcion, estado, fecha, horas_presencial, horas_remoto } = req.body;
const { rows } = await pool.query(
`UPDATE bitacoras.hitos SET
titulo = COALESCE($1, titulo),
descripcion = COALESCE($2, descripcion),
estado = COALESCE($3, estado),
fecha = COALESCE($4, fecha),
horas_presencial = COALESCE($5, horas_presencial),
horas_remoto = COALESCE($6, horas_remoto)
WHERE id = $7 RETURNING *`,
[titulo, descripcion, estado, fecha, horas_presencial, horas_remoto, id]
);
if (rows.length === 0) return res.status(404).json({ error: 'Hito no encontrado' });
res.json(rows[0]);
});
return router;
};
@@ -0,0 +1,41 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const { Pool } = require('pg');
const app = express();
const PORT = process.env.PORT || 3001;
// Middleware
app.use(helmet());
app.use(cors({ origin: process.env.CORS_ORIGIN || '*' }));
app.use(express.json());
// Database pool
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Health check
app.get('/health', async (req, res) => {
try {
await pool.query('SELECT 1');
res.json({ status: 'ok', timestamp: new Date().toISOString() });
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
});
// Routes
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));
// Error handler
app.use((err, req, res, next) => {
console.error('Error:', err.message);
res.status(500).json({ error: err.message });
});
app.listen(PORT, () => {
console.log(`🔐 dtic-BITACORAs API corriendo en puerto ${PORT}`);
});
@@ -0,0 +1,47 @@
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_USER: dtic_user
POSTGRES_PASSWORD: dtic_pass_2026
POSTGRES_DB: dtic_bitacoras
ports:
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dtic_user -d dtic_bitacoras"]
interval: 5s
timeout: 5s
retries: 5
api:
build: ./backend
ports:
- "3002:3001"
environment:
NODE_ENV: development
PORT: 3001
DATABASE_URL: postgres://dtic_user:dtic_pass_2026@postgres:5432/dtic_bitacoras
JWT_SECRET: dtic_bitacoras_secret_2026
CORS_ORIGIN: "*"
depends_on:
postgres:
condition: service_healthy
volumes:
- ./backend/src:/app/src
frontend:
build: ./frontend
ports:
- "5174:5173"
environment:
VITE_API_URL: /bitacoras/api
depends_on:
- api
volumes:
- ./frontend/src:/app/src
volumes:
postgres_data:
@@ -0,0 +1,91 @@
-- dtic-BITACORAs - Esquema de Base de Datos
-- Formato I-F-D-E (Inicio, Fin, Descripción, Estado)
CREATE SCHEMA IF NOT EXISTS bitacoras;
-- Nodos del sistema (servidores, VMs, PCs)
CREATE TABLE bitacoras.nodos (
id SERIAL PRIMARY KEY,
nombre VARCHAR(50) NOT NULL UNIQUE,
ip VARCHAR(45),
tipo VARCHAR(20) DEFAULT 'servidor',
descripcion TEXT,
activo BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Bitácoras diarias
CREATE TABLE bitacoras.bitacoras (
id SERIAL PRIMARY KEY,
fecha DATE NOT NULL UNIQUE,
resumen TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Temas (agrupadores de entradas por nodo)
CREATE TABLE bitacoras.temas (
id SERIAL PRIMARY KEY,
id_tema VARCHAR(20) NOT NULL,
titulo VARCHAR(200) NOT NULL,
estado VARCHAR(10) DEFAULT '',
descripcion TEXT,
bitacora_id INTEGER REFERENCES bitacoras.bitacoras(id) ON DELETE CASCADE,
nodo_id INTEGER REFERENCES bitacoras.nodos(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Entradas cronológicas (formato I-F-D-E)
CREATE TABLE bitacoras.entradas (
id SERIAL PRIMARY KEY,
inicio TIME NOT NULL,
fin TIME,
descripcion TEXT NOT NULL,
estado VARCHAR(10) DEFAULT '',
modo CHAR(1) DEFAULT 'P' CHECK (modo IN ('P', 'R')),
es_ia BOOLEAN DEFAULT false,
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),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Índices
CREATE INDEX idx_bitacoras_fecha ON bitacoras.bitacoras(fecha);
CREATE INDEX idx_entradas_bitacora ON bitacoras.entradas(bitacora_id);
CREATE INDEX idx_entradas_nodo ON bitacoras.entradas(nodo_id);
CREATE INDEX idx_temas_bitacora ON bitacoras.temas(bitacora_id);
-- Trigger para updated_at
CREATE OR REPLACE FUNCTION bitacoras.update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_nodos_updated BEFORE UPDATE ON bitacoras.nodos
FOR EACH ROW EXECUTE FUNCTION bitacoras.update_timestamp();
CREATE TRIGGER trg_bitacoras_updated BEFORE UPDATE ON bitacoras.bitacoras
FOR EACH ROW EXECUTE FUNCTION bitacoras.update_timestamp();
CREATE TRIGGER trg_temas_updated BEFORE UPDATE ON bitacoras.temas
FOR EACH ROW EXECUTE FUNCTION bitacoras.update_timestamp();
CREATE TRIGGER trg_entradas_updated BEFORE UPDATE ON bitacoras.entradas
FOR EACH ROW EXECUTE FUNCTION bitacoras.update_timestamp();
-- Datos semilla: Nodos del proyecto
INSERT INTO bitacoras.nodos (nombre, ip, tipo, descripcion) VALUES
('srv-ns8', '10.0.10.8', 'servidor', 'Nodo central de gestión, GIT y Dashboard'),
('srv-dasu', '10.0.10.205', 'hipervisor', 'Hipervisor Proxmox VE (DASUTEN)'),
('dc-dasuten', '10.0.100.10', 'vm', 'Controlador de Dominio AD DS + DNS'),
('sql-dasuten', '10.0.100.11', 'vm', 'Motor SQL Server 2019 Core'),
('pcv-dasu0', '10.0.100.12', 'vm', 'VM de pruebas Win 10 LTSC'),
('pc-dasu0', NULL, 'pc', 'PC física cliente oficina DASUTeN'),
('srv-pmox1', '10.0.10.201', 'hipervisor', 'Pool master Proxmox'),
('srv-pmox2', '10.0.10.202', 'hipervisor', 'Nodo Proxmox secundario'),
('srv-pmox3', '10.0.10.203', 'hipervisor', 'Nodo Proxmox terciario (64GB RAM)'),
('srv-xen1', '10.0.10.23', 'hipervisor', 'Citrix XenServer 7.0 (Legacy)');
@@ -0,0 +1,7 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="dtic-BITACORAs - Sistema de Bitácoras de Operaciones DTIC UTNLR" />
<title>dtic-BITACORAs | DTIC UTNLR</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,21 @@
{
"name": "dtic-bitacoras-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.2.2",
"vite": "^5.1.0"
}
}
@@ -0,0 +1,528 @@
/// <reference types="vite/client" />
import React, { useState, useEffect, useCallback } from 'react';
import ProyectoDashboard from './pages/ProyectoDashboard';
const API_URL = import.meta.env.VITE_API_URL || '/bitacoras/api';
interface Entrada {
id: number;
inicio: string;
fin: string | null;
descripcion: string;
estado: string;
modo: string;
es_ia: boolean;
nodo_nombre: string;
nodo_ip: string;
}
interface NodoGroup {
nodo: string;
ip: string;
entradas: Entrada[];
}
interface Bitacora {
id: number;
fecha: string;
resumen: string;
nodos: NodoGroup[];
}
interface Nodo {
id: number;
nombre: string;
ip: string;
tipo: string;
descripcion: string;
activo: boolean;
}
// ===== MODAL DE CONFIRMACIÓN =====
function ConfirmModal({ show, title, message, onConfirm, onCancel }: {
show: boolean; title: string; message: string;
onConfirm: () => void; onCancel: () => void;
}) {
if (!show) return null;
return (
<div className="modal-overlay" onClick={onCancel}>
<div className="modal-box" onClick={e => e.stopPropagation()}>
<div className="modal-header"><i className="fas fa-exclamation-triangle"></i> {title}</div>
<p className="modal-body">{message}</p>
<div className="modal-actions">
<button className="btn btn-sm" onClick={onCancel} style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', color: 'var(--text)' }}>Cancelar</button>
<button className="btn btn-sm btn-danger" onClick={onConfirm}><i className="fas fa-trash-alt"></i> Eliminar</button>
</div>
</div>
</div>
);
}
// ===== TABS =====
type Tab = 'bitacora' | 'nodos' | 'proyecto';
function getInitialTab(): Tab {
const path = window.location.pathname.toLowerCase();
if (path.includes('/p2601')) return 'proyecto';
if (path.includes('/nodos')) return 'nodos';
return 'bitacora';
}
function App() {
const [tab, setTab] = useState<Tab>(getInitialTab());
const changeTab = (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',
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[]>([]);
const [loading, setLoading] = useState(true);
// Form: nueva entrada
const [newInicio, setNewInicio] = useState('');
const [newFin, setNewFin] = useState('');
const [newDesc, setNewDesc] = useState('');
const [newEstado, setNewEstado] = useState('⏳');
const [newModo, setNewModo] = useState('P');
const [newNodoId, setNewNodoId] = useState('');
const [newEsIa, setNewEsIa] = useState(false);
// Edit: entrada en edición
const [editId, setEditId] = useState<number | null>(null);
const [editInicio, setEditInicio] = useState('');
const [editFin, setEditFin] = useState('');
const [editDesc, setEditDesc] = useState('');
const [editEstado, setEditEstado] = useState('');
const [editModo, setEditModo] = useState('');
const [editEsIa, setEditEsIa] = useState(false);
// Confirm delete
const [confirmDelete, setConfirmDelete] = useState<{ type: string; id: number; name: string } | null>(null);
// Nodo form
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('');
// === FETCH ===
const fetchBitacora = useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`${API_URL}/bitacoras/${fecha}/completa`);
const data = await res.json();
setBitacora(data);
} catch (err) { console.error('Error:', err); }
setLoading(false);
}, [fecha]);
const fetchNodos = async () => {
try {
const res = await fetch(`${API_URL}/nodos`);
setNodos(await res.json());
} catch (err) { console.error('Error:', err); }
};
useEffect(() => { fetchBitacora(); }, [fetchBitacora]);
useEffect(() => { fetchNodos(); }, []);
// === ENTRADAS CRUD ===
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!bitacora || !newDesc || !newInicio || !newNodoId) return;
try {
await fetch(`${API_URL}/entradas`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
inicio: newInicio, fin: newFin || null, descripcion: newDesc,
estado: newEstado, modo: newModo, es_ia: newEsIa,
bitacora_id: bitacora.id, nodo_id: parseInt(newNodoId)
})
});
setNewDesc(''); setNewInicio(''); setNewFin('');
fetchBitacora();
} catch (err) { console.error('Error:', err); }
};
const startEdit = (e: Entrada) => {
setEditId(e.id);
setEditInicio(e.inicio?.substring(0, 5) || '');
setEditFin(e.fin ? e.fin.substring(0, 5) : '');
setEditDesc(e.descripcion);
setEditEstado(e.estado);
setEditModo(e.modo);
setEditEsIa(e.es_ia);
};
const cancelEdit = () => setEditId(null);
const saveEdit = async () => {
if (!editId) return;
await fetch(`${API_URL}/entradas/${editId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
inicio: editInicio, fin: editFin || null, descripcion: editDesc,
estado: editEstado, modo: editModo, es_ia: editEsIa,
})
});
setEditId(null);
fetchBitacora();
};
const requestDeleteEntrada = (id: number, desc: string) => {
setConfirmDelete({ type: 'entrada', id, name: desc.substring(0, 50) + '...' });
};
const executeDelete = async () => {
if (!confirmDelete) return;
const { type, id } = confirmDelete;
if (type === 'entrada') {
await fetch(`${API_URL}/entradas/${id}`, { method: 'DELETE' });
fetchBitacora();
} else if (type === 'nodo') {
await fetch(`${API_URL}/nodos/${id}`, { method: 'DELETE' });
fetchNodos();
}
setConfirmDelete(null);
};
// === NODOS CRUD ===
const resetNodoForm = () => {
setEditNodoId(null); setNodoNombre(''); setNodoIp(''); setNodoTipo('servidor'); setNodoDesc('');
setNodoFormVisible(false);
};
const openNodoForm = (nodo?: Nodo) => {
if (nodo) {
setEditNodoId(nodo.id); setNodoNombre(nodo.nombre); setNodoIp(nodo.ip || '');
setNodoTipo(nodo.tipo || 'servidor'); setNodoDesc(nodo.descripcion || '');
} else {
setEditNodoId(null); setNodoNombre(''); setNodoIp(''); setNodoTipo('servidor'); setNodoDesc('');
}
setNodoFormVisible(true);
};
const saveNodo = async (e: React.FormEvent) => {
e.preventDefault();
if (!nodoNombre) return;
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 })
});
} else {
await fetch(`${API_URL}/nodos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nombre: nodoNombre, ip: nodoIp, tipo: nodoTipo, descripcion: nodoDesc })
});
}
resetNodoForm();
fetchNodos();
};
const requestDeleteNodo = (id: number, nombre: string) => {
setConfirmDelete({ type: 'nodo', id, name: nombre });
};
// === STATS ===
const totalEntradas = bitacora?.nodos?.reduce((sum, n) => sum + n.entradas.length, 0) || 0;
const totalNodos = bitacora?.nodos?.length || 0;
return (
<div className="app">
{/* Modal de confirmación */}
<ConfirmModal
show={!!confirmDelete}
title="Confirmar eliminación"
message={`¿Estás seguro que querés eliminar "${confirmDelete?.name}"? Esta acción no se puede deshacer.`}
onConfirm={executeDelete}
onCancel={() => setConfirmDelete(null)}
/>
<nav className="navbar">
<div className="navbar-brand">
<i className="fas fa-book-open"></i>
dtic-BITACORAs
<span className="badge badge-gradient">v1.1</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
className={`btn btn-sm ${tab === 'bitacora' ? 'btn-primary' : ''}`}
onClick={() => changeTab('bitacora')}
style={tab !== 'bitacora' ? { background: 'var(--bg-card)', color: 'var(--text)', border: '1px solid var(--border)' } : {}}
><i className="fas fa-book"></i> Bitácora</button>
<button
className={`btn btn-sm ${tab === 'nodos' ? 'btn-primary' : ''}`}
onClick={() => changeTab('nodos')}
style={tab !== 'nodos' ? { background: 'var(--bg-card)', color: 'var(--text)', border: '1px solid var(--border)' } : {}}
><i className="fas fa-server"></i> Nodos</button>
<button
className={`btn btn-sm ${tab === 'proyecto' ? 'btn-primary' : ''}`}
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> P2601</button>
{tab === 'bitacora' && (
<a href={`${API_URL}/bitacoras/${fecha}/export`} target="_blank" rel="noopener"
className="btn btn-sm btn-primary">
<i className="fas fa-file-export"></i> Exportar MD
</a>
)}
</div>
</nav>
<main className="main">
{/* =================== TAB BITÁCORA =================== */}
{tab === 'bitacora' && (
<>
{/* Date Selector */}
<div className="date-selector">
<button className="btn btn-sm btn-primary" onClick={() => {
const d = new Date(fecha); d.setDate(d.getDate() - 1); setFecha(d.toISOString().split('T')[0]);
}}><i className="fas fa-chevron-left"></i></button>
<input type="date" value={fecha} onChange={e => setFecha(e.target.value)} />
<button className="btn btn-sm btn-primary" onClick={() => {
const d = new Date(fecha); d.setDate(d.getDate() + 1); setFecha(d.toISOString().split('T')[0]);
}}><i className="fas fa-chevron-right"></i></button>
<button className="btn btn-sm" onClick={() => setFecha(new Date().toISOString().split('T')[0])}
style={{ background: 'var(--bg-card)', color: 'var(--text-secondary)', border: '1px solid var(--border)' }}>
<i className="fas fa-calendar-day"></i> Hoy
</button>
</div>
{/* Stats */}
<div className="stats-bar">
<div className="stat-card">
<div className="stat-value">{totalEntradas}</div>
<div className="stat-label">Entradas</div>
</div>
<div className="stat-card">
<div className="stat-value">{totalNodos}</div>
<div className="stat-label">Nodos Activos</div>
</div>
<div className="stat-card">
<div className="stat-value">{bitacora?.nodos?.reduce((s, n) => s + n.entradas.filter(e => e.estado === '✅').length, 0) || 0}</div>
<div className="stat-label">Completadas</div>
</div>
<div className="stat-card">
<div className="stat-value">{bitacora?.nodos?.reduce((s, n) => s + n.entradas.filter(e => e.estado === '⏳').length, 0) || 0}</div>
<div className="stat-label">En Proceso</div>
</div>
</div>
{/* New Entry Form */}
<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="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>
{nodos.map(n => <option key={n.id} value={n.id}>{n.nombre}</option>)}
</select>
<select value={newEstado} onChange={e => setNewEstado(e.target.value)}>
<option value="⏳"></option><option value="✅"></option>
<option value="⚠️"></option><option value="❌"></option><option value="👁️">👁</option>
</select>
<select value={newModo} onChange={e => setNewModo(e.target.value)}>
<option value="P">[P] Presencial</option><option value="R">[R] Remoto</option>
</select>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', color: 'var(--text-secondary)', fontSize: '0.85rem' }}>
<input type="checkbox" checked={newEsIa} onChange={e => setNewEsIa(e.target.checked)} /> IA
</label>
<button type="submit" className="btn btn-primary"><i className="fas fa-save"></i> Guardar</button>
</form>
</div>
{/* 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>
<table className="table-ifde">
<thead>
<tr>
<th className="col-time">I</th>
<th className="col-time">F</th>
<th>Descripción</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><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%' }} />
<div style={{ display: 'flex', gap: '0.3rem', marginTop: '0.3rem', alignItems: 'center' }}>
<select value={editEstado} onChange={ev => setEditEstado(ev.target.value)} className="edit-input">
<option value="⏳"></option><option value="✅"></option>
<option value="⚠️"></option><option value="❌"></option><option value="👁️">👁</option>
</select>
<select value={editModo} onChange={ev => setEditModo(ev.target.value)} className="edit-input">
<option value="P">[P]</option><option value="R">[R]</option>
</select>
<label style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: '0.2rem' }}>
<input type="checkbox" checked={editEsIa} onChange={ev => setEditEsIa(ev.target.checked)} /> IA
</label>
</div>
</td>
<td></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 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.descripcion}
</td>
<td className="col-status">[{e.modo}] {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)' }}>
<i className="fas fa-inbox fa-3x" style={{ marginBottom: '1rem', display: 'block' }}></i>
Sin entradas para esta fecha. ¡Creá la primera!
</div>
)}
</>
)}
{/* =================== TAB NODOS =================== */}
{tab === 'nodos' && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h2 style={{ fontSize: '1.3rem', fontWeight: 600 }}><i className="fas fa-server" style={{ color: 'var(--primary)', marginRight: '0.5rem' }}></i>Gestión de Nodos</h2>
<button className="btn btn-primary" onClick={() => openNodoForm()}>
<i className="fas fa-plus"></i> Nuevo Nodo
</button>
</div>
{/* Nodo Form */}
{nodoFormVisible && (
<div className="card" style={{ marginBottom: '1.5rem' }}>
<div className="card-header">
<i className={editNodoId ? 'fas fa-pen' : 'fas fa-plus-circle'}></i>
{editNodoId ? 'Editar Nodo' : 'Nuevo Nodo'}
</div>
<form className="form-inline" onSubmit={saveNodo}>
<input type="text" value={nodoNombre} onChange={e => setNodoNombre(e.target.value)} placeholder="Nombre (ej: srv-ns8)" required style={{ minWidth: '150px' }} />
<input type="text" value={nodoIp} onChange={e => setNodoIp(e.target.value)} placeholder="IP (ej: 10.0.10.200)" style={{ minWidth: '130px' }} />
<select value={nodoTipo} onChange={e => setNodoTipo(e.target.value)}>
<option value="servidor">Servidor</option>
<option value="vm">VM</option>
<option value="pc">PC</option>
<option value="switch">Switch</option>
<option value="otro">Otro</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)' }}>
<i className="fas fa-times"></i> Cancelar
</button>
</form>
</div>
)}
{/* Nodos Table */}
<div className="card">
<table className="table-ifde">
<thead>
<tr>
<th>ID</th>
<th>Nombre</th>
<th>IP</th>
<th>Tipo</th>
<th>Descripción</th>
<th style={{ width: '80px' }}>Acc.</th>
</tr>
</thead>
<tbody>
{nodos.map(n => (
<tr key={n.id}>
<td style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{n.id}</td>
<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 style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>{n.descripcion || '—'}</td>
<td>
<div style={{ display: 'flex', gap: '0.2rem' }}>
<button className="btn btn-sm" onClick={() => openNodoForm(n)} title="Editar"
style={{ background: 'var(--info)', color: 'white' }}>
<i className="fas fa-pen"></i>
</button>
<button className="btn btn-sm btn-danger" onClick={() => requestDeleteNodo(n.id, n.nombre)} title="Eliminar">
<i className="fas fa-trash-alt"></i>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{/* =================== TAB PROYECTO =================== */}
{tab === 'proyecto' && (
<ProyectoDashboard codigo="P2601" />
)}
</main>
</div>
);
}
export default App;
@@ -0,0 +1,468 @@
/* dtic-BITACORAs — Estética del Dashboard P2601 */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
:root {
--primary: #667eea;
--secondary: #764ba2;
--gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--info: #3b82f6;
--bg: #0f172a;
--bg-card: #1e293b;
--bg-card-hover: #334155;
--bg-input: #1e293b;
--text: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--border: #334155;
--radius: 12px;
--shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
/* Layout */
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.navbar {
background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border);
padding: 1rem 2rem;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
}
.navbar-brand {
font-size: 1.4rem;
font-weight: 700;
background: var(--gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
display: flex;
align-items: center;
gap: 0.5rem;
}
.navbar-brand i {
-webkit-text-fill-color: initial;
color: var(--primary);
}
.badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 20px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.05em;
}
.badge-gradient {
background: var(--gradient);
color: white;
}
/* Main content */
.main {
flex: 1;
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
width: 100%;
}
/* Cards */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
transition: var(--transition);
animation: fadeIn 0.5s ease-out;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow);
border-color: var(--primary);
}
.card-header {
background: var(--gradient);
color: white;
padding: 1rem 1.5rem;
border-radius: var(--radius) var(--radius) 0 0;
margin: -1.5rem -1.5rem 1.5rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Table I-F-D-E */
.table-ifde {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.table-ifde th {
background: rgba(102, 126, 234, 0.15);
color: var(--primary);
padding: 0.75rem 1rem;
text-align: left;
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 2px solid var(--border);
}
.table-ifde td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.table-ifde tr:hover td {
background: rgba(102, 126, 234, 0.05);
}
.table-ifde .col-time {
width: 60px;
font-family: 'JetBrains Mono', monospace;
color: var(--text-secondary);
font-size: 0.85rem;
}
.table-ifde .col-status {
width: 80px;
text-align: center;
}
.table-ifde .col-desc {
color: var(--text);
}
.table-ifde .ia-tag {
background: rgba(102, 126, 234, 0.2);
color: var(--primary);
padding: 0.1rem 0.4rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
margin-right: 0.3rem;
}
/* Nodo section */
.nodo-section {
margin-bottom: 2rem;
}
.nodo-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--border);
}
.nodo-title i {
color: var(--primary);
}
.nodo-ip {
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 400;
}
/* Form inline */
.form-inline {
display: flex;
gap: 0.5rem;
align-items: center;
padding: 1rem;
background: rgba(102, 126, 234, 0.05);
border-radius: var(--radius);
margin-top: 1rem;
flex-wrap: wrap;
}
.form-inline input,
.form-inline select {
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text);
padding: 0.5rem 0.75rem;
border-radius: 8px;
font-size: 0.85rem;
font-family: inherit;
transition: var(--transition);
}
.form-inline input:focus,
.form-inline select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
}
.form-inline .input-time {
width: 80px;
}
.form-inline .input-desc {
flex: 1;
min-width: 200px;
}
/* Buttons */
.btn {
padding: 0.5rem 1rem;
border-radius: 8px;
border: none;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
font-size: 0.85rem;
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.btn:hover {
transform: translateY(-1px);
}
.btn-primary {
background: var(--gradient);
color: white;
}
.btn-primary:hover {
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.btn-sm {
padding: 0.3rem 0.6rem;
font-size: 0.75rem;
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn-success {
background: var(--success);
color: white;
}
/* Date selector */
.date-selector {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.date-selector input[type="date"] {
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text);
padding: 0.6rem 1rem;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
}
.date-selector input[type="date"]:focus {
outline: none;
border-color: var(--primary);
}
/* Stats bar */
.stats-bar {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 1.5rem;
flex: 1;
min-width: 150px;
text-align: center;
transition: var(--transition);
}
.stat-card:hover {
border-color: var(--primary);
}
.stat-value {
font-size: 2rem;
font-weight: 700;
background: var(--gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.stat-label {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 0.3rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* Responsive */
@media (max-width: 768px) {
.main {
padding: 1rem;
}
.navbar {
padding: 0.75rem 1rem;
}
.stats-bar {
flex-direction: column;
}
.form-inline {
flex-direction: column;
}
.form-inline .input-desc {
min-width: 100%;
}
}
/* Modal de confirmación */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease-out;
}
.modal-box {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0;
max-width: 420px;
width: 90%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
animation: fadeIn 0.3s ease-out;
}
.modal-header {
background: var(--danger);
color: white;
padding: 1rem 1.5rem;
border-radius: var(--radius) var(--radius) 0 0;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.modal-body {
padding: 1.5rem;
color: var(--text-secondary);
line-height: 1.5;
}
.modal-actions {
padding: 0 1.5rem 1.5rem;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
/* Campos de edición inline */
.edit-input {
background: var(--bg-input);
border: 1px solid var(--primary);
color: var(--text);
padding: 0.35rem 0.5rem;
border-radius: 6px;
font-size: 0.8rem;
font-family: inherit;
transition: var(--transition);
}
.edit-input:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
}
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
@@ -0,0 +1,645 @@
import React, { useState, useEffect } from 'react';
const API_URL = import.meta.env.VITE_API_URL || '/bitacoras/api';
interface Hito {
id: number;
id_hito: string;
titulo: string;
descripcion: string;
estado: string;
fecha: string | null;
horas_presencial: number;
horas_remoto: number;
nodo_nombre: string;
nodo_ip: string;
}
interface Fase {
id: number;
numero: number;
nombre: string;
estado: string;
descripcion: string;
fecha_inicio: string | null;
fecha_fin: string | null;
hitos: Hito[] | null;
}
interface NodoInfo {
id: number;
nombre: string;
ip: string;
tipo: string;
descripcion: string;
}
interface Metricas {
hitos_completados: string;
hitos_pendientes: string;
hitos_total: string;
total_presencial: string;
total_remoto: string;
total_horas: string;
}
interface Proyecto {
id: number;
codigo: string;
nombre: string;
estado: string;
descripcion: string;
fases: Fase[];
metricas: Metricas;
nodos: NodoInfo[];
}
type Vista = 'gerencial' | 'tecnica';
/* ═══════════ DONUT CHART SVG ═══════════ */
function DonutChart({ segments, size = 140, strokeWidth = 18, centerLabel, centerValue, centerColor }: {
segments: { value: number; color: string; label: string }[];
size?: number;
strokeWidth?: number;
centerLabel?: string;
centerValue?: string;
centerColor?: string;
}) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const total = segments.reduce((s, seg) => s + seg.value, 0);
let offset = 0;
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
<svg width={size} height={size} style={{ transform: 'rotate(-90deg)' }}>
{/* Fondo gris */}
<circle cx={size / 2} cy={size / 2} r={radius}
fill="none" stroke="rgba(255,255,255,0.05)" strokeWidth={strokeWidth} />
{/* Segmentos */}
{total > 0 && segments.map((seg, i) => {
const segLen = (seg.value / total) * circumference;
const el = (
<circle key={i} cx={size / 2} cy={size / 2} r={radius}
fill="none" stroke={seg.color} strokeWidth={strokeWidth}
strokeDasharray={`${segLen} ${circumference - segLen}`}
strokeDashoffset={-offset}
strokeLinecap="round"
style={{ transition: 'stroke-dasharray 0.6s ease, stroke-dashoffset 0.6s ease' }}
/>
);
offset += segLen;
return el;
})}
{/* Texto central */}
{centerValue && (
<g style={{ transform: 'rotate(90deg)', transformOrigin: 'center' }}>
<text x={size / 2} y={size / 2 - 4} textAnchor="middle" dominantBaseline="middle"
fill={centerColor || '#fff'} fontSize="1.6rem" fontWeight="700">{centerValue}</text>
{centerLabel && (
<text x={size / 2} y={size / 2 + 16} textAnchor="middle" dominantBaseline="middle"
fill="var(--text-muted)" fontSize="0.6rem" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>{centerLabel}</text>
)}
</g>
)}
</svg>
{/* Leyenda */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem 1rem', justifyContent: 'center' }}>
{segments.filter(s => s.value > 0).map((seg, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.7rem', color: 'var(--text-muted)' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: seg.color, flexShrink: 0 }}></span>
{seg.label}
</div>
))}
</div>
</div>
);
}
/* ═══════════ CONTENIDO ENRIQUECIDO POR FASE ═══════════ */
const faseDetalles: Record<number, { objetivo: string; desarrollo: string; logros: string }> = {
1: {
objetivo: 'Preparar el equipo central que alojará todos los servicios de DASUTeN, garantizando que opere de forma independiente a los sistemas de la Facultad.',
desarrollo: 'Se instaló y configuró el servidor dedicado en la oficina de DASUTeN, creando un entorno de trabajo completamente separado de la red universitaria para mayor seguridad y control.',
logros: 'Servidor propio operativo y listo para funcionar. Entorno aislado que brinda independencia total respecto a los sistemas de la Facultad.'
},
2: {
objetivo: 'Establecer un sistema centralizado de identificación de usuarios y permisos de acceso, propio de la oficina DASUTeN.',
desarrollo: 'Se configuró un servicio de gestión de identidades que permite controlar quién accede a cada equipo y recurso dentro de la red de DASUTeN.',
logros: 'Sistema de usuarios operativo. Cada persona accede con credenciales propias. Se simplifica la administración de permisos y la seguridad de acceso.'
},
3: {
objetivo: 'Instalar el sistema de base de datos necesario y recuperar toda la información del sistema DASUTeN desde el respaldo existente.',
desarrollo: 'Se preparó un entorno dedicado para la base de datos, se transfirió el respaldo completo (~9 GB de información) y se restauró verificando su integridad.',
logros: 'Toda la información de gestión de DASUTeN fue recuperada exitosamente. La base de datos está operativa y accesible desde la red interna.'
},
4: {
objetivo: 'Habilitar la administración remota segura de todos los equipos y generar respaldos de seguridad como punto de recuperación.',
desarrollo: 'Se activó el acceso remoto seguro en todos los equipos y se generaron copias de respaldo completas para poder restaurar el sistema ante cualquier eventualidad.',
logros: 'Posibilidad de gestionar todos los equipos sin necesidad de presencia física. Respaldos de seguridad disponibles ante cualquier imprevisto.'
},
5: {
objetivo: 'Analizar la aplicación de gestión DASUTeN y ponerla en funcionamiento sobre la nueva infraestructura virtual.',
desarrollo: 'Configuración de Kermet.ini, instalación de componentes de sistema, fuentes y apertura de firewall SQL. Pruebas de conectividad exitosas.',
logros: 'Despliegue operativo verificado con login exitoso. Validación presencial (UAT) realizada con Andrea Almirón satisfactoriamente.'
},
6: {
objetivo: '🚀 Poner en funcionamiento el servidor físico en la oficina, integrar clientes al dominio y configurar políticas de seguridad.',
desarrollo: 'Traslado físico del servidor Proxmox, unión de la PC pc-dasu0 al dominio dasuten.utnlr y despliegue de GPOs de seguridad.',
logros: 'Hitos pendientes: Instalación física, AD-Join, GPO de seguridad y mapeo de unidades de red.'
}
};
/* ═══════════ MAIN COMPONENT ═══════════ */
export default function ProyectoDashboard({ codigo = 'P2601' }: { codigo?: string }) {
const [proyecto, setProyecto] = useState<Proyecto | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [vista, setVista] = useState<Vista>('gerencial');
const [expandedFases, setExpandedFases] = useState<Set<number>>(new Set());
const [expandedGerFases, setExpandedGerFases] = useState<Set<number>>(new Set());
const [descExpanded, setDescExpanded] = useState(false);
useEffect(() => { fetchProyecto(); }, [codigo]);
const fetchProyecto = async () => {
setLoading(true);
try {
const res = await fetch(`${API_URL}/proyectos/${codigo}`);
if (!res.ok) throw new Error('Proyecto no encontrado');
const data = await res.json();
setProyecto(data);
setExpandedFases(new Set(data.fases.map((f: Fase) => f.id)));
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
};
const toggleFase = (id: number) => {
setExpandedFases(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const toggleGerFase = (num: number) => {
setExpandedGerFases(prev => {
const next = new Set(prev);
next.has(num) ? next.delete(num) : next.add(num);
return next;
});
};
if (loading) return <div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>Cargando proyecto...</div>;
if (error || !proyecto) return <div style={{ textAlign: 'center', padding: '3rem', color: '#ef4444' }}>Error: {error}</div>;
const m = proyecto.metricas;
const totalH = parseFloat(m.total_horas);
const completados = parseInt(m.hitos_completados);
const pendientes = parseInt(m.hitos_pendientes);
const totalHitos = parseInt(m.hitos_total);
const progreso = totalHitos > 0 ? Math.round((completados / totalHitos) * 100) : 0;
const hrsP = parseFloat(m.total_presencial);
const hrsR = parseFloat(m.total_remoto);
const fasesCompletas = proyecto.fases.filter(f => f.estado === '✅').length;
const fasesEnCurso = proyecto.fases.filter(f => f.estado === '⏳').length;
const fasesPendientes = proyecto.fases.filter(f => f.estado !== '✅' && f.estado !== '⏳').length;
const estadoIcon = (estado: string) => {
if (estado === '✅') return '✅';
if (estado === '⏳') return '⏳';
if (estado === '📋') return '📋';
return estado;
};
return (
<div className="proyecto-dashboard">
{/* Header */}
<div style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
<div style={{
display: 'inline-block', padding: '3px 12px', borderRadius: '999px',
background: 'rgba(56,189,248,0.12)', color: '#38bdf8',
fontSize: '0.72rem', fontWeight: 600, letterSpacing: '0.05em', textTransform: 'uppercase',
marginBottom: '0.5rem'
}}>{proyecto.codigo}</div>
<h2 style={{ fontSize: '1.5rem', fontWeight: 700, margin: '0.3rem 0' }}>
{proyecto.nombre}
</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', maxWidth: '600px', margin: '0 auto 0.8rem' }}>
{proyecto.descripcion}
</p>
{/* Toggle vista */}
<div style={{
display: 'inline-flex', borderRadius: '8px', overflow: 'hidden',
border: '1px solid var(--border)', background: 'rgba(255,255,255,0.03)'
}}>
<button
onClick={() => setVista('gerencial')}
style={{
padding: '6px 16px', fontSize: '0.75rem', fontWeight: 600, border: 'none', cursor: 'pointer',
background: vista === 'gerencial' ? 'linear-gradient(135deg, #38bdf8, #818cf8)' : 'transparent',
color: vista === 'gerencial' ? '#fff' : 'var(--text-muted)', transition: 'all 0.2s ease'
}}
><i className="fas fa-chart-pie" style={{ marginRight: '5px' }}></i>Resumen</button>
<button
onClick={() => setVista('tecnica')}
style={{
padding: '6px 16px', fontSize: '0.75rem', fontWeight: 600, border: 'none', cursor: 'pointer',
background: vista === 'tecnica' ? 'linear-gradient(135deg, #38bdf8, #818cf8)' : 'transparent',
color: vista === 'tecnica' ? '#fff' : 'var(--text-muted)', transition: 'all 0.2s ease'
}}
><i className="fas fa-list-alt" style={{ marginRight: '5px' }}></i>Detalle Técnico</button>
</div>
</div>
{/* ═══════════ VISTA GERENCIAL ═══════════ */}
{vista === 'gerencial' && (
<>
{/* Descripción del Proyecto — desplegable */}
<div className="card" style={{ padding: 0, overflow: 'hidden', marginBottom: '1.5rem' }}>
<div
onClick={() => setDescExpanded(prev => !prev)}
style={{
padding: '0.8rem 1rem', cursor: 'pointer', display: 'flex',
alignItems: 'center', justifyContent: 'space-between',
userSelect: 'none'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<span style={{ fontSize: '1.1rem' }}>📋</span>
<span style={{ fontWeight: 600, fontSize: '0.88rem' }}>Descripción del Proyecto</span>
</div>
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', transition: 'transform 0.2s' }}>
{descExpanded ? '▾' : '▸'}
</span>
</div>
{descExpanded && (
<div style={{ padding: '0 1rem 1rem', lineHeight: 1.6, fontSize: '0.82rem', color: 'var(--text)' }}>
<p style={{ margin: '0 0 0.8rem' }}>
<strong>DASUTeN</strong> es la obra social que brinda cobertura a empleados, docentes y alumnos de la
Universidad Tecnológica Nacional. Su oficina en la Facultad Regional La Rioja depende
operativamente de la <strong>sede central en Buenos Aires</strong>.
</p>
<p style={{ margin: '0 0 0.8rem' }}>
Este proyecto tiene como objetivo <strong>dotar a la oficina DASUTeN de su propia infraestructura
tecnológica independiente</strong>, operando bajo un entorno virtualizado (Proxmox) con acceso remoto seguro vía Tailscale.
</p>
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)',
gap: '1.2rem', marginTop: '1rem'
}}>
<div>
<div style={subheadStyle}>🎯 Objetivos y Alcance</div>
<ul style={listStyle}>
<li>Sistema de gestión operando desde servidor propio</li>
<li>Independencia total de los equipos de la Facultad</li>
<li>Oficina autosuficiente con su propia red y respaldos</li>
<li>Servidor dedicado en oficina DASUTeN</li>
<li>Red interna propia y segura</li>
<li>Computadora de escritorio como acceso al sistema</li>
</ul>
</div>
<div>
<div style={subheadStyle}>👥 Responsables y Beneficiarios</div>
<ul style={listStyle}>
<li><strong>Ejecución:</strong> Lic. Ricardo Monla</li>
<li><strong>Supervisión:</strong> Dirección de TIC</li>
</ul>
<div style={{ fontSize: '0.7rem', color: '#38bdf8', fontWeight: 600, marginTop: '0.4rem', marginBottom: '0.2rem' }}>Usuarios directos:</div>
<ul style={listStyle}>
<li>Andrea Almirón Administrativa DASUTeN</li>
<li>Romina Molina Administrativa</li>
<li>Dra. Eugenia Riveros Auditora Médica</li>
</ul>
<div style={{ fontSize: '0.7rem', color: '#38bdf8', fontWeight: 600, marginTop: '0.4rem', marginBottom: '0.2rem' }}>Beneficiarios indirectos:</div>
<ul style={listStyle}>
<li>Personal Docente</li>
<li>Personal No Docente</li>
<li>Alumnos</li>
</ul>
</div>
</div>
</div>
)}
</div>
{/* Gráficos de torta */}
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: '1.2rem', marginBottom: '1.8rem'
}}>
{/* Donut: Estado de Fases */}
<div className="card" style={{ padding: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.6rem', fontWeight: 600 }}>
Estado de Fases
</div>
<DonutChart
segments={[
{ value: fasesCompletas, color: '#22c55e', label: `${fasesCompletas} Completas` },
{ value: fasesEnCurso, color: '#eab308', label: `${fasesEnCurso} En curso` },
{ value: fasesPendientes, color: '#64748b', label: `${fasesPendientes} Pendientes` },
]}
centerValue={`${fasesCompletas + fasesEnCurso}/${proyecto.fases.length}`}
centerLabel="fases"
centerColor="#22c55e"
/>
</div>
{/* Donut: Avance Global */}
<div className="card" style={{ padding: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.6rem', fontWeight: 600 }}>
Avance de Hitos
</div>
<DonutChart
segments={[
{ value: completados, color: '#22c55e', label: `${completados} Completados` },
{ value: pendientes, color: '#eab308', label: `${pendientes} Pendientes` },
]}
centerValue={`${progreso}%`}
centerLabel="avance"
centerColor="#22c55e"
/>
</div>
{/* Donut: Distribución de Horas */}
<div className="card" style={{ padding: '1.2rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.6rem', fontWeight: 600 }}>
Distribución de Horas
</div>
<DonutChart
segments={[
{ value: hrsP, color: '#f97316', label: `${hrsP.toFixed(1)}h Presencial` },
{ value: hrsR, color: '#a78bfa', label: `${hrsR.toFixed(1)}h Remoto` },
]}
centerValue={`${totalH.toFixed(0)}h`}
centerLabel="total"
centerColor="#38bdf8"
/>
</div>
</div>
{/* Fases con descripciones expandibles */}
<SectionTitle title="📊 Fases del Proyecto" />
{proyecto.fases.map(fase => {
const hitos = fase.hitos || [];
const faseCompletos = hitos.filter(h => h.estado === '✅').length;
const faseProgreso = hitos.length > 0 ? Math.round((faseCompletos / hitos.length) * 100) : 0;
const hrsTotal = hitos.reduce((s, h) => s + h.horas_presencial + h.horas_remoto, 0);
const isExpanded = expandedGerFases.has(fase.numero);
const detalle = faseDetalles[fase.numero];
return (
<div key={fase.id} className="card" style={{
marginBottom: '0.6rem', padding: 0, overflow: 'hidden', position: 'relative'
}}>
{/* Barra de progreso de fondo */}
<div style={{
position: 'absolute', top: 0, left: 0, bottom: 0, width: `${faseProgreso}%`,
background: fase.estado === '✅' ? 'rgba(34,197,94,0.06)' : 'rgba(56,189,248,0.04)',
transition: 'width 0.4s', pointerEvents: 'none'
}} />
{/* Header clickeable */}
<div
onClick={() => toggleGerFase(fase.numero)}
style={{
position: 'relative', display: 'flex', justifyContent: 'space-between',
alignItems: 'center', padding: '0.8rem 1rem', cursor: 'pointer'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.7rem' }}>
<span style={{
fontSize: '1.3rem', width: '36px', height: '36px', display: 'flex',
alignItems: 'center', justifyContent: 'center', borderRadius: '8px',
background: fase.estado === '✅' ? 'rgba(34,197,94,0.12)' :
fase.estado === '⏳' ? 'rgba(234,179,8,0.12)' : 'rgba(255,255,255,0.05)'
}}>{estadoIcon(fase.estado)}</span>
<div>
<div style={{ fontWeight: 600, fontSize: '0.88rem' }}>
F{fase.numero} · {fase.nombre}
</div>
{!isExpanded && fase.descripcion && (
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginTop: '2px' }}>
{fase.descripcion}
</div>
)}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexShrink: 0 }}>
{hrsTotal > 0 && (
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{hrsTotal.toFixed(1)}h</span>
)}
<div style={{
minWidth: '52px', textAlign: 'right', fontWeight: 700, fontSize: '0.85rem',
color: faseProgreso === 100 ? '#22c55e' : faseProgreso > 0 ? '#38bdf8' : 'var(--text-muted)'
}}>{faseProgreso}%</div>
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{isExpanded ? '▾' : '▸'}</span>
</div>
</div>
{/* Detalle expandible */}
{isExpanded && detalle && (
<div style={{
position: 'relative', borderTop: '1px solid var(--border)',
padding: '0.8rem 1rem',
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '0.8rem'
}}>
<FaseDetailCol icon="🎯" title="Objetivo" text={detalle.objetivo} />
<FaseDetailCol icon="⚙️" title="Desarrollo" text={detalle.desarrollo} />
<FaseDetailCol icon="✨" title="Logros" text={detalle.logros} />
</div>
)}
</div>
);
})}
</>
)}
{/* ═══════════ VISTA TÉCNICA ═══════════ */}
{vista === 'tecnica' && (
<>
{/* Métricas numéricas */}
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))',
gap: '0.8rem', marginBottom: '1.5rem'
}}>
<MetricCard label="Fases" value={`${fasesCompletas + fasesEnCurso}/${proyecto.fases.length}`} color="#38bdf8" />
<MetricCard label="Hitos Comp." value={m.hitos_completados} color="#22c55e" />
<MetricCard label="Hitos Pend." value={m.hitos_pendientes} color="#eab308" />
<MetricCard label="Hrs Físicas" value={`${hrsP.toFixed(1)}h`} color="#f97316" />
<MetricCard label="Esfuerzo Total" value={`${totalH.toFixed(1)}h`} color="#a78bfa" />
</div>
{/* Barra progreso */}
<div style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem', marginBottom: '0.3rem' }}>
<span style={{ fontWeight: 600 }}>Avance Global</span>
<span style={{ color: '#38bdf8', fontWeight: 600 }}>{progreso}%</span>
</div>
<div style={{
width: '100%', height: '10px', background: 'rgba(255,255,255,0.06)',
borderRadius: '999px', overflow: 'hidden'
}}>
<div style={{
width: `${progreso}%`, height: '100%', borderRadius: '999px',
background: 'linear-gradient(90deg, #38bdf8, #818cf8)', transition: 'width 0.6s ease'
}} />
</div>
</div>
{/* Nodos */}
<SectionTitle title="🖧 Nodos del Proyecto" />
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
gap: '0.7rem', marginBottom: '1.5rem'
}}>
{proyecto.nodos.map(n => (
<div key={n.id} className="card" style={{ padding: '0.8rem' }}>
<div style={{ fontWeight: 700, fontSize: '0.85rem', marginBottom: '0.3rem' }}>{n.nombre}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
{n.ip && <><code style={codeStyle}>{n.ip}</code><br /></>}
{n.descripcion}
</div>
</div>
))}
</div>
{/* Fases detalladas */}
<SectionTitle title="📋 Fases del Proyecto" />
{proyecto.fases.map(fase => {
const hitos = fase.hitos || [];
const faseCompletos = hitos.filter(h => h.estado === '✅').length;
const faseProgreso = hitos.length > 0 ? Math.round((faseCompletos / hitos.length) * 100) : 0;
const fasHrsP = hitos.reduce((s, h) => s + h.horas_presencial, 0);
const fasHrsR = hitos.reduce((s, h) => s + h.horas_remoto, 0);
const expanded = expandedFases.has(fase.id);
return (
<div key={fase.id} className="card" style={{ marginBottom: '0.8rem', padding: 0, overflow: 'hidden' }}>
<div onClick={() => toggleFase(fase.id)} style={{
padding: '0.8rem 1rem', cursor: 'pointer', display: 'flex',
justifyContent: 'space-between', alignItems: 'center',
borderBottom: expanded ? '1px solid var(--border)' : 'none'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<span style={{ fontSize: '0.7rem', color: '#38bdf8', fontWeight: 700 }}>F{fase.numero}</span>
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>{fase.nombre}</span>
<span style={{ fontSize: '0.8rem' }}>{estadoIcon(fase.estado)}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.8rem', fontSize: '0.72rem', color: 'var(--text-muted)' }}>
{fasHrsP > 0 && <span style={{ color: '#f97316' }}>🏢 {fasHrsP.toFixed(1)}h</span>}
{fasHrsR > 0 && <span style={{ color: '#a78bfa' }}>💻 {fasHrsR.toFixed(1)}h</span>}
<span style={{ color: '#38bdf8' }}>{faseProgreso}%</span>
<span>{expanded ? '▾' : '▸'}</span>
</div>
</div>
<div style={{ height: '3px', background: 'rgba(255,255,255,0.03)' }}>
<div style={{
width: `${faseProgreso}%`, height: '100%',
background: fase.estado === '✅' ? '#22c55e' : 'linear-gradient(90deg, #38bdf8, #818cf8)',
transition: 'width 0.4s'
}} />
</div>
{expanded && hitos.length > 0 && (
<div style={{ padding: '0.5rem 1rem 0.8rem' }}>
<table className="table-ifde" style={{ fontSize: '0.8rem' }}>
<thead>
<tr>
<th style={{ width: '55px' }}>ID</th>
<th>Hito</th>
<th style={{ width: '75px' }}>Fecha</th>
<th style={{ width: '60px' }}>🏢</th>
<th style={{ width: '60px' }}>💻</th>
<th style={{ width: '80px' }}>Nodo</th>
<th style={{ width: '35px' }}>E</th>
</tr>
</thead>
<tbody>
{hitos.map(h => (
<tr key={h.id}>
<td><code style={{ fontSize: '0.72rem', color: '#38bdf8' }}>{h.id_hito}</code></td>
<td>
<strong>{h.titulo}</strong>
{h.descripcion && <div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginTop: '2px' }}>{h.descripcion}</div>}
</td>
<td style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>
{h.fecha ? new Date(h.fecha + 'T12:00:00').toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' }) : '—'}
</td>
<td style={{ textAlign: 'center', color: h.horas_presencial > 0 ? '#f97316' : 'var(--text-muted)' }}>
{h.horas_presencial > 0 ? `${h.horas_presencial}h` : '—'}
</td>
<td style={{ textAlign: 'center', color: h.horas_remoto > 0 ? '#a78bfa' : 'var(--text-muted)' }}>
{h.horas_remoto > 0 ? `${h.horas_remoto}h` : '—'}
</td>
<td style={{ fontSize: '0.72rem' }}>{h.nodo_nombre || '—'}</td>
<td style={{ textAlign: 'center' }}>{estadoIcon(h.estado)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
})}
</>
)}
<div style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.7rem', marginTop: '1.5rem', paddingTop: '0.8rem', borderTop: '1px solid var(--border)' }}>
P2601 Infraestructura DASUTEN · UTN FRLR · Dashboard integrado en dtic-BITACORAs
</div>
</div>
);
}
/* ═══ Estilos compartidos ═══ */
const codeStyle: React.CSSProperties = {
background: 'rgba(255,255,255,0.06)', padding: '1px 4px', borderRadius: '3px', fontSize: '0.72rem'
};
const subheadStyle: React.CSSProperties = {
fontSize: '0.75rem', fontWeight: 600, marginBottom: '0.4rem', color: '#38bdf8'
};
const listStyle: React.CSSProperties = {
margin: 0, paddingLeft: '1.1rem', fontSize: '0.78rem', color: 'var(--text-muted)', lineHeight: 1.7
};
/* ═══ Sub-componentes ═══ */
function MetricCard({ label, value, color }: { label: string; value: string; color: string }) {
return (
<div className="card" style={{ padding: '0.9rem', textAlign: 'center' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color, lineHeight: 1 }}>{value}</div>
<div style={{ fontSize: '0.68rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.04em', marginTop: '0.3rem' }}>{label}</div>
</div>
);
}
function SectionTitle({ title }: { title: string }) {
return (
<div style={{
fontSize: '0.78rem', textTransform: 'uppercase', letterSpacing: '0.06em',
color: 'var(--text-muted)', fontWeight: 600, marginBottom: '0.8rem',
paddingBottom: '0.4rem', borderBottom: '1px solid var(--border)'
}}>{title}</div>
);
}
function FaseDetailCol({ icon, title, text }: { icon: string; title: string; text: string }) {
return (
<div>
<div style={{ fontSize: '0.75rem', fontWeight: 600, color: '#38bdf8', marginBottom: '0.3rem' }}>
{icon} {title}
</div>
<div style={{ fontSize: '0.76rem', color: 'var(--text-muted)', lineHeight: 1.5 }}>
{text}
</div>
</div>
);
}
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": [
"src"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": [
"vite.config.ts"
]
}
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
base: '/bitacoras/',
server: {
host: true,
port: 5173,
strictPort: true,
cors: true,
allowedHosts: ['ns8.frlr.utn.edu.ar', 'localhost'],
proxy: {
'/bitacoras/api': {
target: 'http://api:3001',
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/bitacoras\/api/, '/api')
}
}
},
define: { 'import.meta.env.VERSION': JSON.stringify('1.0.0') }
});
@@ -0,0 +1,20 @@
const { Client } = require('pg');
const client = new Client({
user: 'dtic_user',
host: '127.0.0.1',
database: 'dtic_bitacoras',
password: 'dtic_pass_2026',
port: 5433,
});
async function run() {
await client.connect();
console.log("Connected to DB");
const hitos = await client.query("SELECT f.numero as fase, h.id_hito, h.titulo FROM bitacoras.hitos h JOIN bitacoras.fases f ON h.fase_id = f.id ORDER BY f.numero, h.id_hito");
console.table(hitos.rows);
await client.end();
}
run().catch(console.error);
@@ -0,0 +1,37 @@
const { Client } = require('pg');
const client = new Client({
user: 'dtic_user',
host: '127.0.0.1',
database: 'dtic_bitacoras',
password: 'dtic_pass_2026',
port: 5433,
});
async function run() {
await client.connect();
console.log("Connected to DB");
// Mapeo de IDs antiguos a nuevos
const mapeos = [
{ old: 'BKP02', new: 'f05001' },
{ old: 'DB02', new: 'f05002' },
{ old: 'MON01', new: 'f05003' },
{ old: 'DOC01', new: 'f05004' },
{ old: 'PERF01', new: 'f05005' },
];
for (const mapeo of mapeos) {
console.log(`Updating ${mapeo.old} to ${mapeo.new}...`);
const res = await client.query(
"UPDATE bitacoras.hitos SET id_hito = $1 WHERE id_hito = $2 RETURNING id",
[mapeo.new, mapeo.old]
);
console.log(`Updated ${res.rowCount} row(s)`);
}
console.log("Migration completed");
await client.end();
}
run().catch(console.error);
+158
View File
@@ -0,0 +1,158 @@
{
"name": "tools",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"pg": "^8.19.0"
}
},
"node_modules/pg": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.11.0",
"pg-pool": "^3.12.0",
"pg-protocol": "^1.12.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
"integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.12.0.tgz",
"integrity": "sha512-eIJ0DES8BLaziFHW7VgJEBPi5hg3Nyng5iKpYtj3wbcAUV9A1wLgWiY7ajf/f/oO1wfxt83phXPY8Emztg7ITg==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.12.0.tgz",
"integrity": "sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}
@@ -0,0 +1,5 @@
{
"dependencies": {
"pg": "^8.19.0"
}
}
@@ -0,0 +1,94 @@
const fs = require('fs');
const { Pool } = require('pg');
const pool = new Pool({
connectionString: "postgres://dtic_user:dtic_pass_2026@localhost:5433/dtic_bitacoras"
});
async function syncFile(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n');
const dateStr = filePath.match(/(\d{4}-\d{2}-\d{2})\.md/)[1];
console.log(`Sincronizando bitácora: ${dateStr}`);
// Asegurar bitácora
const bResult = await pool.query(
'INSERT INTO bitacoras.bitacoras (fecha) VALUES ($1) ON CONFLICT (fecha) DO UPDATE SET fecha = EXCLUDED.fecha RETURNING id',
[dateStr]
);
const bitacoraId = bResult.rows[0].id;
// Obtener mapeo de nodos
const { rows: nodos } = await pool.query('SELECT id, nombre FROM bitacoras.nodos');
const nodoMap = {};
nodos.forEach(n => { nodoMap[n.nombre] = n.id; });
// Limpiar entradas previas
await pool.query('DELETE FROM bitacoras.entradas WHERE bitacora_id = $1', [bitacoraId]);
let currentNodoId = null;
let inDetailedActivities = false;
let entriesFound = 0;
for (let line of lines) {
// Detectar inicio de sección de actividades detalladas
if (line.includes('Actividades Detalladas')) {
inDetailedActivities = true;
continue;
}
if (!inDetailedActivities) continue;
// Detectar cambio de nodo
if (line.startsWith('### ')) {
const nodoName = line.replace('### ', '').trim().split(' ')[0];
currentNodoId = nodoMap[nodoName] || null;
if (currentNodoId) console.log(`Cambiando a nodo: ${nodoName} (ID: ${currentNodoId})`);
continue;
}
if (!currentNodoId) continue;
// Detectar entrada de tabla
const regex = /\|\s*(\d{2}:\d{2})\s*\|\s*(\d{2}:\d{2})\s*\|\s*(.*?)\s*\|\s*(.*?)\s*\|/;
const match = line.match(regex);
if (match) {
let inicio = match[1];
let fin = match[2];
let descRaw = match[3];
let estadoRaw = match[4];
const esIA = descRaw.includes('(IA)');
let descripcion = descRaw.replace('(IA)', '').replace(/\*\*/g, '').replace(/__/g, '').trim();
// A veces el MD tiene ** al principio
if (descripcion.startsWith('**')) descripcion = descripcion.substring(2);
const statusMatch = estadoRaw.match(/\[([A-Z])\] (.*)/);
const modo = statusMatch ? statusMatch[1] : 'P';
const estado = statusMatch ? statusMatch[2] : estadoRaw;
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)`,
[inicio, fin, descripcion, estado, modo, esIA, bitacoraId, currentNodoId]
);
entriesFound++;
}
}
console.log(`Sincronización finalizada. Entradas cargadas: ${entriesFound}`);
await pool.end();
}
const file = process.argv[2];
if (!file) {
console.error('Uso: node sync_bitacora.js <path_to_md>');
process.exit(1);
}
syncFile(file).catch(err => {
console.error(err);
process.exit(1);
});
@@ -0,0 +1,87 @@
const { Client } = require('pg');
const client = new Client({
user: 'dtic_user',
host: '127.0.0.1',
database: 'dtic_bitacoras',
password: 'dtic_pass_2026',
port: 5433,
});
async function run() {
await client.connect();
console.log("Connected to DB");
const codigo = 'P2601';
// Get Project ID
const pRes = await client.query("SELECT id FROM bitacoras.proyectos WHERE codigo = $1", [codigo]);
if (pRes.rowCount === 0) {
console.error("Proyecto no encontrado");
process.exit(1);
}
const pid = pRes.rows[0].id;
// Fases IDs
const fRes = await client.query("SELECT id, numero FROM bitacoras.fases WHERE proyecto_id = $1", [pid]);
const fases = {};
fRes.rows.forEach(f => fases[f.numero] = f.id);
// Nodos IDs
const nRes = await client.query("SELECT id, nombre FROM bitacoras.nodos");
const nodos = {};
nRes.rows.forEach(n => nodos[n.nombre] = n.id);
// Updates a realizar según el HTML
const hitos = [
{ fase: 1, id_hito: 'A00-A01', titulo: 'Acondicionamiento Hardware + Proxmox VE', desc: 'Instalación física del servidor srv-dasu y despliegue de Proxmox VE 9.1.', est: '✅', fec: '2026-02-23', h_p: 2.5, h_r: 0, nodo: 'srv-dasu' },
{ fase: 2, id_hito: 'A05', titulo: 'VM Windows Server Core → DC', desc: 'Creación de VM, instalación de OS, promoción a Controlador de Dominio (dc-dasuten).', est: '✅', fec: '2026-02-24', h_p: 0.5, h_r: 14, nodo: 'dc-dasuten' },
{ fase: 2, id_hito: 'A06', titulo: 'Dashboard P2601', desc: 'Despliegue del panel web con telemetría operativa, glassmorphism y métricas.', est: '✅', fec: '2026-02-24', h_p: 0, h_r: 2, nodo: 'srv-ns8' },
{ fase: 3, id_hito: 'DB01', titulo: 'SQL Server 2019 en sql-dasuten', desc: 'Instalación de SQL Server 2019 con collation Modern_Spanish_CI_AS.', est: '✅', fec: '2026-02-25', h_p: 0, h_r: 8, nodo: 'sql-dasuten' },
{ fase: 4, id_hito: 'SSH', titulo: 'OpenSSH Server en VMs Windows', desc: 'Despliegue de SSH nativo en dc-dasuten y sql-dasuten. Puerto 7022, firewall configurado.', est: '✅', fec: '2026-02-26', h_p: 0, h_r: 3, nodo: 'dc-dasuten' },
{ fase: 4, id_hito: 'BKP', titulo: 'Resguardo VZDump', desc: 'Backup offline (stop mode) de las VMs DC y SQL vía VZDump.', est: '✅', fec: '2026-02-26', h_p: 0, h_r: 1, nodo: 'srv-dasu' },
{ fase: 4, id_hito: 'TEST', titulo: 'VM de Pruebas pcv-dasu0', desc: 'Despliegue de Win 10 LTSC, SSH, toolkit W-Zombi. Unión al dominio.', est: '✅', fec: '2026-02-27', h_p: 0, h_r: 6, nodo: 'pcv-dasu0' },
{ fase: 3, id_hito: 'P02.DB', titulo: 'Restauración Base de Datos', desc: 'Backup bksysdasuten_Full.bak (~9GB) restaurado. Login sysadmin creado.', est: '✅', fec: '2026-03-04', h_p: 0, h_r: 1, nodo: 'sql-dasuten' },
{ fase: 5, id_hito: 'P02.APP', titulo: 'Despliegue del Sistema DASUTEN', desc: 'Configuración Kermet.ini, componentes y fuentes. Login exitoso con aalmiron.', est: '✅', fec: '2026-03-05', h_p: 1, h_r: 2, nodo: 'pcv-dasu0' },
{ fase: 5, id_hito: 'NET01', titulo: 'Tailscale VPN en srv-dasu', desc: 'Instalación y configuración como subnet router para 10.0.100.0/24.', est: '✅', fec: '2026-03-05', h_p: 0, h_r: 0.5, nodo: 'srv-dasu' },
{ fase: 5, id_hito: 'TEST03', titulo: 'Conectividad SQL desde Cliente', desc: 'Apertura de firewall TCP 1433 y validación ODBC.', est: '✅', fec: '2026-03-05', h_p: 0, h_r: 1, nodo: 'pcv-dasu0' },
{ fase: 6, id_hito: 'UAT', titulo: 'Verificación Presencial por Usuario', desc: 'Validación funcional in-situ con Andrea Almirón. Resultado exitoso.', est: '✅', fec: '2026-03-05', h_p: 1, h_r: 0, nodo: 'pc-dasu0' },
{ fase: 6, id_hito: 'FÍSICO', titulo: '🧱 Instalación Física de srv-dasu', desc: 'Traslado e instalación definitiva del servidor físico Proxmox.', est: '⏳', fec: null, h_p: 0, h_r: 0, nodo: 'srv-dasu' },
{ fase: 6, id_hito: 'ADDOM', titulo: '🤝 AD-Join: pc-dasu0 al Dominio', desc: 'Unir la PC física del cliente local al directorio activo (dc-dasuten).', est: '⏳', fec: null, h_p: 0, h_r: 0, nodo: 'pc-dasu0' },
{ fase: 6, id_hito: 'GPO', titulo: '🛡️ Políticas de Seguridad (GPO)', desc: 'Configuración de GPOs de dominio para restringir acceso y mapeos.', est: '⏳', fec: null, h_p: 0, h_r: 0, nodo: 'dc-dasuten' },
{ fase: 6, id_hito: 'S02', titulo: '🔐 Hardenización SSH', desc: 'Llaves SSH, passphrases, auditoría y elevación de seguridad.', est: '⏳', fec: null, h_p: 0, h_r: 0, nodo: 'dc-dasuten' },
{ fase: 6, id_hito: 'CLI', titulo: '💻 Configuración Final pc-dasu0', desc: 'PC física de la oficina DASUTeN como cliente del dominio.', est: '⏳', fec: null, h_p: 0, h_r: 0, nodo: 'pc-dasu0' }
];
// Primero marcamos P02.DB, P02.APP viejos u otros como completados/borrados si es necesario
await client.query("UPDATE bitacoras.hitos SET estado = '✅', fecha = '2026-03-04' WHERE id_hito = 'P02.DB'");
await client.query("DELETE FROM bitacoras.hitos WHERE id_hito IN ('MIG', 'P02.APP')");
for (const h of hitos) {
if (!fases[h.fase]) {
console.log(`Fase ${h.fase} no existe, creándola...`);
const faseI = await client.query("INSERT INTO bitacoras.fases (proyecto_id, numero, titulo) VALUES ($1, $2, $3) RETURNING id", [pid, h.fase, `Fase ${h.fase}`]);
fases[h.fase] = faseI.rows[0].id;
}
const nodo_id = nodos[h.nodo] || null;
// Verificar si existe el hito
const exRes = await client.query("SELECT id FROM bitacoras.hitos WHERE id_hito = $1 AND fase_id = $2", [h.id_hito, fases[h.fase]]);
if (exRes.rowCount > 0) {
console.log(`Updating ${h.id_hito}...`);
await client.query(`UPDATE bitacoras.hitos SET titulo=$1, descripcion=$2, estado=$3, fecha=$4, horas_presencial=$5, horas_remoto=$6, nodo_id=$7 WHERE id=$8`,
[h.titulo, h.desc, h.est, h.fec, h.h_p, h.h_r, nodo_id, exRes.rows[0].id]);
} else {
console.log(`Inserting ${h.id_hito}...`);
await client.query(`INSERT INTO bitacoras.hitos (fase_id, id_hito, titulo, descripcion, estado, fecha, horas_presencial, horas_remoto, nodo_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[fases[h.fase], h.id_hito, h.titulo, h.desc, h.est, h.fec, h.h_p, h.h_r, nodo_id]);
}
}
console.log("Done");
await client.end();
}
run().catch(console.error);
@@ -0,0 +1,16 @@
const section = `srv-ns8
#### ⏳ - SINC01 - Inicio de Jornada Presencial
🚀 Inicio de jornada presencial (08:00 - 13:00). Sincronización de contexto y tareas administrativas.
| I | F | Descripción | E |
| :--- | :--- | :--- | :--- |
| 12:35 | 12:50 | (IA) **P2603 - Sistema de Bitácoras**: Creación completa de \`dtic-BITACORAs/\`. Backend API REST (3 rutas CRUD), esquema SQL I-F-D-E, frontend React 18 dark mode, Docker Compose. 3 contenedores operativos. | [P] ✅ |
| 11:15 | 11:37 | (IA) **Categorización de Nodos**: Separación de nodos exclusivos vs. soporte en P2601 y P2602. ADN 07 actualizado con el nuevo estándar. | [P] ✅ |`;
const regex = /\|\s*(\d{2}:\d{2})\s*\|\s*(\d{2}:\d{2})\s*\|\s*(.*?)\s*\|\s*(.*?)\s*\|/g;
const matches = [...section.matchAll(regex)];
console.log(`Encontradas: ${matches.length}`);
matches.forEach((m, i) => {
console.log(`Match ${i}: ${m[1]} - ${m[2]} - ${m[3].substring(0, 20)}...`);
});
@@ -0,0 +1,14 @@
services:
apps:
image: tinyfilemanager/tinyfilemanager:master
container_name: srvv-apps
restart: always
ports:
- "8082:80"
volumes:
- /mnt/ns8Disco2/DTIC_APPs/:/var/www/html/data
- ./index.php:/var/www/html/index.php
- ./translation.json:/var/www/html/translation.json
- ./uploads.ini:/usr/local/etc/php/conf.d/uploads.ini
# Persist config if needed, or mount specific config.php
# - ./config.php:/var/www/html/config.php
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
upload_max_filesize = 10G
post_max_size = 10G
memory_limit = 1024M
max_execution_time = 3600
max_input_time = 3600
@@ -0,0 +1,16 @@
- Developer:
- rmGithub:
- abbr: rmGH
href: https://github.com/rmonla
- Social:
- Reddit:
- abbr: RE
href: https://reddit.com/
- Entertainment:
- YouTube:
- abbr: YT
href: https://youtube.com/
- Documentación:
- gethomepage.dev:
- icon: homepage.png
- href: https://gethomepage.dev/
@@ -0,0 +1,10 @@
---
# For configuration options and examples, please see:
# https://gethomepage.dev/configs/docker/
# my-docker:
# host: 127.0.0.1
# port: 2375
# my-docker:
# socket: /var/run/docker.sock
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
---
# sample kubernetes config
@@ -0,0 +1,5 @@
---
# pve:
# url: https://proxmox.host.or.ip:8006
# token: username@pam!Token ID
# secret: secret
@@ -0,0 +1,93 @@
- Infraestructura Virtual:
- Proxmox 1:
icon: proxmox.png
href: "https://pmox.frlr.utn.edu.ar:8006"
description: Nodo 1
widget:
type: proxmox
url: "{{HOMEPAGE_VAR_PROXMOX1_URL}}"
username: "{{HOMEPAGE_VAR_PROXMOX1_USER}}"
password: "{{HOMEPAGE_VAR_PROXMOX1_API_KEY}}"
node: pmox1
- Proxmox 2:
icon: proxmox.png
href: "https://pmox.frlr.utn.edu.ar:8006"
description: Nodo 2
widget:
type: proxmox
url: "{{HOMEPAGE_VAR_PROXMOX1_URL}}"
username: "{{HOMEPAGE_VAR_PROXMOX1_USER}}"
password: "{{HOMEPAGE_VAR_PROXMOX1_API_KEY}}"
node: pmox2
- Proxmox 3:
icon: proxmox.png
href: "https://pmox.frlr.utn.edu.ar:8006"
description: Nodo 3
widget:
type: proxmox
url: "{{HOMEPAGE_VAR_PROXMOX1_URL}}"
username: "{{HOMEPAGE_VAR_PROXMOX1_USER}}"
password: "{{HOMEPAGE_VAR_PROXMOX1_API_KEY}}"
node: pmox3
- SSH/Terminal:
icon: si-gnumetalshell # SimpleIcons
href: "#"
description: Acceso NS8
- Windows SQL Server (DB01):
icon: mdi-microsoft-windows
href: "ssh://rmonla@10.0.100.11"
description: DB01 Operativo (ES-ES)
- Gestión de Servicios:
- Monitor UPT Kuma:
icon: uptime-kuma.png
href: "http://upt.frlr.utn.edu.ar/status/servicios"
description: Status General
widget:
type: uptimekuma
url: "{{HOMEPAGE_VAR_UPTIMEKUMA_URL}}"
slug: "{{HOMEPAGE_VAR_UPTIMEKUMA_SLUG}}"
- Portainer srv-NS8:
icon: portainer.png
href: "https://ns8.frlr.utn.edu.ar/portainer/"
description: Gestión de contenedores
widget:
type: portainer
url: "http://172.17.0.1:9000"
env: 3
key: "{{HOMEPAGE_VAR_PORTAINER_API_KEY}}"
- rmGhDocker:
icon: si-github-#181717 # SimpleIcons
href: "https://github.com/rmonla/rmDocker"
description: Scripts de despliegue
- AI Secret Vault:
icon: si-rubyonrails # SimpleIcons
href: "https://github.com/rmonla/dtic-srv-NS8/blob/main/scripts/secret_box.rb"
description: AES-256-GCM Vault
- Aplicaciones y Herramientas:
- Eventos UTNLR:
icon: google-calendar.png
href: "https://calendar.google.com/calendar/u/0/r?cid=s4nnn9vr3vnt1llq7ir7nuu7dg@group.calendar.google.com"
description: Calendario Institucional
widget:
type: calendar
view: agenda
maxEvents: 5
showTime: true
integrations:
- type: ical
url: https://calendar.google.com/calendar/ical/s4nnn9vr3vnt1llq7ir7nuu7dg%40group.calendar.google.com/public/basic.ics
name: Institucional
- Bitácoras:
icon: si-notebook # SimpleIcons
href: "https://ns8.frlr.utn.edu.ar/bitacoras/"
description: Registro de actividades
- SysACAD:
icon: mdi-bookshelf-#FFB441 # MDI Icons
href: "https://ns8.frlr.utn.edu.ar/sysacad/"
description: Sistema Académico
- GEMA Chatbot:
icon: si-google-gemini # SimpleIcons
href: "#"
description: Asistente GEMA
@@ -0,0 +1,20 @@
title: NS8 Dashboard
background:
image: https://cdnb.artstation.com/p/assets/images/images/006/897/659/large/mikael-gustafsson-wallpaper-mikael-gustafsson.jpg
blur: sm
saturate: 100
brightness: 50
opacity: 100
theme: dark
color: slate
useEqualHeights: true
layout:
Infraestructura Virtual:
columns: 2
Gestión de Servicios:
columns: 1
Aplicaciones y Herramientas:
columns: 2
@@ -0,0 +1,11 @@
- datetime:
text_size: xl
format:
dateStyle: long
timeStyle: short
hour12: false
- resources:
label: Sistema
cpu: true
memory: true
disk: /
@@ -0,0 +1,16 @@
services:
homepage:
image: ghcr.io/gethomepage/homepage:latest
container_name: homepage
ports:
- 3001:3000
volumes:
- ./config:/app/config
- /var/run/docker.sock:/var/run/docker.sock:ro
env_file:
- .env
environment:
PUID: 1000
PGID: 1000
HOMEPAGE_ALLOWED_HOSTS: "ns8.frlr.utn.edu.ar,127.0.0.1,localhost,10.0.10.8,190.114.205.18,srvns8.tail982245.ts.net"
restart: unless-stopped