diff --git a/packages/sim-rabbit-monitor/index.ts b/packages/sim-rabbit-monitor/index.ts new file mode 100644 index 0000000..27a5195 --- /dev/null +++ b/packages/sim-rabbit-monitor/index.ts @@ -0,0 +1,25 @@ +import express from "express" +import cors from 'cors'; +import path from 'path'; +import { env } from "packages/sim-shared/config/env/index.js" + +const PORT = env.API_PORT +const HOSTNAME = "0.0.0.0" +const app = express() + +// Middleware +app.use(cors()); + +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +app.use("/docs", express.static(path.join(process.cwd(), '../../docs'))) + +app.get("/health", (req, res) => { + res.status(200).json({ status: "ok" }) +}) + +app.listen(PORT, HOSTNAME, () => { + console.log("[o] Servidor iniciado en el puerto %d", PORT) +}) +export default {} diff --git a/packages/sim-shared/aplication/Rabbit.usecases.ts b/packages/sim-shared/aplication/Rabbit.usecases.ts new file mode 100644 index 0000000..97db858 --- /dev/null +++ b/packages/sim-shared/aplication/Rabbit.usecases.ts @@ -0,0 +1,15 @@ +import { RabbitManagementClient } from "../infrastructure/RabbitManagementClient.js"; +import { Queue } from "../domain/Queue.js"; + +export class RabbitUseCases { + private client: RabbitManagementClient + + constructor(client: RabbitManagementClient) { + this.client = client + } + + public async getQueuesStatus(): Promise { + const queues = await this.client.getQueues() + return queues.sort((a, b) => b.messages - a.messages) + } +} \ No newline at end of file diff --git a/packages/sim-shared/config/env/index.ts b/packages/sim-shared/config/env/index.ts new file mode 100644 index 0000000..964de47 --- /dev/null +++ b/packages/sim-shared/config/env/index.ts @@ -0,0 +1,20 @@ +import { loadEnvFile } from "node:process"; +import path from "node:path"; + + +loadEnvFile(path.join("../../.env")) // Global + +export const env = { + ENVIRONMENT: process.env.ENVIORMENT, + API_PORT: parseInt(process.env.API_PORT ?? "3000"), + RABBITMQ_HOST: String(process.env.RABBITMQ_HOST ?? "localhost"), + RABBITMQ_USER: String(process.env.RABBITMQ_USER ?? "test"), + RABBITMQ_PASSWORD: String(process.env.RABBITMQ_PASSWORD ?? "test"), + RABBITMQ_EXCHANGE: String(process.env.RABBITMQ_EXCHANGE ?? "/"), + RABBITMQ_PORT: parseInt(process.env.RABBITMQ_PORT ?? "5672"), + RABBITMQ_MODULENAME: process.env.MODULENAME, + RABBITMQ_TTL: process.env.RABBITMQ_TTL, + RABBITMQ_SECURE: process.env.RABBITMQ_SECURE, + RABBITMQ_RETRY_INTERVAL: process.env.RABBITMQ_INTERVAL, + RABBITMQ_VHOST: String(process.env.RABBITMQ_VHOST), +}; diff --git a/packages/sim-shared/config/eventBusConfig.ts b/packages/sim-shared/config/eventBusConfig.ts new file mode 100644 index 0000000..fad7bb2 --- /dev/null +++ b/packages/sim-shared/config/eventBusConfig.ts @@ -0,0 +1,26 @@ +import { RabbitMQEventBus, RMQConnectionParams } from "sim-shared/infrastructure/RabbitMQEventBus.js" +import { env } from "./env/index.js" + +const rmqUser = env.RABBITMQ_USER +const rmqPass = env.RABBITMQ_PASSWORD +const rmqHost = env.RABBITMQ_HOST +const rmqPort = Number(env.RABBITMQ_PORT) +const rmqSecure = false +const rmqVhost = env.RABBITMQ_VHOST + +export const rmqConnOptions = { + username: rmqUser, + password: rmqPass, + vhost: rmqVhost, + hostname: rmqHost, + port: rmqPort, + secure: rmqSecure, +} + +export const rabbitmqEventBus = new RabbitMQEventBus({ + connectionParams: rmqConnOptions +}) + +export async function startRMQClient() { + await rabbitmqEventBus.connect() +} diff --git a/packages/sim-shared/domain/Queue.ts b/packages/sim-shared/domain/Queue.ts new file mode 100644 index 0000000..f35037d --- /dev/null +++ b/packages/sim-shared/domain/Queue.ts @@ -0,0 +1,7 @@ +export interface Queue { + name: string; + messages: number; + ready: number; + unacked: number; + consumers: number; +} \ No newline at end of file diff --git a/packages/sim-shared/infrastructure/RabbitManagementClient.ts b/packages/sim-shared/infrastructure/RabbitManagementClient.ts new file mode 100644 index 0000000..32a7256 --- /dev/null +++ b/packages/sim-shared/infrastructure/RabbitManagementClient.ts @@ -0,0 +1,37 @@ + +import axios, { AxiosInstance } from "axios"; +import { Queue } from "sim-shared/domain/Queue.js"; + +export class RabbitManagementClient { + private client: AxiosInstance; + + constructor(args: { + baseURL: string; + user: string; + password: string; + }) { + this.client = axios.create({ + baseURL: args.baseURL, + auth: { + username: args.user, + password: args.password, + }, + }); + } + + public async getQueues(): Promise { + try { + const response = await this.client.get("/queues") + return response.data.map((q: any) => ({ + name: q.name, + messages: q.messages, + ready: q.messages_ready, + unacked: q.messages_unacknowledged, + consumers: q.consumers + })) + } catch (err) { + console.error("[RabbitManagementClient] Error obteniendo colas", err); + throw err; + } + } +} \ No newline at end of file diff --git a/packages/sim-visualizador-tareas-back/aplication/Dashboard.controller.ts b/packages/sim-visualizador-tareas-back/aplication/Dashboard.controller.ts new file mode 100644 index 0000000..ad6a2c1 --- /dev/null +++ b/packages/sim-visualizador-tareas-back/aplication/Dashboard.controller.ts @@ -0,0 +1,107 @@ +import { Request, Response } from "express"; +import { DashboardUseCases } from "./Dashboard.usecases.js"; +import { QueueSummary } from "#domain/Dashboard.js"; +import { OrderTracking } from "packages/sim-shared/domain/Order.js"; + +export class DashboardController { + constructor(private readonly useCases: DashboardUseCases) {} + + public async getQueuesFragment(req: Request, res: Response) { + const data = await this.useCases.getDashboardData() + res.send(renderQueuesHtml(data.queues)) + } + + public async getOrdersFragment(req: Request, res: Response) { + const data = await this.useCases.getDashboardData() + res.send(renderOrdersHtml(data.pendingOrders)) + } + + public async getFullFragment(req: Request, res: Response) { + const data = await this.useCases.getDashboardData(); + res.send(` + ${renderQueuesHtml(data.queues)} + ${renderOrdersHtml(data.pendingOrders)} +

