feat: Agregar modulo de Sistema (mongosh, logs, export/import)
This commit is contained in:
+119
-1
@@ -17,9 +17,43 @@ import { initDb,
|
||||
getAllIndicaciones, createIndicacion, updateIndicacion, deleteIndicacion,
|
||||
getAllMovimientosIndicaciones, createMovimientoIndicacion,
|
||||
getAllPendientes, createPendiente, updatePendiente, deletePendiente,
|
||||
getValue, setValue
|
||||
getValue, setValue,
|
||||
getDb, exportAllData, importAllData
|
||||
} from './db-mongodb.js';
|
||||
|
||||
// Capture live logs in-memory
|
||||
const logBuffer = [];
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
const originalWarn = console.warn;
|
||||
|
||||
function addLog(type, args) {
|
||||
const message = args.map(arg => {
|
||||
if (typeof arg === 'object') {
|
||||
try { return JSON.stringify(arg); } catch { return String(arg); }
|
||||
}
|
||||
return String(arg);
|
||||
}).join(' ');
|
||||
const logEntry = `[${new Date().toISOString()}] [${type}] ${message}`;
|
||||
logBuffer.push(logEntry);
|
||||
if (logBuffer.length > 500) {
|
||||
logBuffer.shift();
|
||||
}
|
||||
}
|
||||
|
||||
console.log = (...args) => {
|
||||
addLog('INFO', args);
|
||||
originalLog.apply(console, args);
|
||||
};
|
||||
console.error = (...args) => {
|
||||
addLog('ERROR', args);
|
||||
originalError.apply(console, args);
|
||||
};
|
||||
console.warn = (...args) => {
|
||||
addLog('WARN', args);
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(cors({
|
||||
origin: true,
|
||||
@@ -760,6 +794,90 @@ app.put('/api/auth/update-email', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ========== ADMIN SYSTEM ENDPOINTS ==========
|
||||
|
||||
app.post('/api/admin/mongosh', async (req, res) => {
|
||||
try {
|
||||
const { command } = req.body;
|
||||
if (!command) {
|
||||
return res.status(400).json({ error: 'Comando requerido' });
|
||||
}
|
||||
|
||||
const dbInstance = getDb();
|
||||
if (!dbInstance) {
|
||||
return res.json({ result: 'Advertencia: MongoDB no está conectado. Utilizando store en memoria. Las consultas de mongosh no están disponibles.', isMemStore: true });
|
||||
}
|
||||
|
||||
const dbProxy = new Proxy(dbInstance, {
|
||||
get(target, prop) {
|
||||
if (typeof target[prop] !== 'undefined') {
|
||||
if (typeof target[prop] === 'function') {
|
||||
return target[prop].bind(target);
|
||||
}
|
||||
return target[prop];
|
||||
}
|
||||
return target.collection(prop);
|
||||
}
|
||||
});
|
||||
|
||||
const { ObjectId } = await import('mongodb');
|
||||
|
||||
const evalFn = new Function('db', 'ObjectId', `
|
||||
return (async () => {
|
||||
${command.trim().includes('return') || command.trim().startsWith('{') || command.trim().includes(';') ? command : 'return (' + command + ')'}
|
||||
})();
|
||||
`);
|
||||
|
||||
const result = await evalFn(dbProxy, ObjectId);
|
||||
res.json({ result });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err.message || String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/db/export', async (req, res) => {
|
||||
try {
|
||||
const dump = await exportAllData();
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=hospital_backup.json');
|
||||
res.json(dump);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Error al exportar base de datos: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/db/import', async (req, res) => {
|
||||
try {
|
||||
const dump = req.body;
|
||||
if (!dump || typeof dump !== 'object') {
|
||||
return res.status(400).json({ error: 'Formato de importación inválido' });
|
||||
}
|
||||
await importAllData(dump);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Error al importar base de datos: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/logs', async (req, res) => {
|
||||
try {
|
||||
let fileLogs = '';
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
fileLogs = await fs.readFile('server_log.txt', 'utf8');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const memoryLogs = logBuffer.join('\n');
|
||||
const combined = [fileLogs, '\n--- LOGS DE LA SESION EN VIVO ---', memoryLogs].filter(Boolean).join('\n');
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.send(combined);
|
||||
} catch (err) {
|
||||
res.status(500).send('Error al leer logs: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize DB and start server
|
||||
const PORT = process.env.PORT || 4000;
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
|
||||
@@ -993,3 +993,68 @@ export async function setValue(key, value) {
|
||||
memStore.kv[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export function getDb() {
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function exportAllData() {
|
||||
const collections = [
|
||||
'usuarios', 'pacientes', 'areas', 'camas', 'internaciones',
|
||||
'evoluciones', 'laboratorios', 'glucemias', 'acidosbase',
|
||||
'cultivos', 'estudiosComplementarios', 'interconsultas', 'atb',
|
||||
'indicaciones', 'movimientos_indicaciones', 'pendientes', 'kv'
|
||||
];
|
||||
const dump = {};
|
||||
if (db) {
|
||||
for (const name of collections) {
|
||||
const docs = await db.collection(name).find().toArray();
|
||||
dump[name] = docs;
|
||||
}
|
||||
} else {
|
||||
for (const name of collections) {
|
||||
dump[name] = [...(memStore[name] || [])];
|
||||
}
|
||||
}
|
||||
return dump;
|
||||
}
|
||||
|
||||
export async function importAllData(dump) {
|
||||
const collections = [
|
||||
'usuarios', 'pacientes', 'areas', 'camas', 'internaciones',
|
||||
'evoluciones', 'laboratorios', 'glucemias', 'acidosbase',
|
||||
'cultivos', 'estudiosComplementarios', 'interconsultas', 'atb',
|
||||
'indicaciones', 'movimientos_indicaciones', 'pendientes', 'kv'
|
||||
];
|
||||
if (db) {
|
||||
for (const name of collections) {
|
||||
if (Array.isArray(dump[name])) {
|
||||
try {
|
||||
await db.collection(name).deleteMany({});
|
||||
} catch (e) {
|
||||
console.warn(`Could not clear collection ${name}:`, e);
|
||||
}
|
||||
if (dump[name].length > 0) {
|
||||
const cleaned = dump[name].map(doc => {
|
||||
const copy = { ...doc };
|
||||
if (copy._id) {
|
||||
if (ObjectId.isValid(copy._id)) {
|
||||
copy._id = new ObjectId(copy._id);
|
||||
} else {
|
||||
delete copy._id;
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
await db.collection(name).insertMany(cleaned);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const name of collections) {
|
||||
if (Array.isArray(dump[name])) {
|
||||
memStore[name] = [...dump[name]];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -14,6 +14,7 @@ import { EditIngreso } from '@/sections/EditIngreso';
|
||||
import { Login } from '@/sections/Login';
|
||||
import { GestionUsuarios } from '@/sections/GestionUsuarios';
|
||||
import { PendientesSala } from '@/sections/PendientesSala';
|
||||
import { SeccionSistema } from '@/sections/SeccionSistema';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
@@ -306,6 +307,16 @@ function AppContent() {
|
||||
return (
|
||||
<GestionUsuarios />
|
||||
);
|
||||
case 'sistema':
|
||||
if (store.currentUser?.rol !== 'admin') {
|
||||
return (
|
||||
<div className="p-8 text-center text-red-500 font-semibold">
|
||||
<p>No tiene permisos para acceder a esta sección.</p>
|
||||
<Button className="mt-4" onClick={() => store.setVista('dashboard')}>Volver al Dashboard</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <SeccionSistema />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
Sun,
|
||||
Moon,
|
||||
LogOut,
|
||||
UserCog
|
||||
UserCog,
|
||||
Terminal
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger, SheetTitle, SheetDescription, SheetHeader } from '@/components/ui/sheet';
|
||||
@@ -59,6 +60,7 @@ export function Layout({ children, vistaActual, onCambiarVista, currentUser, onL
|
||||
|
||||
if ((currentUser?.rol as string) === 'admin') {
|
||||
filteredMenuItems.push({ vista: 'usuarios', label: 'Usuarios', icon: UserCog });
|
||||
filteredMenuItems.push({ vista: 'sistema', label: 'Sistema', icon: Terminal });
|
||||
}
|
||||
|
||||
const renderNavContent = (onItemClick?: () => void) => (
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Terminal, Download, Upload, FileText, Send, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function SeccionSistema() {
|
||||
const [mongoshCommand, setMongoshCommand] = useState('');
|
||||
const [consoleHistory, setConsoleHistory] = useState<{ id: number; type: 'input' | 'output' | 'error'; content: string }[]>([]);
|
||||
const [logs, setLogs] = useState<string>('');
|
||||
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const historyCounter = useRef(0);
|
||||
const consoleEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
consoleEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [consoleHistory]);
|
||||
|
||||
const addHistory = (type: 'input' | 'output' | 'error', content: string) => {
|
||||
setConsoleHistory(prev => [...prev, { id: historyCounter.current++, type, content }]);
|
||||
};
|
||||
|
||||
const handleExecuteMongosh = async () => {
|
||||
if (!mongoshCommand.trim()) return;
|
||||
|
||||
addHistory('input', mongoshCommand);
|
||||
const commandToRun = mongoshCommand;
|
||||
setMongoshCommand('');
|
||||
setIsExecuting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/mongosh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: commandToRun })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
addHistory('error', data.error || 'Error desconocido');
|
||||
} else {
|
||||
const outputString = typeof data.result === 'object' ? JSON.stringify(data.result, null, 2) : String(data.result);
|
||||
addHistory('output', outputString);
|
||||
if (data.isMemStore) {
|
||||
toast.warning('MongoDB no está conectado. Utilizando modo memoria.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
addHistory('error', String(error));
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleExecuteMongosh();
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewLogs = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/logs');
|
||||
if (!response.ok) throw new Error('Error al obtener logs');
|
||||
const data = await response.text();
|
||||
setLogs(data || 'No hay logs disponibles.');
|
||||
setIsLogsModalOpen(true);
|
||||
} catch (e) {
|
||||
toast.error('Error al cargar logs: ' + String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportData = () => {
|
||||
window.location.href = '/api/admin/db/export';
|
||||
toast.success('Descarga iniciada');
|
||||
};
|
||||
|
||||
const handleImportData = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
try {
|
||||
const content = event.target?.result as string;
|
||||
const dump = JSON.parse(content);
|
||||
|
||||
const response = await fetch('/api/admin/db/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dump)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Error al importar base de datos');
|
||||
}
|
||||
|
||||
toast.success('Base de datos importada exitosamente. Recarga la página para ver los cambios.');
|
||||
// Opcional: window.location.reload();
|
||||
} catch (error) {
|
||||
toast.error('Error: ' + String(error));
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = ''; // Reset input
|
||||
};
|
||||
|
||||
const handleClearConsole = () => {
|
||||
setConsoleHistory([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-gray-900 dark:text-white">Sistema y Depuración</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400">Herramientas avanzadas para administradores</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card className="col-span-1 md:col-span-3">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5" /> Consola mongosh
|
||||
</CardTitle>
|
||||
<CardDescription>Ejecuta comandos de consulta directamente en la base de datos.</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleClearConsole}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" /> Limpiar
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="bg-gray-900 text-green-400 font-mono text-sm p-4 rounded-md h-[400px] overflow-y-auto mb-4 flex flex-col gap-2 relative">
|
||||
{consoleHistory.length === 0 && (
|
||||
<div className="text-gray-500 italic">Conectado. Esperando comandos... Ej: db.pacientes.find()</div>
|
||||
)}
|
||||
{consoleHistory.map((item) => (
|
||||
<div key={item.id} className={`${item.type === 'error' ? 'text-red-400' : item.type === 'input' ? 'text-blue-300' : 'text-gray-300'}`}>
|
||||
{item.type === 'input' && <span className="text-blue-500 mr-2">{'>'}</span>}
|
||||
{item.type === 'output' && <span className="text-gray-500 mr-2">{'<-'}</span>}
|
||||
{item.type === 'error' && <span className="text-red-500 mr-2">{'!'}</span>}
|
||||
<pre className="inline-block whitespace-pre-wrap font-inherit m-0">{item.content}</pre>
|
||||
</div>
|
||||
))}
|
||||
<div ref={consoleEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
|
||||
placeholder="Escribe un comando... (ej: db.usuarios.find())"
|
||||
value={mongoshCommand}
|
||||
onChange={(e) => setMongoshCommand(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isExecuting}
|
||||
/>
|
||||
<Button onClick={handleExecuteMongosh} disabled={isExecuting}>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Ejecutar
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500 flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Soporta asincronía. Variables globales: `db`, `ObjectId`. Las promesas se resuelven automáticamente.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" /> Logs del Servidor
|
||||
</CardTitle>
|
||||
<CardDescription>Visualiza los registros recientes del servidor.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button className="w-full" onClick={handleViewLogs}>
|
||||
Ver Logs Recientes
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Download className="h-5 w-5" /> Exportar Datos
|
||||
</CardTitle>
|
||||
<CardDescription>Descarga una copia completa de la base de datos (JSON).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button className="w-full" variant="secondary" onClick={handleExportData}>
|
||||
Exportar Todo
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5" /> Importar Datos
|
||||
</CardTitle>
|
||||
<CardDescription>Restaura la base de datos desde un archivo (JSON).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
onChange={handleImportData}
|
||||
/>
|
||||
<Button className="w-full" variant="destructive">
|
||||
Seleccionar e Importar
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-red-500 mt-2 text-center">Advertencia: Esto sobreescribirá todos los datos actuales.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{isLogsModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg p-6 w-full max-w-4xl max-h-[90vh] flex flex-col shadow-xl">
|
||||
<h3 className="text-lg font-bold mb-4 flex justify-between items-center text-gray-900 dark:text-white">
|
||||
<span>Logs del Servidor</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => setIsLogsModalOpen(false)}>Cerrar</Button>
|
||||
</h3>
|
||||
<div className="flex-1 overflow-auto bg-gray-950 text-green-400 font-mono text-xs p-4 rounded border border-gray-800">
|
||||
<pre className="whitespace-pre-wrap">{logs}</pre>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button onClick={() => setIsLogsModalOpen(false)}>Aceptar</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -237,7 +237,7 @@ export interface MovimientoIndicacion {
|
||||
observaciones?: string;
|
||||
}
|
||||
|
||||
export type Vista = 'dashboard' | 'camas' | 'pacientes' | 'internaciones' | 'evoluciones' | 'laboratorios' | 'acidobase' | 'cultivos' | 'historiaclinica' | 'nuevoingreso' | 'editaringreso' | 'usuarios' | 'pendientessala';
|
||||
export type Vista = 'dashboard' | 'camas' | 'pacientes' | 'internaciones' | 'evoluciones' | 'laboratorios' | 'acidobase' | 'cultivos' | 'historiaclinica' | 'nuevoingreso' | 'editaringreso' | 'usuarios' | 'pendientessala' | 'sistema';
|
||||
|
||||
export type RolUsuario = 'admin' | 'medico' | 'enfermero';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user