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
@@ -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)}...`);
});