Actualizado: ${new Date(data.generatedAt).toLocaleTimeString("es-ES")}

+ `) + } +} + +function queueBadgeClass(count: number): string { + if (count === 0 ) return "badge--ok" + if (count < 10) return "badge--warn" + return "badge--error" +} + +function orderBadgeClass(status: string): string { + if (status === 'finished' ) return "badge--ok" + if (status == 'pending') return "badge--warn" + return "badge--error" +} + +function renderQueuesHtml(queues: QueueSummary): string { + const row = (label: string, q: typeof queues.main) => ` + + ${label} + ${q.name} + ${q.messages} + ${q.ready} + ${q.unacked} + ${q.consumers} + + ` + return ` +
+

Estado de las colas

+ + + + + + + + + + + + + ${row("Principal", queues.main)} + ${row("Reintentos", queues.retry)} + ${row("Fallidos (DLX)", queues.dlx)} + +
TipoColaTotalReadyUnackedConsumers
+
+ ` +} + +function renderOrdersHtml(orders: OrderTracking[]): string { + const rows = orders.map(o => ` + + ${o.id} + ${o.correlation_id} + ${o.order_type} + ${o.status} + ${o.retry_count ?? 0} + ${new Date(o.start_date).toLocaleString("es-ES")} + + ` + ).join("") + + return ` +
+

Tareas pendientes (${orders.length})

