comienzo de la visualización de tareas + cola de rabbit

This commit is contained in:
2026-03-26 12:35:44 +01:00
parent cfb907b840
commit cbbc0f6edb
18 changed files with 495 additions and 0 deletions

View File

@@ -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 {}

View File

@@ -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<Queue[]> {
const queues = await this.client.getQueues()
return queues.sort((a, b) => b.messages - a.messages)
}
}

20
packages/sim-shared/config/env/index.ts vendored Normal file
View File

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

View File

@@ -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 = <RMQConnectionParams>{
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()
}

View File

@@ -0,0 +1,7 @@
export interface Queue {
name: string;
messages: number;
ready: number;
unacked: number;
consumers: number;
}

View File

@@ -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<Queue[]> {
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;
}
}
}

View File

@@ -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)}
<p class="updated-at">Actualizado: ${new Date(data.generatedAt).toLocaleTimeString("es-ES")}</p>
`)
}
}
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) => `
<tr>
<td>${label}</td>
<td>${q.name}</td>
<td><span class="badge ${queueBadgeClass(q.messages)}">${q.messages}</span></td>
<td>${q.ready}</td>
<td>${q.unacked}</td>
<td>${q.consumers}</td>
</tr>
`
return `
<section id="queues-section">
<h2>Estado de las colas</h2>
<table class="table">
<thead>
<tr>
<th>Tipo</th>
<th>Cola</th>
<th>Total</th>
<th>Ready</th>
<th>Unacked</th>
<th>Consumers</th>
<tr>
</thead>
<tbody>
${row("Principal", queues.main)}
${row("Reintentos", queues.retry)}
${row("Fallidos (DLX)", queues.dlx)}
</tbody>
</table>
</section>
`
}
function renderOrdersHtml(orders: OrderTracking<unknown>[]): string {
const rows = orders.map(o => `
<tr>
<td>${o.id}</td>
<td>${o.correlation_id}</td>
<td>${o.order_type}</td>
<td><span class="badge ${orderBadgeClass(o.status)}">${o.status}</span></td>
<td>${o.retry_count ?? 0}</td>
<td>${new Date(o.start_date).toLocaleString("es-ES")}</td>
</tr>
`
).join("")
return `
<section id="orders-section">
<h2>Tareas pendientes (${orders.length})</h2>
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Correlation ID</th>
<th>Tipo</th>
<th>Estado</th>
<th>Reintentos</th>
<th>Inicio</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</section>
`
}

View File

@@ -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<DashboardData> {
//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(),
}
}
}

View File

@@ -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
}

View File

@@ -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 })

View File

@@ -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!
})

View File

@@ -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<unknown>[];
generatedAt: string;
}

View File

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

View File

@@ -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
}

View File

@@ -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": "*"
}
}

View File

@@ -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"
]
}

8
sf-sim.code-workspace Normal file
View File

@@ -0,0 +1,8 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}