replica-failover-mongodb-ts 2.2.4 → 3.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Readme.md +71 -8
- package/dist/config/connectionManager.js +44 -5
- package/dist/config/metrics.js +29 -0
- package/dist/config/websocket.js +30 -0
- package/dist/scripts/dashboard.js +211 -125
- package/dist/server.js +22 -1
- package/package.json +7 -1
package/Readme.md
CHANGED
|
@@ -5,14 +5,52 @@
|
|
|
5
5
|
[](https://nodejs.org/)
|
|
6
6
|
[](https://www.typescriptlang.org/)
|
|
7
7
|
|
|
8
|
-
## 🚀
|
|
8
|
+
## 🚀 QuickStart (Plug & Play)
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
Escolha como você quer usar o projeto:
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
### Opção A: Via NPM (Apenas Dashboard)
|
|
13
|
+
Ideal se você já tem um cluster MongoDB e quer apenas visualizar/controlar.
|
|
14
|
+
|
|
15
|
+
1. **Instale a ferramenta globalmente:**
|
|
16
|
+
```bash
|
|
17
|
+
npm install -g replica-failover-mongodb-ts
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
2. **Rode o dashboard:**
|
|
21
|
+
```bash
|
|
22
|
+
node-balancer-dashboard
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
3. **Responda às perguntas de configuração:**
|
|
26
|
+
O sistema pedirá os dados do seu ambiente. Exemplo de preenchimento:
|
|
27
|
+
|
|
28
|
+
- **API URL**: `http://localhost:3000/api/users`
|
|
29
|
+
- **MongoDB Nodes**: `mongodb://localhost:27017,mongodb://localhost:27018`
|
|
30
|
+
- **Enable Docker Control?**: `Yes` (Se quiser parar/iniciar containers pelo painel)
|
|
31
|
+
- **Docker Container Names**: `mongo1,mongo2,mongo3`
|
|
32
|
+
|
|
33
|
+
### Opção B: Via Git (Ambiente Completo)
|
|
34
|
+
Ideal para ver a mágica acontecer do zero (cria API + Banco + Réplicas).
|
|
35
|
+
|
|
36
|
+
1. **Clone e Instale:**
|
|
37
|
+
```bash
|
|
38
|
+
git clone https://github.com/JoaoIto/node-balancer.git
|
|
39
|
+
cd node-balancer
|
|
40
|
+
npm install
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
2. **Suba o Ambiente (Docker):**
|
|
44
|
+
```bash
|
|
45
|
+
docker-compose up -d --build
|
|
46
|
+
```
|
|
47
|
+
*Aguarde ~30s para o cluster configurar.*
|
|
48
|
+
|
|
49
|
+
3. **Rode o Dashboard:**
|
|
50
|
+
```bash
|
|
51
|
+
npm run dashboard
|
|
52
|
+
```
|
|
53
|
+
*Pronto! Selecione "RUN CHAOS DEMO" e divirta-se.*
|
|
16
54
|
|
|
17
55
|
---
|
|
18
56
|
|
|
@@ -32,8 +70,9 @@ O Node Balancer é uma API escalável construída utilizando Node.js, MongoDB co
|
|
|
32
70
|
4. [Visual Dashboard (Painel de Controle)](#visual-dashboard-painel-de-controle)
|
|
33
71
|
5. [Uso Avançado do Dashboard (CLI)](#uso-avançado-do-dashboard-cli)
|
|
34
72
|
6. [Testes e Automação (Chaos Testing)](#testes-e-automação-chaos-testing)
|
|
35
|
-
7. [
|
|
36
|
-
8. [
|
|
73
|
+
7. [Observabilidade (v3.0)](#observabilidade-v30)
|
|
74
|
+
8. [Documentação Detalhada](#documentação-detalhada)
|
|
75
|
+
9. [Configuração Manual (Referência)](#configuração-manual-referência)
|
|
37
76
|
|
|
38
77
|
---
|
|
39
78
|
|
|
@@ -201,6 +240,29 @@ npm run ops:demo
|
|
|
201
240
|
|
|
202
241
|
---
|
|
203
242
|
|
|
243
|
+
## Observabilidade (v3.0)
|
|
244
|
+
|
|
245
|
+
A versão 3.0 introduz recursos avançados de monitoramento para produção:
|
|
246
|
+
|
|
247
|
+
### 🔔 Webhooks (Rápido)
|
|
248
|
+
Receba alertas no seu Slack ou Discord. Basta passar a URL ao iniciar:
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
const db = new ConnectionManager({
|
|
252
|
+
nodes: [...],
|
|
253
|
+
webhookUrl: 'https://discord.com/api/webhooks/...' // Sua URL aqui
|
|
254
|
+
});
|
|
255
|
+
```
|
|
256
|
+
*O sistema fará um POST automático com JSON sempre que houver um failover.*
|
|
257
|
+
|
|
258
|
+
### 📊 Métricas e Real-time
|
|
259
|
+
- **Prometheus**: Acesse `http://localhost:3000/metrics` para ver dados de latência e conexão.
|
|
260
|
+
- **WebSocket**: Conecte via Socket.io para receber logs em tempo real.
|
|
261
|
+
|
|
262
|
+
👉 **[Leia o guia completo de Observabilidade (Português)](docs/observability.md)**
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
204
266
|
## Documentação Detalhada
|
|
205
267
|
|
|
206
268
|
Para mais detalhes, consulte os guias na pasta `docs/`:
|
|
@@ -208,6 +270,7 @@ Para mais detalhes, consulte os guias na pasta `docs/`:
|
|
|
208
270
|
- 🖥️ **[Guia do Dashboard (Visual Runner)](docs/dashboard-runner.md)**: Manual completo do painel interativo.
|
|
209
271
|
- 📄 **[Guia de Testes e Execução (Demo Runner)](docs/demo-runner.md)**: Passo a passo detalhado de como rodar os testes manuais e automatizados.
|
|
210
272
|
- 🛠️ **[Documentação dos Scripts](docs/scripts.md)**: Explicação técnica de como os scripts de automação funcionam.
|
|
273
|
+
- 📡 **[Observabilidade e Alertas](docs/observability.md)**: Guia de configuração de Webhooks, Métricas e WebSocket.
|
|
211
274
|
|
|
212
275
|
---
|
|
213
276
|
|
|
@@ -8,10 +8,16 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
8
8
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
9
|
});
|
|
10
10
|
};
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
11
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
15
|
exports.ConnectionManager = void 0;
|
|
13
16
|
const mongodb_1 = require("mongodb");
|
|
17
|
+
const axios_1 = __importDefault(require("axios"));
|
|
14
18
|
const logger_1 = require("../middlewares/logger");
|
|
19
|
+
const websocket_1 = require("./websocket");
|
|
20
|
+
const metrics_1 = require("./metrics");
|
|
15
21
|
class ConnectionManager {
|
|
16
22
|
constructor(opts) {
|
|
17
23
|
var _a;
|
|
@@ -22,6 +28,7 @@ class ConnectionManager {
|
|
|
22
28
|
this.nodes = (opts.nodes || []).map((u, i) => ({ uri: u, name: `node${i + 1}` }));
|
|
23
29
|
this.dbName = opts.dbName || 'node-balancer';
|
|
24
30
|
this.healthCheckIntervalMs = (_a = opts.healthCheckIntervalMs) !== null && _a !== void 0 ? _a : 5000;
|
|
31
|
+
this.webhookUrl = opts.webhookUrl;
|
|
25
32
|
}
|
|
26
33
|
init() {
|
|
27
34
|
return __awaiter(this, void 0, void 0, function* () {
|
|
@@ -77,12 +84,16 @@ class ConnectionManager {
|
|
|
77
84
|
this.primaryDb = client.db(this.dbName);
|
|
78
85
|
// register monitoring events on the client
|
|
79
86
|
client.on('topologyDescriptionChanged', (td) => {
|
|
87
|
+
var _a;
|
|
80
88
|
logger_1.logger.info(`topologyDescriptionChanged: ${JSON.stringify(this.summarizeTopology(td))}`);
|
|
81
|
-
|
|
89
|
+
const summary = this.summarizeTopology(td);
|
|
90
|
+
this.recordEvent('topologyDescriptionChanged', { td: summary }).catch(() => { });
|
|
91
|
+
(_a = (0, websocket_1.getIO)()) === null || _a === void 0 ? void 0 : _a.emit('topology-change', summary);
|
|
82
92
|
});
|
|
83
93
|
client.on('serverHeartbeatFailed', (event) => {
|
|
84
94
|
logger_1.logger.warn(`serverHeartbeatFailed: ${JSON.stringify(event)}`);
|
|
85
95
|
this.recordEvent('serverHeartbeatFailed', { event }).catch(() => { });
|
|
96
|
+
this.sendAlert('serverHeartbeatFailed', event).catch(() => { });
|
|
86
97
|
});
|
|
87
98
|
client.on('serverHeartbeatSucceeded', (event) => {
|
|
88
99
|
logger_1.logger.debug(`serverHeartbeatSucceeded: ${JSON.stringify(event)}`);
|
|
@@ -92,6 +103,7 @@ class ConnectionManager {
|
|
|
92
103
|
this.recordEvent('clientClose', {}).catch(() => { });
|
|
93
104
|
});
|
|
94
105
|
logger_1.logger.info('Primary client attached.');
|
|
106
|
+
metrics_1.connectionStatus.set(1);
|
|
95
107
|
}
|
|
96
108
|
summarizeTopology(td) {
|
|
97
109
|
// gentle summary - driver topologyDescription shape may vary
|
|
@@ -130,21 +142,38 @@ class ConnectionManager {
|
|
|
130
142
|
}
|
|
131
143
|
recordEvent(type, payload) {
|
|
132
144
|
return __awaiter(this, void 0, void 0, function* () {
|
|
145
|
+
var _a;
|
|
133
146
|
try {
|
|
134
|
-
|
|
135
|
-
return;
|
|
136
|
-
yield this.primaryDb.collection('logs').insertOne({
|
|
147
|
+
const event = {
|
|
137
148
|
ts: new Date(),
|
|
138
149
|
level: 'event',
|
|
139
150
|
type,
|
|
140
151
|
payload,
|
|
141
|
-
}
|
|
152
|
+
};
|
|
153
|
+
(_a = (0, websocket_1.getIO)()) === null || _a === void 0 ? void 0 : _a.emit('log', event);
|
|
154
|
+
if (!this.primaryDb)
|
|
155
|
+
return;
|
|
156
|
+
yield this.primaryDb.collection('logs').insertOne(event);
|
|
142
157
|
}
|
|
143
158
|
catch (err) {
|
|
144
159
|
logger_1.logger.warn('recordEvent failed: ' + err.message);
|
|
145
160
|
}
|
|
146
161
|
});
|
|
147
162
|
}
|
|
163
|
+
sendAlert(event, details) {
|
|
164
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
165
|
+
if (!this.webhookUrl)
|
|
166
|
+
return;
|
|
167
|
+
try {
|
|
168
|
+
yield axios_1.default.post(this.webhookUrl, {
|
|
169
|
+
text: `⚠️ **NodeBalancer Alert**\n**Event**: ${event}\n**Details**: \`\`\`${JSON.stringify(details, null, 2)}\`\`\``
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
logger_1.logger.warn(`Failed to send webhook alert: ${err.message}`);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
}
|
|
148
177
|
startHealthChecks() {
|
|
149
178
|
if (this.healthInterval)
|
|
150
179
|
clearInterval(this.healthInterval);
|
|
@@ -165,6 +194,7 @@ class ConnectionManager {
|
|
|
165
194
|
catch (_a) { }
|
|
166
195
|
this.primaryClient = null;
|
|
167
196
|
this.primaryDb = null;
|
|
197
|
+
metrics_1.connectionStatus.set(0);
|
|
168
198
|
}
|
|
169
199
|
// attempt to find a writable node among nodes (directConnection)
|
|
170
200
|
for (const n of this.nodes) {
|
|
@@ -176,6 +206,8 @@ class ConnectionManager {
|
|
|
176
206
|
logger_1.logger.info(`Health-check: promoted ${n.uri} to primary connection`);
|
|
177
207
|
this.attachClient(c);
|
|
178
208
|
yield this.recordEvent('promote', { node: n.uri });
|
|
209
|
+
yield this.sendAlert('promote', { node: n.uri, message: 'Promoted new primary connection' });
|
|
210
|
+
metrics_1.failoverCount.inc();
|
|
179
211
|
return;
|
|
180
212
|
}
|
|
181
213
|
else {
|
|
@@ -189,6 +221,7 @@ class ConnectionManager {
|
|
|
189
221
|
// no writable found
|
|
190
222
|
logger_1.logger.error('Health-check: no writable nodes found.');
|
|
191
223
|
yield this.recordEvent('no-writable', {});
|
|
224
|
+
yield this.sendAlert('no-writable', { message: 'CRITICAL: No writable nodes found in cluster!' });
|
|
192
225
|
});
|
|
193
226
|
}
|
|
194
227
|
getDb() {
|
|
@@ -212,6 +245,7 @@ class ConnectionManager {
|
|
|
212
245
|
meta,
|
|
213
246
|
durationMs: took,
|
|
214
247
|
});
|
|
248
|
+
metrics_1.operationDuration.observe({ operation: 'read', collection: collectionName, success: 'true' }, took / 1000);
|
|
215
249
|
return res;
|
|
216
250
|
}
|
|
217
251
|
catch (err) {
|
|
@@ -225,6 +259,7 @@ class ConnectionManager {
|
|
|
225
259
|
meta,
|
|
226
260
|
durationMs: took,
|
|
227
261
|
});
|
|
262
|
+
metrics_1.operationDuration.observe({ operation: 'read', collection: collectionName, success: 'false' }, took / 1000);
|
|
228
263
|
throw err;
|
|
229
264
|
}
|
|
230
265
|
});
|
|
@@ -246,6 +281,7 @@ class ConnectionManager {
|
|
|
246
281
|
meta,
|
|
247
282
|
durationMs: took,
|
|
248
283
|
});
|
|
284
|
+
metrics_1.operationDuration.observe({ operation: 'write', collection: collectionName, success: 'true' }, took / 1000);
|
|
249
285
|
return res;
|
|
250
286
|
}
|
|
251
287
|
catch (err) {
|
|
@@ -259,13 +295,16 @@ class ConnectionManager {
|
|
|
259
295
|
meta,
|
|
260
296
|
durationMs: took,
|
|
261
297
|
});
|
|
298
|
+
metrics_1.operationDuration.observe({ operation: 'write', collection: collectionName, success: 'false' }, took / 1000);
|
|
262
299
|
throw err;
|
|
263
300
|
}
|
|
264
301
|
});
|
|
265
302
|
}
|
|
266
303
|
safeLog(doc) {
|
|
267
304
|
return __awaiter(this, void 0, void 0, function* () {
|
|
305
|
+
var _a;
|
|
268
306
|
try {
|
|
307
|
+
(_a = (0, websocket_1.getIO)()) === null || _a === void 0 ? void 0 : _a.emit('log', doc);
|
|
269
308
|
if (!this.primaryDb) {
|
|
270
309
|
logger_1.logger.warn('safeLog: no primaryDb, skipping db log. Logging to console instead.');
|
|
271
310
|
logger_1.logger.info(JSON.stringify(doc));
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.operationDuration = exports.failoverCount = exports.connectionStatus = exports.register = void 0;
|
|
7
|
+
const prom_client_1 = __importDefault(require("prom-client"));
|
|
8
|
+
// Create a Registry
|
|
9
|
+
exports.register = new prom_client_1.default.Registry();
|
|
10
|
+
// Add default metrics (cpu, memory, etc.)
|
|
11
|
+
prom_client_1.default.collectDefaultMetrics({ register: exports.register });
|
|
12
|
+
// Define custom metrics
|
|
13
|
+
exports.connectionStatus = new prom_client_1.default.Gauge({
|
|
14
|
+
name: 'node_balancer_connection_status',
|
|
15
|
+
help: 'Status of the MongoDB connection (1 = connected, 0 = disconnected)',
|
|
16
|
+
registers: [exports.register]
|
|
17
|
+
});
|
|
18
|
+
exports.failoverCount = new prom_client_1.default.Counter({
|
|
19
|
+
name: 'node_balancer_failover_count',
|
|
20
|
+
help: 'Total number of failover events triggered',
|
|
21
|
+
registers: [exports.register]
|
|
22
|
+
});
|
|
23
|
+
exports.operationDuration = new prom_client_1.default.Histogram({
|
|
24
|
+
name: 'node_balancer_operation_duration_seconds',
|
|
25
|
+
help: 'Duration of database operations in seconds',
|
|
26
|
+
labelNames: ['operation', 'collection', 'success'],
|
|
27
|
+
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5],
|
|
28
|
+
registers: [exports.register]
|
|
29
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getIO = exports.initWebSocket = void 0;
|
|
4
|
+
const socket_io_1 = require("socket.io");
|
|
5
|
+
const logger_1 = require("../middlewares/logger");
|
|
6
|
+
let io = null;
|
|
7
|
+
const initWebSocket = (httpServer) => {
|
|
8
|
+
io = new socket_io_1.Server(httpServer, {
|
|
9
|
+
cors: {
|
|
10
|
+
origin: "*",
|
|
11
|
+
methods: ["GET", "POST"]
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
io.on('connection', (socket) => {
|
|
15
|
+
logger_1.logger.info(`New WebSocket connection: ${socket.id}`);
|
|
16
|
+
socket.on('disconnect', () => {
|
|
17
|
+
logger_1.logger.info(`WebSocket disconnected: ${socket.id}`);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
logger_1.logger.info('WebSocket Server initialized');
|
|
21
|
+
return io;
|
|
22
|
+
};
|
|
23
|
+
exports.initWebSocket = initWebSocket;
|
|
24
|
+
const getIO = () => {
|
|
25
|
+
if (!io) {
|
|
26
|
+
logger_1.logger.warn('getIO called before initialization');
|
|
27
|
+
}
|
|
28
|
+
return io;
|
|
29
|
+
};
|
|
30
|
+
exports.getIO = getIO;
|
|
@@ -18,70 +18,26 @@ const blessed_contrib_1 = __importDefault(require("blessed-contrib"));
|
|
|
18
18
|
const mongodb_1 = require("mongodb");
|
|
19
19
|
const http_1 = __importDefault(require("http"));
|
|
20
20
|
const child_process_1 = require("child_process");
|
|
21
|
+
const inquirer_1 = __importDefault(require("inquirer"));
|
|
22
|
+
const socket_io_client_1 = require("socket.io-client");
|
|
21
23
|
// Parse Args
|
|
22
24
|
const args = process.argv.slice(2);
|
|
23
|
-
function getArg(flag, def) {
|
|
25
|
+
function getArg(flag, def = null) {
|
|
24
26
|
const idx = args.indexOf(flag);
|
|
25
27
|
return idx !== -1 && args[idx + 1] ? args[idx + 1] : def;
|
|
26
28
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
title: 'NodeBalancer Control Center'
|
|
40
|
-
});
|
|
41
|
-
const grid = new blessed_contrib_1.default.grid({ rows: 12, cols: 12, screen: screen });
|
|
42
|
-
// Components
|
|
43
|
-
const topologyTable = grid.set(0, 0, 6, 4, blessed_contrib_1.default.table, {
|
|
44
|
-
keys: true,
|
|
45
|
-
fg: 'white',
|
|
46
|
-
selectedFg: 'white',
|
|
47
|
-
selectedBg: 'blue',
|
|
48
|
-
interactive: false,
|
|
49
|
-
label: 'Cluster Topology',
|
|
50
|
-
border: { type: "line", fg: "cyan" },
|
|
51
|
-
columnSpacing: 3,
|
|
52
|
-
columnWidth: [10, 15, 10]
|
|
53
|
-
});
|
|
54
|
-
const latencyLine = grid.set(0, 4, 6, 8, blessed_contrib_1.default.line, {
|
|
55
|
-
style: { line: "yellow", text: "green", baseline: "black" },
|
|
56
|
-
xLabelPadding: 3,
|
|
57
|
-
xPadding: 5,
|
|
58
|
-
showLegend: true,
|
|
59
|
-
legend: { width: 20 },
|
|
60
|
-
label: 'API Response Time (ms)'
|
|
61
|
-
});
|
|
62
|
-
const logBox = grid.set(6, 0, 6, 8, blessed_contrib_1.default.log, {
|
|
63
|
-
fg: "green",
|
|
64
|
-
selectedFg: "green",
|
|
65
|
-
label: 'Execution Logs'
|
|
66
|
-
});
|
|
67
|
-
const controls = grid.set(6, 8, 6, 4, blessed_1.default.list, {
|
|
68
|
-
label: 'Actions (Enter to Execute)',
|
|
69
|
-
keys: true,
|
|
70
|
-
vi: true,
|
|
71
|
-
mouse: true,
|
|
72
|
-
style: { selected: { bg: 'blue' }, item: { fg: 'white' } },
|
|
73
|
-
items: [
|
|
74
|
-
'RUN CHAOS DEMO (Auto)',
|
|
75
|
-
'SEND BATCH (2 POST + 1 GET)',
|
|
76
|
-
...(NO_DOCKER ? [] : [
|
|
77
|
-
'STOP PRIMARY',
|
|
78
|
-
...DOCKER_NODES.map(n => `START ${n.toUpperCase()}`),
|
|
79
|
-
'START STACK'
|
|
80
|
-
]),
|
|
81
|
-
'EXIT'
|
|
82
|
-
]
|
|
83
|
-
});
|
|
84
|
-
// State
|
|
29
|
+
// Global Config (Mutable)
|
|
30
|
+
let API_URL = '';
|
|
31
|
+
let NODES = [];
|
|
32
|
+
let DOCKER_NODES = [];
|
|
33
|
+
let NO_DOCKER = false;
|
|
34
|
+
// TUI State (Lazy Init)
|
|
35
|
+
let screen;
|
|
36
|
+
let grid;
|
|
37
|
+
let topologyTable;
|
|
38
|
+
let latencyLine;
|
|
39
|
+
let logBox;
|
|
40
|
+
let controls;
|
|
85
41
|
let latencyData = {
|
|
86
42
|
title: 'Latency',
|
|
87
43
|
x: Array(20).fill('.'),
|
|
@@ -89,6 +45,10 @@ let latencyData = {
|
|
|
89
45
|
};
|
|
90
46
|
// Utils
|
|
91
47
|
function log(msg) {
|
|
48
|
+
if (!logBox) {
|
|
49
|
+
console.log(msg);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
92
52
|
const time = new Date().toISOString().split('T')[1].split('.')[0];
|
|
93
53
|
logBox.log(`[${time}] ${msg}`);
|
|
94
54
|
screen.render();
|
|
@@ -133,6 +93,8 @@ function request(method) {
|
|
|
133
93
|
});
|
|
134
94
|
}
|
|
135
95
|
function updateLatencyGraph(ms) {
|
|
96
|
+
if (!latencyLine)
|
|
97
|
+
return;
|
|
136
98
|
latencyData.y.shift();
|
|
137
99
|
latencyData.y.push(ms);
|
|
138
100
|
latencyLine.setData([latencyData]);
|
|
@@ -157,6 +119,8 @@ function getPrimary() {
|
|
|
157
119
|
}
|
|
158
120
|
function updateTopology() {
|
|
159
121
|
return __awaiter(this, void 0, void 0, function* () {
|
|
122
|
+
if (!topologyTable)
|
|
123
|
+
return;
|
|
160
124
|
const rows = [];
|
|
161
125
|
for (const node of NODES) {
|
|
162
126
|
let status = 'DOWN';
|
|
@@ -164,7 +128,7 @@ function updateTopology() {
|
|
|
164
128
|
try {
|
|
165
129
|
const client = new mongodb_1.MongoClient(node.uri, { serverSelectionTimeoutMS: 500, directConnection: true });
|
|
166
130
|
yield client.connect();
|
|
167
|
-
const db = client.db('node-balancer');
|
|
131
|
+
const db = client.db('node-balancer');
|
|
168
132
|
const hello = yield db.command({ hello: 1 });
|
|
169
133
|
count = (yield db.collection('users').countDocuments()).toString();
|
|
170
134
|
yield client.close();
|
|
@@ -200,9 +164,7 @@ function runChaosDemo() {
|
|
|
200
164
|
}
|
|
201
165
|
else {
|
|
202
166
|
// Chaos
|
|
203
|
-
const primaryName = yield getPrimary();
|
|
204
|
-
// We need to map node name to container name if they differ, but here we assume index matching or we need smarter logic
|
|
205
|
-
// For simplicity in this generic version, let's try to find the container name based on index
|
|
167
|
+
const primaryName = yield getPrimary();
|
|
206
168
|
const primaryIndex = NODES.findIndex(n => n.name === primaryName);
|
|
207
169
|
const containerName = primaryIndex !== -1 ? DOCKER_NODES[primaryIndex] : null;
|
|
208
170
|
if (containerName) {
|
|
@@ -241,75 +203,199 @@ function runChaosDemo() {
|
|
|
241
203
|
log('✅ DEMO COMPLETED');
|
|
242
204
|
});
|
|
243
205
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
206
|
+
function initTui() {
|
|
207
|
+
screen = blessed_1.default.screen({
|
|
208
|
+
smartCSR: true,
|
|
209
|
+
title: 'NodeBalancer Control Center'
|
|
210
|
+
});
|
|
211
|
+
grid = new blessed_contrib_1.default.grid({ rows: 12, cols: 12, screen: screen });
|
|
212
|
+
topologyTable = grid.set(0, 0, 6, 4, blessed_contrib_1.default.table, {
|
|
213
|
+
keys: true,
|
|
214
|
+
fg: 'white',
|
|
215
|
+
selectedFg: 'white',
|
|
216
|
+
selectedBg: 'blue',
|
|
217
|
+
interactive: false,
|
|
218
|
+
label: 'Cluster Topology',
|
|
219
|
+
border: { type: "line", fg: "cyan" },
|
|
220
|
+
columnSpacing: 3,
|
|
221
|
+
columnWidth: [10, 15, 10]
|
|
222
|
+
});
|
|
223
|
+
latencyLine = grid.set(0, 4, 6, 8, blessed_contrib_1.default.line, {
|
|
224
|
+
style: { line: "yellow", text: "green", baseline: "black" },
|
|
225
|
+
xLabelPadding: 3,
|
|
226
|
+
xPadding: 5,
|
|
227
|
+
showLegend: true,
|
|
228
|
+
legend: { width: 20 },
|
|
229
|
+
label: 'API Response Time (ms)'
|
|
230
|
+
});
|
|
231
|
+
logBox = grid.set(6, 0, 6, 8, blessed_contrib_1.default.log, {
|
|
232
|
+
fg: "green",
|
|
233
|
+
selectedFg: "green",
|
|
234
|
+
label: 'Execution Logs'
|
|
235
|
+
});
|
|
236
|
+
controls = grid.set(6, 8, 6, 4, blessed_1.default.list, {
|
|
237
|
+
label: 'Actions (Enter to Execute)',
|
|
238
|
+
keys: true,
|
|
239
|
+
vi: true,
|
|
240
|
+
mouse: true,
|
|
241
|
+
style: { selected: { bg: 'blue' }, item: { fg: 'white' } },
|
|
242
|
+
items: [
|
|
243
|
+
'RUN CHAOS DEMO (Auto)',
|
|
244
|
+
'SEND BATCH (2 POST + 1 GET)',
|
|
245
|
+
...(NO_DOCKER ? [] : [
|
|
246
|
+
'STOP PRIMARY',
|
|
247
|
+
...DOCKER_NODES.map(n => `START ${n.toUpperCase()}`),
|
|
248
|
+
'START STACK'
|
|
249
|
+
]),
|
|
250
|
+
'EXIT'
|
|
251
|
+
]
|
|
252
|
+
});
|
|
253
|
+
controls.on('select', (item, index) => __awaiter(this, void 0, void 0, function* () {
|
|
254
|
+
const cmd = item.getText();
|
|
255
|
+
if (cmd.includes('RUN CHAOS DEMO')) {
|
|
256
|
+
runChaosDemo();
|
|
257
|
+
}
|
|
258
|
+
else if (cmd.includes('SEND BATCH')) {
|
|
259
|
+
runBatch();
|
|
260
|
+
}
|
|
261
|
+
else if (cmd.includes('STOP PRIMARY')) {
|
|
262
|
+
if (NO_DOCKER)
|
|
263
|
+
return log('Docker disabled.');
|
|
264
|
+
const pName = yield getPrimary();
|
|
265
|
+
const pIdx = NODES.findIndex(n => n.name === pName);
|
|
266
|
+
const container = pIdx !== -1 ? DOCKER_NODES[pIdx] : null;
|
|
267
|
+
if (container) {
|
|
268
|
+
log(`Stopping ${container}...`);
|
|
269
|
+
try {
|
|
270
|
+
(0, child_process_1.execSync)(`docker stop ${container}`);
|
|
271
|
+
log('Stopped.');
|
|
272
|
+
}
|
|
273
|
+
catch (e) {
|
|
274
|
+
log('Error.');
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
log('No Primary found.');
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
else if (cmd.includes('START MONGO') || cmd.includes('START NODE')) {
|
|
282
|
+
if (NO_DOCKER)
|
|
283
|
+
return log('Docker disabled.');
|
|
284
|
+
const parts = cmd.split(' ');
|
|
285
|
+
const container = parts[1].toLowerCase();
|
|
286
|
+
log(`Starting ${container}...`);
|
|
261
287
|
try {
|
|
262
|
-
(0, child_process_1.execSync)(`docker
|
|
263
|
-
log('
|
|
288
|
+
(0, child_process_1.execSync)(`docker start ${container}`);
|
|
289
|
+
log('Started.');
|
|
264
290
|
}
|
|
265
291
|
catch (e) {
|
|
266
292
|
log('Error.');
|
|
267
293
|
}
|
|
268
294
|
}
|
|
269
|
-
else {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
try {
|
|
281
|
-
(0, child_process_1.execSync)(`docker start ${container}`);
|
|
282
|
-
log('Started.');
|
|
295
|
+
else if (cmd.includes('START STACK')) {
|
|
296
|
+
if (NO_DOCKER)
|
|
297
|
+
return log('Docker disabled.');
|
|
298
|
+
log('Starting stack...');
|
|
299
|
+
try {
|
|
300
|
+
(0, child_process_1.execSync)('docker-compose up -d');
|
|
301
|
+
log('Stack up.');
|
|
302
|
+
}
|
|
303
|
+
catch (e) {
|
|
304
|
+
log(`Error: ${e.message.split('\n')[0]}`);
|
|
305
|
+
}
|
|
283
306
|
}
|
|
284
|
-
|
|
285
|
-
|
|
307
|
+
else if (cmd.includes('EXIT')) {
|
|
308
|
+
process.exit(0);
|
|
286
309
|
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
310
|
+
}));
|
|
311
|
+
screen.key(['escape', 'q', 'C-c'], () => process.exit(0));
|
|
312
|
+
controls.focus();
|
|
313
|
+
screen.render();
|
|
314
|
+
}
|
|
315
|
+
// Main Execution
|
|
316
|
+
function main() {
|
|
317
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
318
|
+
// Check if flags are provided, otherwise prompt
|
|
319
|
+
const argApiUrl = getArg('--api-url');
|
|
320
|
+
const argNodes = getArg('--nodes');
|
|
321
|
+
const argDocker = getArg('--docker-containers');
|
|
322
|
+
const argNoDocker = args.includes('--no-docker');
|
|
323
|
+
if (argApiUrl && argNodes) {
|
|
324
|
+
// Non-interactive mode (Flags provided)
|
|
325
|
+
API_URL = argApiUrl;
|
|
326
|
+
NODES = argNodes.split(',').map((uri, i) => ({ name: `node${i + 1}`, uri: uri.trim() }));
|
|
327
|
+
DOCKER_NODES = (argDocker || 'mongo1,mongo2,mongo3').split(',').map(n => n.trim());
|
|
328
|
+
NO_DOCKER = argNoDocker;
|
|
295
329
|
}
|
|
296
|
-
|
|
297
|
-
|
|
330
|
+
else {
|
|
331
|
+
// Interactive mode
|
|
332
|
+
console.clear();
|
|
333
|
+
console.log('🤖 NodeBalancer Dashboard Setup\n');
|
|
334
|
+
const answers = yield inquirer_1.default.prompt([
|
|
335
|
+
{
|
|
336
|
+
type: 'input',
|
|
337
|
+
name: 'apiUrl',
|
|
338
|
+
message: 'API URL:',
|
|
339
|
+
default: 'http://localhost:3000/api/users'
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
type: 'input',
|
|
343
|
+
name: 'nodes',
|
|
344
|
+
message: 'MongoDB Nodes (comma separated):',
|
|
345
|
+
default: 'mongodb://localhost:27017/node-balancer,mongodb://localhost:27018/node-balancer,mongodb://localhost:27019/node-balancer'
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
type: 'confirm',
|
|
349
|
+
name: 'enableDocker',
|
|
350
|
+
message: 'Enable Docker Control (Stop/Start containers)?',
|
|
351
|
+
default: true
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
type: 'input',
|
|
355
|
+
name: 'dockerContainers',
|
|
356
|
+
message: 'Docker Container Names (comma separated):',
|
|
357
|
+
default: 'mongo1,mongo2,mongo3',
|
|
358
|
+
when: (answers) => answers.enableDocker
|
|
359
|
+
}
|
|
360
|
+
]);
|
|
361
|
+
API_URL = answers.apiUrl;
|
|
362
|
+
NODES = answers.nodes.split(',').map((uri, i) => ({ name: `node${i + 1}`, uri: uri.trim() }));
|
|
363
|
+
NO_DOCKER = !answers.enableDocker;
|
|
364
|
+
DOCKER_NODES = (answers.dockerContainers || '').split(',').map((n) => n.trim());
|
|
298
365
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
log('
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
366
|
+
// Init TUI AFTER prompts
|
|
367
|
+
initTui();
|
|
368
|
+
log('Control Center Ready.');
|
|
369
|
+
if (NO_DOCKER)
|
|
370
|
+
log('Docker Control: DISABLED');
|
|
371
|
+
log(`API: ${API_URL}`);
|
|
372
|
+
// Init WebSocket
|
|
373
|
+
const socket = (0, socket_io_client_1.io)(API_URL.replace('/api/users', '').replace('/api', ''));
|
|
374
|
+
socket.on('connect', () => log('✅ WebSocket Connected'));
|
|
375
|
+
socket.on('disconnect', () => log('❌ WebSocket Disconnected'));
|
|
376
|
+
socket.on('log', (data) => {
|
|
377
|
+
let msg = '';
|
|
378
|
+
if (data.type === 'promote')
|
|
379
|
+
msg = `👑 NEW PRIMARY: ${data.payload.node}`;
|
|
380
|
+
else if (data.type === 'no-writable')
|
|
381
|
+
msg = `🚨 CRITICAL: NO WRITABLE NODES`;
|
|
382
|
+
else if (data.op)
|
|
383
|
+
msg = `${data.op.toUpperCase()} ${data.collection} (${data.durationMs}ms) ${data.success ? '✅' : '❌'}`;
|
|
384
|
+
else
|
|
385
|
+
msg = JSON.stringify(data);
|
|
386
|
+
log(`[WS] ${msg}`);
|
|
387
|
+
});
|
|
388
|
+
socket.on('topology-change', () => {
|
|
389
|
+
log(`[WS] Topology Change`);
|
|
390
|
+
updateTopology();
|
|
391
|
+
});
|
|
392
|
+
// Loops
|
|
393
|
+
setInterval(updateTopology, 2000);
|
|
394
|
+
updateTopology();
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
// Start
|
|
398
|
+
main().catch(err => {
|
|
399
|
+
console.error(err);
|
|
400
|
+
process.exit(1);
|
|
401
|
+
});
|
package/dist/server.js
CHANGED
|
@@ -1,11 +1,32 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
2
11
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
12
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
13
|
};
|
|
5
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
15
|
const app_1 = __importDefault(require("./app"));
|
|
7
16
|
const logger_1 = require("./middlewares/logger");
|
|
17
|
+
const websocket_1 = require("./config/websocket");
|
|
18
|
+
const metrics_1 = require("./config/metrics");
|
|
8
19
|
const PORT = process.env.PORT || 3000;
|
|
9
|
-
app_1.default.
|
|
20
|
+
app_1.default.get('/metrics', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
|
|
21
|
+
try {
|
|
22
|
+
res.set('Content-Type', metrics_1.register.contentType);
|
|
23
|
+
res.end(yield metrics_1.register.metrics());
|
|
24
|
+
}
|
|
25
|
+
catch (ex) {
|
|
26
|
+
res.status(500).end(ex);
|
|
27
|
+
}
|
|
28
|
+
}));
|
|
29
|
+
const server = app_1.default.listen(PORT, () => {
|
|
10
30
|
logger_1.logger.info(`Servidor rodando na porta ${PORT}`);
|
|
11
31
|
});
|
|
32
|
+
(0, websocket_1.initWebSocket)(server);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "replica-failover-mongodb-ts",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.3",
|
|
4
4
|
"description": "O sistema replica um dos conceitos básicos da arquitetura de sistemas distribuído, na qual ele tem uma API em express.js com TypeScript, e usando o mongoDB com replicaSET e failover, consegue automáticamente garantir disponibilidade e redundância de dados das requisições.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -31,12 +31,18 @@
|
|
|
31
31
|
"license": "ISC",
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@types/blessed": "^0.1.26",
|
|
34
|
+
"@types/inquirer": "^9.0.9",
|
|
35
|
+
"axios": "^1.13.2",
|
|
34
36
|
"blessed": "^0.1.81",
|
|
35
37
|
"blessed-contrib": "^4.11.0",
|
|
36
38
|
"dotenv": "^17.2.3",
|
|
37
39
|
"express": "^5.1.0",
|
|
40
|
+
"inquirer": "^8.0.0",
|
|
38
41
|
"mongoose": "^8.19.1",
|
|
39
42
|
"morgan": "^1.10.1",
|
|
43
|
+
"prom-client": "^15.1.3",
|
|
44
|
+
"socket.io": "^4.8.1",
|
|
45
|
+
"socket.io-client": "^4.8.1",
|
|
40
46
|
"winston": "^3.18.3"
|
|
41
47
|
},
|
|
42
48
|
"devDependencies": {
|