+ + + + + + + + + + + + ${rows} +
IDCorrelation IDTipoEstadoReintentosInicio
+
+ ` +} \ No newline at end of file diff --git a/packages/sim-visualizador-tareas-back/aplication/Dashboard.usecases.ts b/packages/sim-visualizador-tareas-back/aplication/Dashboard.usecases.ts new file mode 100644 index 0000000..e65af8d --- /dev/null +++ b/packages/sim-visualizador-tareas-back/aplication/Dashboard.usecases.ts @@ -0,0 +1,47 @@ +import { RabbitManagementClient } from "packages/sim-shared/infrastructure/RabbitManagementClient.js"; +import { OrderRepository } from "packages/sim-shared/infrastructure/OrderRepository.js"; +import { DashboardData, QueueSummary } from "#domain/Dashboard.js"; +import { Queue } from "packages/sim-shared/domain/Queue.js"; +import { env } from "#config/env/index.js"; + +const EMPTY_QUEUE: Queue = { name: "", messages: 0, ready: 0, unacked: 0, consumers: 0} + +export class DashboardUseCases { + constructor( + private readonly rabbitClient: RabbitManagementClient, + private readonly orderRepo: OrderRepository, + ) {} + + public async getDashboardData(): Promise { + //si uno peta, no rompe al otro + const [queuesResult, pendingResult] = await Promise.allSettled([ + this.rabbitClient.getQueues(), + this.orderRepo.getPendingOrders({ limit: 100 }), //por poner un lĂ­mite + ]) + + let queues: QueueSummary = { main: EMPTY_QUEUE, retry: EMPTY_QUEUE, dlx: EMPTY_QUEUE} + + if (queuesResult.status === 'fulfilled') { + const all = queuesResult.value + const find = (name: string) => all.find(q => q.name === name) ?? {...EMPTY_QUEUE, name} + queues = { + main: find(env.QUEUE_MAIN!), + retry: find(env.QUEUE_RETRY!), + dlx: find(env.QUEUE_DLX!) + } + } else { + console.error('[Dasboard] Error obteniendo colas: ', queuesResult.reason) + } + const pendingOrders = (pendingResult.status === "fulfilled" && !pendingResult.value.error) + ? pendingResult.value.data ?? [] + : []; + if (pendingResult.status === 'rejected') { + console.error('[Dashboard]Error obteniendo tareas: ', pendingResult.reason) + } + return { + queues, + pendingOrders, + generatedAt: new Date().toISOString(), + } + } +} \ No newline at end of file diff --git a/packages/sim-visualizador-tareas-back/config/env/index.ts b/packages/sim-visualizador-tareas-back/config/env/index.ts new file mode 100644 index 0000000..c28612a --- /dev/null +++ b/packages/sim-visualizador-tareas-back/config/env/index.ts @@ -0,0 +1,22 @@ +import { loadEnvFile } from "node:process"; +import path from "node:path"; + +loadEnvFile(path.join("../../env")) + +export const env = { + API_PORT: parseInt(process.env.API_PORT ?? "3010"), + POSTGRES_USER: process.env.POSTGRES_USER, + POSTGRES_PASSWORD: process.env.POSTGRES_PASSWORD, + POSTGRES_PORT: process.env.POSTGRES_PORT, + POSTGRES_HOST: process.env.POSTGRES_HOST, + POSTGRES_DATABASE: process.env.POSTGRES_DATABASE, + RABBITMQ_HOST: String(process.env.RABBITMQ_HOST), + RABBITMQ_PORT: process.env.RABBITMQ_PORT, + RABBITMQ_USER: String(process.env.RABBITMQ_USER), + RABBITMQ_PASSWORD: String(process.env.RABBITMQ_PASSWORD), + RABBITMQ_SECURE: process.env.RABBITMQ_SECURE, + RABBITMQ_VHOST: process.env.RABBITMQ_VHOST, + QUEUE_MAIN: process.env.QUEUE_MAIN, + QUEUE_RETRY: process.env.QUEUE_RETRY, + QUEUE_DLX: process.env.QUEUE_DLX +} \ No newline at end of file diff --git a/packages/sim-visualizador-tareas-back/config/postgreConfig.ts b/packages/sim-visualizador-tareas-back/config/postgreConfig.ts new file mode 100644 index 0000000..bbe61c6 --- /dev/null +++ b/packages/sim-visualizador-tareas-back/config/postgreConfig.ts @@ -0,0 +1,13 @@ +import { Pool } from "pg"; +import { PgClient } from "packages/sim-shared/infrastructure/PgClient.js"; +import { env } from "./env/index.js"; + +export const pgPool = new Pool({ + user: env.POSTGRES_USER!, + host: env.POSTGRES_HOST!, + database: env.POSTGRES_DATABASE!, + password: env.POSTGRES_PASSWORD!, + port: Number(env.POSTGRES_PORT) || 5432 +}) + +export const postgresClient = new PgClient({ pool: pgPool }) \ No newline at end of file diff --git a/packages/sim-visualizador-tareas-back/config/rabbitMQConfig.ts b/packages/sim-visualizador-tareas-back/config/rabbitMQConfig.ts new file mode 100644 index 0000000..d142841 --- /dev/null +++ b/packages/sim-visualizador-tareas-back/config/rabbitMQConfig.ts @@ -0,0 +1,8 @@ +import { RabbitManagementClient } from "packages/sim-shared/infrastructure/RabbitManagementClient.js"; +import { env } from "./env/index.js"; + +export const rabbitManagementClient = new RabbitManagementClient({ + baseURL: env.RABBITMQ_HOST!, + user: env.RABBITMQ_USER!, + password: env.RABBITMQ_PASSWORD! +}) \ No newline at end of file diff --git a/packages/sim-visualizador-tareas-back/domain/Dashboard.ts b/packages/sim-visualizador-tareas-back/domain/Dashboard.ts new file mode 100644 index 0000000..2ea51bb --- /dev/null +++ b/packages/sim-visualizador-tareas-back/domain/Dashboard.ts @@ -0,0 +1,14 @@ +import { Queue } from "packages/sim-shared/domain/Queue.js"; +import { OrderTracking } from "packages/sim-shared/domain/Order.js"; + +export type QueueSummary = { + main: Queue; + retry: Queue; + dlx: Queue; +} + +export interface DashboardData { + queues: QueueSummary; + pendingOrders: OrderTracking[]; + generatedAt: string; +} \ No newline at end of file diff --git a/packages/sim-visualizador-tareas-back/index.ts b/packages/sim-visualizador-tareas-back/index.ts new file mode 100644 index 0000000..a57bfdb --- /dev/null +++ b/packages/sim-visualizador-tareas-back/index.ts @@ -0,0 +1,45 @@ +import express from "express"; +import cors from "cors"; +import path from "path"; +import { env } from "#config/env/index.js"; +import { pgPool, postgresClient } from "#config/postgreConfig.js"; +import { OrderRepository } from "packages/sim-shared/infrastructure/OrderRepository.js"; +import { DashboardController } from "./aplication/Dashboard.controller.js"; +import { DashboardUseCases } from "./aplication/Dashboard.usecases.js"; +import { createDashboardRouter } from "#adapters/dashboardRoutes.http.js"; +import { PgClient } from "packages/sim-shared/infrastructure/PgClient.js"; +import { RabbitManagementClient } from "packages/sim-shared/infrastructure/RabbitManagementClient.js"; + +const pgClient = new PgClient({ + pool: pgPool, +}) + +const rabbitClient = new RabbitManagementClient({ + baseURL: env.RABBITMQ_HOST, + user: env.RABBITMQ_USER, + password: env.RABBITMQ_PASSWORD, +}) + +const orderRepo = new OrderRepository(pgClient) +const useCases = new DashboardUseCases(rabbitClient, orderRepo) +const controller = new DashboardController(useCases) +const dashboardRouter = createDashboardRouter(controller) + +const app = express(); +app.use(cors()); +app.use(express.json()); +app.use(express.static(path.join(process.cwd(), "../sim-visualizador-tareas-front"))); +app.use("/", dashboardRouter); +app.get("/health", (_req, res) => res.status(200).json({ status: "ok" })); + +async function main() { + await pgClient.checkDatabaseConnection(); + app.listen(Number(env.API_PORT), "0.0.0.0", () => { + console.log("[o] Visualizador iniciado en el puerto %d", env.API_PORT); + }); +} + +main().catch((err) => { + console.error("[x] Error arrancando el visualizador:", err); + process.exit(1); +}); diff --git a/packages/sim-visualizador-tareas-back/infrastructure/dashboardRoutes.http.ts b/packages/sim-visualizador-tareas-back/infrastructure/dashboardRoutes.http.ts new file mode 100644 index 0000000..6ee3388 --- /dev/null +++ b/packages/sim-visualizador-tareas-back/infrastructure/dashboardRoutes.http.ts @@ -0,0 +1,12 @@ +import { Router } from "express"; +import { DashboardController } from "../aplication/Dashboard.controller.js"; + +export function createDashboardRouter(controller: DashboardController): Router { + const router = Router() + + router.get("/fragments/queues", (req, res) => controller.getQueuesFragment(req, res)) + router.get("/fragments/orders", (req, res) => controller.getOrdersFragment(req, res)) + router.get("/fragments/dashboard", (req, res) => controller.getFullFragment(req, res)) + + return router +} \ No newline at end of file diff --git a/packages/sim-visualizador-tareas-back/package.json b/packages/sim-visualizador-tareas-back/package.json new file mode 100644 index 0000000..fb4cbfc --- /dev/null +++ b/packages/sim-visualizador-tareas-back/package.json @@ -0,0 +1,72 @@ +{ + "name": "sim-visualizador-tareas-back", + "version": "1.0.0", + "type": "module", + "description": "", + "main": "index.ts", + "imports": { + "#config/*.js": { + "types": "./config/*.ts", + "default": "./config/*.js" + }, + "#config/*": { + "types": "./config/*.ts", + "default": "./config/*.js" + }, + "#adapters/*.js": { + "types": "./infrastructure/*.ts", + "default": "./infrastructure/*.js" + }, + "#adapters/*": { + "types": "./infrastructure/*.ts", + "default": "./infrastructure/*.js" + }, + "#domain/*.js": { + "types": "./domain/*.ts", + "default": "./domain/*.js" + }, + "#domain/*": { + "types": "./domain/*.ts", + "default": "./domain/*.js" + }, + "#ports/*.js": { + "types": "./ports/*.ts", + "default": "./ports/*.js" + }, + "#ports/*": { + "types": "./ports/*.ts", + "default": "./ports/*.js" + } + }, + "scripts": { + "test": "node --import tsx --test ./**/*.test.ts", + "build": "tsc --build && tsc-alias -p tsconfig.json && cp package.json ../../dist/packages/sim-visualizador-tareas-back/", + "dev": "tsx watch index.ts", + "start": "node ../../dist/packages/sim-visualizador-tareas-back/index.js" + }, + "author": "", + "license": "ISC", + "packageManager": "yarn@4.12.0", + "dependencies": { + "@tsconfig/node22": "*", + "amqplib": "^0.10.9", + "axios": "*", + "cors": "*", + "dotenv": "*", + "express": "*", + "sim-shared": "sim-shared:*", + "typescript": "*" + }, + "devDependencies": { + "@types/amqplib": "^0.10.8", + "@types/cors": "*", + "@types/express": "*", + "@types/node": "*", + "@types/supertest": "*", + "prettier": "*", + "supertest": "*", + "tsc-alias": "^1.8.16", + "tsx": "*", + "vitest": "*" + } +} diff --git a/packages/sim-visualizador-tareas-back/tsconfig.json b/packages/sim-visualizador-tareas-back/tsconfig.json new file mode 100644 index 0000000..10b37fd --- /dev/null +++ b/packages/sim-visualizador-tareas-back/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../dist", + "rootDir": "../../", + }, + "exclude": [ + "node_modules" + ], + "include": [ + "**/*.ts", + "../../packages/sim-shared/**/*.ts" + ], + "files": [ + "config/env/index.ts" + ] +} diff --git a/packages/sim-visualizador-tareas-front/index.html b/packages/sim-visualizador-tareas-front/index.html new file mode 100644 index 0000000..e69de29 diff --git a/sf-sim.code-workspace b/sf-sim.code-workspace new file mode 100644 index 0000000..876a149 --- /dev/null +++ b/sf-sim.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": {} +} \ No newline at end of file