ratelimit-engine 1.0.1 → 1.1.0
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/bin/cli.js +39 -9
- package/bin/dashboard.js +92 -0
- package/package.json +3 -2
- package/src/dashboard/DashboardCollector.js +97 -0
- package/src/dashboard/DashboardRenderer.js +186 -0
package/bin/cli.js
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* ratelimit-engine CLI
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* npx ratelimit-engine — setup wizard
|
|
6
|
+
* npx ratelimit-engine dashboard — live 10ms dashboard
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import * as readline from 'readline'
|
|
9
10
|
import * as fs from 'fs'
|
|
10
11
|
import * as path from 'path'
|
|
12
|
+
import { createRequire } from 'module'
|
|
13
|
+
import { fileURLToPath } from 'url'
|
|
14
|
+
|
|
15
|
+
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
16
|
+
|
|
17
|
+
// ── Route subcommands ─────────────────────────────────────────────
|
|
18
|
+
const subcommand = process.argv[2]
|
|
19
|
+
if (subcommand === 'dashboard') {
|
|
20
|
+
// Dynamically import and run the dashboard
|
|
21
|
+
await import(new URL('./dashboard.js', import.meta.url))
|
|
22
|
+
process.exit(0)
|
|
23
|
+
}
|
|
11
24
|
|
|
12
25
|
// ── ANSI colours ──────────────────────────────────────────────────
|
|
13
26
|
const C = {
|
|
@@ -138,7 +151,7 @@ function algoArgs(key, config) {
|
|
|
138
151
|
}
|
|
139
152
|
}
|
|
140
153
|
|
|
141
|
-
function generateConfig({ algoKey, algoConfig, storeKey, frameworkKey, keyStrategyKey }) {
|
|
154
|
+
function generateConfig({ algoKey, algoConfig, storeKey, frameworkKey, keyStrategyKey, failOpen = true, customOnBlocked = null }) {
|
|
142
155
|
const isRedis = storeKey === 'RedisStore'
|
|
143
156
|
const isComposite = algoKey === 'Composite'
|
|
144
157
|
|
|
@@ -146,7 +159,7 @@ function generateConfig({ algoKey, algoConfig, storeKey, frameworkKey, keyStrate
|
|
|
146
159
|
? [algoKey, algoConfig.primary.key, algoConfig.secondary.key]
|
|
147
160
|
: [algoKey]
|
|
148
161
|
|
|
149
|
-
const allImports = [...new Set([...imports, storeKey, keyStrategyKey])]
|
|
162
|
+
const allImports = [...new Set(['createLimiter', ...imports, storeKey, keyStrategyKey])]
|
|
150
163
|
|
|
151
164
|
const redisImport = isRedis ? `\nimport Redis from 'ioredis'` : ''
|
|
152
165
|
const redisClient = isRedis ? `\nconst redis = new Redis() // configure your Redis connection\n` : ''
|
|
@@ -203,15 +216,19 @@ if (!result.allowed) {
|
|
|
203
216
|
break
|
|
204
217
|
}
|
|
205
218
|
|
|
219
|
+
const onBlockedMsg = customOnBlocked
|
|
220
|
+
? `console.warn(\`${customOnBlocked}\`)`
|
|
221
|
+
: `console.warn(\`[RATE LIMIT] \${ctx.ip} blocked — retry in \${result.retryAfter}s\`)`
|
|
222
|
+
|
|
206
223
|
return `import { ${allImports.join(', ')}${compositeImport} } from 'ratelimit-engine'${redisImport}
|
|
207
224
|
${redisClient}
|
|
208
225
|
const limiter = createLimiter({
|
|
209
226
|
algorithm: ${algoInit},
|
|
210
227
|
store: ${storeInit},
|
|
211
228
|
keyBy: ${keyStrategyKey},
|
|
212
|
-
failOpen:
|
|
229
|
+
failOpen: ${failOpen},
|
|
213
230
|
onBlocked: (ctx, result) => {
|
|
214
|
-
|
|
231
|
+
${onBlockedMsg}
|
|
215
232
|
},
|
|
216
233
|
})
|
|
217
234
|
|
|
@@ -252,6 +269,17 @@ async function main() {
|
|
|
252
269
|
console.log(`\n ${bold('Step 4 — How should requests be keyed?')}\n`)
|
|
253
270
|
const keyStrategy = await askChoice(KEY_STRATEGIES)
|
|
254
271
|
|
|
272
|
+
// Step 5 — Custom overrides
|
|
273
|
+
console.log(`\n ${bold('Step 5 — Custom options')} ${dim('(press Enter to skip each)')}`)
|
|
274
|
+
console.log(` ${dim('Override any value before the file is written.\n')}`)
|
|
275
|
+
|
|
276
|
+
const outputFile = await ask(` Output filename ${dim('[ratelimit.config.js]')}: `)
|
|
277
|
+
const fileName = outputFile || 'ratelimit.config.js'
|
|
278
|
+
|
|
279
|
+
const customOnBlocked = await ask(` onBlocked message ${dim('[Rate limit exceeded, retry in {n}s]')}: `)
|
|
280
|
+
const failOpenRaw = await ask(` failOpen — allow requests when store is down? ${dim('[yes]')}: `)
|
|
281
|
+
const failOpen = failOpenRaw.toLowerCase() === 'no' ? false : true
|
|
282
|
+
|
|
255
283
|
// Generate
|
|
256
284
|
const code = generateConfig({
|
|
257
285
|
algoKey: algo.key,
|
|
@@ -259,21 +287,23 @@ async function main() {
|
|
|
259
287
|
storeKey: store.key,
|
|
260
288
|
frameworkKey: framework.key,
|
|
261
289
|
keyStrategyKey: keyStrategy.key,
|
|
290
|
+
failOpen,
|
|
291
|
+
customOnBlocked: customOnBlocked || null,
|
|
262
292
|
})
|
|
263
293
|
|
|
264
294
|
// Write file
|
|
265
|
-
const outPath = path.join(process.cwd(),
|
|
295
|
+
const outPath = path.join(process.cwd(), fileName)
|
|
266
296
|
fs.writeFileSync(outPath, code, 'utf8')
|
|
267
297
|
|
|
268
298
|
// Output
|
|
269
299
|
console.log(`\n ${dim('─────────────────────────────────────────────')}`)
|
|
270
|
-
console.log(` ${green(bold('✅ Done!'))} Generated ${bold(
|
|
300
|
+
console.log(` ${green(bold('✅ Done!'))} Generated ${bold(fileName)}\n`)
|
|
271
301
|
console.log(` ${dim('─────────────────────────────────────────────')}`)
|
|
272
302
|
console.log(`\n${code}`)
|
|
273
303
|
console.log(` ${dim('─────────────────────────────────────────────')}`)
|
|
274
304
|
console.log(`\n ${bold('Next steps:')}\n`)
|
|
275
305
|
console.log(` ${cyan('1.')} Import the config into your app:`)
|
|
276
|
-
console.log(` ${dim(
|
|
306
|
+
console.log(` ${dim(`import { limiter } from './${fileName}'`)}`)
|
|
277
307
|
console.log(`\n ${cyan('2.')} Wire it up — the usage snippet is inside the generated file.`)
|
|
278
308
|
if (store.key === 'RedisStore') {
|
|
279
309
|
console.log(`\n ${cyan('3.')} Install ioredis if not already:`)
|
package/bin/dashboard.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Live dashboard entry point.
|
|
3
|
+
* Starts a demo server with rate limiting wired to the DashboardCollector,
|
|
4
|
+
* then renders the terminal UI at 10ms intervals.
|
|
5
|
+
*
|
|
6
|
+
* Usage: npx ratelimit-engine dashboard
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { DashboardCollector } from '../src/dashboard/DashboardCollector.js'
|
|
10
|
+
import { DashboardRenderer } from '../src/dashboard/DashboardRenderer.js'
|
|
11
|
+
import { createLimiter } from '../src/engine/createLimiter.js'
|
|
12
|
+
import { TokenBucket } from '../src/algorithms/TokenBucket.js'
|
|
13
|
+
import { FixedWindow } from '../src/algorithms/FixedWindow.js'
|
|
14
|
+
import { SlidingWindow } from '../src/algorithms/SlidingWindow.js'
|
|
15
|
+
import { MemoryStore } from '../src/stores/MemoryStore.js'
|
|
16
|
+
import { byIP } from '../src/resolvers/byIP.js'
|
|
17
|
+
|
|
18
|
+
const REFRESH_MS = 10
|
|
19
|
+
const DEMO_IPS = ['192.168.1.1', '10.0.0.5', '172.16.0.3', '127.0.0.1']
|
|
20
|
+
const DEMO_PATHS = ['/api/hello', '/api/login', '/api/search', '/api/admin/data']
|
|
21
|
+
const DEMO_METHOD = ['GET', 'POST', 'GET', 'DELETE']
|
|
22
|
+
|
|
23
|
+
// ── Collector + Renderer ──────────────────────────────────────────
|
|
24
|
+
const collector = new DashboardCollector()
|
|
25
|
+
const renderer = new DashboardRenderer()
|
|
26
|
+
|
|
27
|
+
// ── Demo limiter (Token Bucket: burst 5, refill 2/sec) ───────────
|
|
28
|
+
const limiter = createLimiter({
|
|
29
|
+
algorithm: new TokenBucket({ capacity: 5, refillRate: 2 }),
|
|
30
|
+
store: new MemoryStore(),
|
|
31
|
+
keyBy: byIP,
|
|
32
|
+
failOpen: true,
|
|
33
|
+
onAllowed: (ctx, result) => collector.recordAllowed(ctx),
|
|
34
|
+
onBlocked: (ctx, result) => collector.recordBlocked(ctx, result),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// ── Simulate traffic — random bursts every 10–80ms ────────────────
|
|
38
|
+
function simulateRequest() {
|
|
39
|
+
const idx = Math.floor(Math.random() * DEMO_IPS.length)
|
|
40
|
+
const ip = DEMO_IPS[idx]
|
|
41
|
+
const path = DEMO_PATHS[idx]
|
|
42
|
+
const method = DEMO_METHOD[idx]
|
|
43
|
+
|
|
44
|
+
limiter.check({ ip, path, method, user: null })
|
|
45
|
+
|
|
46
|
+
// Schedule next request with variable interval to create burst patterns
|
|
47
|
+
const nextIn = Math.random() < 0.3
|
|
48
|
+
? Math.floor(Math.random() * 5) // burst: 0–5ms
|
|
49
|
+
: Math.floor(Math.random() * 80) // normal: 0–80ms
|
|
50
|
+
setTimeout(simulateRequest, nextIn)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Flush bucket every 10ms ───────────────────────────────────────
|
|
54
|
+
const flushInterval = setInterval(() => collector.flush(), REFRESH_MS)
|
|
55
|
+
|
|
56
|
+
// ── Render loop every 10ms ────────────────────────────────────────
|
|
57
|
+
let paused = false
|
|
58
|
+
const renderInterval = setInterval(() => {
|
|
59
|
+
if (!paused) renderer.render(collector.snapshot())
|
|
60
|
+
}, REFRESH_MS)
|
|
61
|
+
|
|
62
|
+
// ── Keyboard input ────────────────────────────────────────────────
|
|
63
|
+
if (process.stdin.isTTY) {
|
|
64
|
+
process.stdin.setRawMode(true)
|
|
65
|
+
}
|
|
66
|
+
process.stdin.resume()
|
|
67
|
+
process.stdin.setEncoding('utf8')
|
|
68
|
+
|
|
69
|
+
process.stdin.on('data', (key) => {
|
|
70
|
+
if (key === 'q' || key === '\u0003') {
|
|
71
|
+
cleanup()
|
|
72
|
+
} else if (key === 'r') {
|
|
73
|
+
collector.reset()
|
|
74
|
+
} else if (key === 'p') {
|
|
75
|
+
paused = !paused
|
|
76
|
+
renderer.paused = paused
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
function cleanup() {
|
|
81
|
+
clearInterval(flushInterval)
|
|
82
|
+
clearInterval(renderInterval)
|
|
83
|
+
renderer.cleanup()
|
|
84
|
+
limiter.destroy()
|
|
85
|
+
process.exit(0)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
process.on('SIGINT', cleanup)
|
|
89
|
+
process.on('SIGTERM', cleanup)
|
|
90
|
+
|
|
91
|
+
// ── Start ─────────────────────────────────────────────────────────
|
|
92
|
+
simulateRequest()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ratelimit-engine",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Enterprise-grade, algorithm-pluggable rate limiting engine for Node.js",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"LICENSE"
|
|
36
36
|
],
|
|
37
37
|
"bin": {
|
|
38
|
-
"ratelimit-engine": "./bin/cli.js"
|
|
38
|
+
"ratelimit-engine": "./bin/cli.js",
|
|
39
|
+
"ratelimit-dashboard": "./bin/dashboard.js"
|
|
39
40
|
},
|
|
40
41
|
"author": "Abhinav710 <kumarabhinav5693@gmail.com>",
|
|
41
42
|
"license": "MIT",
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview In-process event collector for the live dashboard.
|
|
3
|
+
* Receives allowed/blocked events from RateLimiter hooks and
|
|
4
|
+
* maintains a rolling 10ms-bucketed time series.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { EventEmitter } from 'events'
|
|
8
|
+
|
|
9
|
+
const BUCKET_MS = 10 // resolution: one bucket per 10ms
|
|
10
|
+
const HISTORY = 200 // keep last 200 buckets (2 seconds of sparkline)
|
|
11
|
+
const TOP_IPS = 8 // max IPs shown in top-IPs panel
|
|
12
|
+
const FEED_SIZE = 12 // max rows in live feed
|
|
13
|
+
|
|
14
|
+
export class DashboardCollector extends EventEmitter {
|
|
15
|
+
constructor() {
|
|
16
|
+
super()
|
|
17
|
+
this._buckets = [] // rolling array of { ts, allowed, blocked }
|
|
18
|
+
this._ipMap = new Map() // ip → { allowed, blocked, lastSeen }
|
|
19
|
+
this._feed = [] // last N events
|
|
20
|
+
this._startedAt = Date.now()
|
|
21
|
+
this._current = this._newBucket()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Called by onAllowed hook */
|
|
25
|
+
recordAllowed(context) {
|
|
26
|
+
this._current.allowed++
|
|
27
|
+
this._trackIP(context, true)
|
|
28
|
+
this._pushFeed({ allowed: true, context, ts: Date.now() })
|
|
29
|
+
this.emit('tick')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Called by onBlocked hook */
|
|
33
|
+
recordBlocked(context, result) {
|
|
34
|
+
this._current.blocked++
|
|
35
|
+
this._trackIP(context, false)
|
|
36
|
+
this._pushFeed({ allowed: false, context, result, ts: Date.now() })
|
|
37
|
+
this.emit('tick')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Flush current bucket and start a new one — called every 10ms */
|
|
41
|
+
flush() {
|
|
42
|
+
this._buckets.push(this._current)
|
|
43
|
+
if (this._buckets.length > HISTORY) this._buckets.shift()
|
|
44
|
+
this._current = this._newBucket()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Snapshot for the renderer */
|
|
48
|
+
snapshot() {
|
|
49
|
+
const buckets = [...this._buckets, this._current]
|
|
50
|
+
const last = buckets.slice(-20) // last 200ms window
|
|
51
|
+
|
|
52
|
+
const totalAllowed = last.reduce((s, b) => s + b.allowed, 0)
|
|
53
|
+
const totalBlocked = last.reduce((s, b) => s + b.blocked, 0)
|
|
54
|
+
const total = totalAllowed + totalBlocked
|
|
55
|
+
|
|
56
|
+
const topIPs = [...this._ipMap.entries()]
|
|
57
|
+
.map(([ip, v]) => ({ ip, ...v }))
|
|
58
|
+
.sort((a, b) => (b.allowed + b.blocked) - (a.allowed + a.blocked))
|
|
59
|
+
.slice(0, TOP_IPS)
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
uptime: Date.now() - this._startedAt,
|
|
63
|
+
total,
|
|
64
|
+
allowed: totalAllowed,
|
|
65
|
+
blocked: totalBlocked,
|
|
66
|
+
sparkline: buckets.map((b) => b.allowed + b.blocked),
|
|
67
|
+
topIPs,
|
|
68
|
+
feed: [...this._feed],
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
reset() {
|
|
73
|
+
this._buckets = []
|
|
74
|
+
this._ipMap = new Map()
|
|
75
|
+
this._feed = []
|
|
76
|
+
this._current = this._newBucket()
|
|
77
|
+
this._startedAt = Date.now()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_newBucket() {
|
|
81
|
+
return { ts: Date.now(), allowed: 0, blocked: 0 }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_trackIP(context, allowed) {
|
|
85
|
+
const ip = context?.ip ?? 'unknown'
|
|
86
|
+
const entry = this._ipMap.get(ip) ?? { allowed: 0, blocked: 0, lastSeen: 0 }
|
|
87
|
+
if (allowed) entry.allowed++
|
|
88
|
+
else entry.blocked++
|
|
89
|
+
entry.lastSeen = Date.now()
|
|
90
|
+
this._ipMap.set(ip, entry)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
_pushFeed(event) {
|
|
94
|
+
this._feed.unshift(event)
|
|
95
|
+
if (this._feed.length > FEED_SIZE) this._feed.pop()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Terminal renderer for the live dashboard.
|
|
3
|
+
* Uses ANSI escape codes only — zero dependencies.
|
|
4
|
+
* Redraws the full screen every 10ms via process.stdout.write.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// ── ANSI helpers ──────────────────────────────────────────────────
|
|
8
|
+
const ESC = '\x1b['
|
|
9
|
+
const CLEAR = '\x1b[2J\x1b[H' // clear screen + move cursor to top
|
|
10
|
+
const HIDE = '\x1b[?25l' // hide cursor
|
|
11
|
+
const SHOW = '\x1b[?25h' // show cursor
|
|
12
|
+
|
|
13
|
+
const C = {
|
|
14
|
+
reset: '\x1b[0m',
|
|
15
|
+
bold: '\x1b[1m',
|
|
16
|
+
dim: '\x1b[2m',
|
|
17
|
+
green: '\x1b[32m',
|
|
18
|
+
red: '\x1b[31m',
|
|
19
|
+
yellow: '\x1b[33m',
|
|
20
|
+
cyan: '\x1b[36m',
|
|
21
|
+
magenta: '\x1b[35m',
|
|
22
|
+
blue: '\x1b[34m',
|
|
23
|
+
white: '\x1b[37m',
|
|
24
|
+
bgRed: '\x1b[41m',
|
|
25
|
+
bgGreen: '\x1b[42m',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const bold = (s) => `${C.bold}${s}${C.reset}`
|
|
29
|
+
const dim = (s) => `${C.dim}${s}${C.reset}`
|
|
30
|
+
const green = (s) => `${C.green}${s}${C.reset}`
|
|
31
|
+
const red = (s) => `${C.red}${s}${C.reset}`
|
|
32
|
+
const yellow = (s) => `${C.yellow}${s}${C.reset}`
|
|
33
|
+
const cyan = (s) => `${C.cyan}${s}${C.reset}`
|
|
34
|
+
const magenta = (s) => `${C.magenta}${s}${C.reset}`
|
|
35
|
+
|
|
36
|
+
// ── Sparkline ─────────────────────────────────────────────────────
|
|
37
|
+
const SPARK_CHARS = ' ▁▂▃▄▅▆▇█'
|
|
38
|
+
|
|
39
|
+
function sparkline(values, width = 50) {
|
|
40
|
+
const slice = values.slice(-width)
|
|
41
|
+
const max = Math.max(...slice, 1)
|
|
42
|
+
return slice
|
|
43
|
+
.map((v) => {
|
|
44
|
+
const idx = Math.round((v / max) * (SPARK_CHARS.length - 1))
|
|
45
|
+
return SPARK_CHARS[idx]
|
|
46
|
+
})
|
|
47
|
+
.join('')
|
|
48
|
+
.padStart(width)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Bar ───────────────────────────────────────────────────────────
|
|
52
|
+
function bar(value, max, width = 30, color = green) {
|
|
53
|
+
const filled = max > 0 ? Math.round((value / max) * width) : 0
|
|
54
|
+
const empty = width - filled
|
|
55
|
+
return color('█'.repeat(filled)) + dim('░'.repeat(empty))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Format helpers ────────────────────────────────────────────────
|
|
59
|
+
function fmtUptime(ms) {
|
|
60
|
+
const s = Math.floor(ms / 1000)
|
|
61
|
+
const m = Math.floor(s / 60)
|
|
62
|
+
const h = Math.floor(m / 60)
|
|
63
|
+
return [
|
|
64
|
+
String(h).padStart(2, '0'),
|
|
65
|
+
String(m % 60).padStart(2, '0'),
|
|
66
|
+
String(s % 60).padStart(2, '0'),
|
|
67
|
+
].join(':')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function fmtTime(ts) {
|
|
71
|
+
return new Date(ts).toISOString().slice(11, 23) // HH:MM:SS.mmm
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pad(s, n) {
|
|
75
|
+
return String(s).slice(0, n).padEnd(n)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Box drawing ───────────────────────────────────────────────────
|
|
79
|
+
const W = 62 // inner box width
|
|
80
|
+
|
|
81
|
+
function boxTop(title) {
|
|
82
|
+
const t = title ? ` ${title} ` : ''
|
|
83
|
+
const dashes = '─'.repeat(W - t.length)
|
|
84
|
+
return ` ┌─${t}${dashes}┐`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function boxRow(content) {
|
|
88
|
+
const raw = content.replace(/\x1b\[[0-9;]*m/g, '') // strip ANSI for length
|
|
89
|
+
const pad = W - raw.length
|
|
90
|
+
return ` │ ${content}${' '.repeat(Math.max(0, pad - 1))}│`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function boxBot() {
|
|
94
|
+
return ` └${'─'.repeat(W + 1)}┘`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Main renderer ─────────────────────────────────────────────────
|
|
98
|
+
export class DashboardRenderer {
|
|
99
|
+
constructor({ paused = false } = {}) {
|
|
100
|
+
this.paused = paused
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
render(snapshot) {
|
|
104
|
+
const { uptime, total, allowed, blocked, sparkline: spark, topIPs, feed } = snapshot
|
|
105
|
+
const blockPct = total > 0 ? ((blocked / total) * 100).toFixed(1) : '0.0'
|
|
106
|
+
const maxBar = Math.max(total, 1)
|
|
107
|
+
const lines = []
|
|
108
|
+
|
|
109
|
+
// ── Header ────────────────────────────────────────────────────
|
|
110
|
+
lines.push('')
|
|
111
|
+
lines.push(
|
|
112
|
+
` ${bold(cyan('⚡ ratelimit-engine'))} ${dim('Live Dashboard')}` +
|
|
113
|
+
`${' '.repeat(18)}${dim('Refresh: 10ms')} ${this.paused ? yellow(bold('[PAUSED]')) : ''}`
|
|
114
|
+
)
|
|
115
|
+
lines.push(` ${dim('─'.repeat(W + 2))}`)
|
|
116
|
+
lines.push(
|
|
117
|
+
` Uptime: ${bold(fmtUptime(uptime))}` +
|
|
118
|
+
`${' '.repeat(10)}Window: last 200ms` +
|
|
119
|
+
`${' '.repeat(8)}${blocked > allowed ? red('⚠ HIGH BLOCK RATE') : green('● healthy')}`
|
|
120
|
+
)
|
|
121
|
+
lines.push('')
|
|
122
|
+
|
|
123
|
+
// ── Traffic panel ─────────────────────────────────────────────
|
|
124
|
+
lines.push(boxTop('Traffic — per 10ms bucket'))
|
|
125
|
+
lines.push(boxRow(`${pad('Total', 9)} ${bar(total, maxBar, 28, cyan)} ${bold(pad(total, 5))} req`))
|
|
126
|
+
lines.push(boxRow(`${pad('Allowed', 9)} ${bar(allowed, maxBar, 28, green)} ${bold(green(pad(allowed, 5)))} req`))
|
|
127
|
+
lines.push(boxRow(`${pad('Blocked', 9)} ${bar(blocked, maxBar, 28, red)} ${bold(red(pad(blocked, 5)))} req ${dim(`${blockPct}%`)}`))
|
|
128
|
+
lines.push(boxBot())
|
|
129
|
+
lines.push('')
|
|
130
|
+
|
|
131
|
+
// ── Sparkline panel ───────────────────────────────────────────
|
|
132
|
+
lines.push(boxTop('Sparkline — last 2s (200 buckets × 10ms)'))
|
|
133
|
+
lines.push(boxRow(cyan(sparkline(spark, W - 2))))
|
|
134
|
+
lines.push(boxBot())
|
|
135
|
+
lines.push('')
|
|
136
|
+
|
|
137
|
+
// ── Top IPs ───────────────────────────────────────────────────
|
|
138
|
+
lines.push(boxTop('Top IPs'))
|
|
139
|
+
if (topIPs.length === 0) {
|
|
140
|
+
lines.push(boxRow(dim('No traffic yet...')))
|
|
141
|
+
} else {
|
|
142
|
+
for (const entry of topIPs) {
|
|
143
|
+
const ipTotal = entry.allowed + entry.blocked
|
|
144
|
+
const label = entry.blocked > entry.allowed
|
|
145
|
+
? red('🔴 high block')
|
|
146
|
+
: green('🟢 normal ')
|
|
147
|
+
const ipBar = bar(entry.blocked, Math.max(ipTotal, 1), 12, red)
|
|
148
|
+
lines.push(boxRow(
|
|
149
|
+
`${pad(entry.ip, 16)} ` +
|
|
150
|
+
`${pad(ipTotal, 5)} req ` +
|
|
151
|
+
`${red(pad(entry.blocked, 4))} blocked ` +
|
|
152
|
+
`${ipBar} ${label}`
|
|
153
|
+
))
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
lines.push(boxBot())
|
|
157
|
+
lines.push('')
|
|
158
|
+
|
|
159
|
+
// ── Live feed ─────────────────────────────────────────────────
|
|
160
|
+
lines.push(boxTop('Live Feed'))
|
|
161
|
+
if (feed.length === 0) {
|
|
162
|
+
lines.push(boxRow(dim('Waiting for requests...')))
|
|
163
|
+
} else {
|
|
164
|
+
for (const ev of feed) {
|
|
165
|
+
const status = ev.allowed ? green('✅ ALLOWED') : red('❌ BLOCKED')
|
|
166
|
+
const ip = pad(ev.context?.ip ?? 'unknown', 15)
|
|
167
|
+
const method = pad(ev.context?.method ?? 'GET', 6)
|
|
168
|
+
const path = pad(ev.context?.path ?? '/', 20)
|
|
169
|
+
const time = dim(fmtTime(ev.ts))
|
|
170
|
+
lines.push(boxRow(`${time} ${status} ${ip} ${method} ${path}`))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
lines.push(boxBot())
|
|
174
|
+
lines.push('')
|
|
175
|
+
|
|
176
|
+
// ── Footer ────────────────────────────────────────────────────
|
|
177
|
+
lines.push(` ${dim('[q]')} quit ${dim('[r]')} reset counters ${dim('[p]')} pause/resume`)
|
|
178
|
+
lines.push('')
|
|
179
|
+
|
|
180
|
+
process.stdout.write(HIDE + CLEAR + lines.join('\n'))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
cleanup() {
|
|
184
|
+
process.stdout.write(SHOW + '\n')
|
|
185
|
+
}
|
|
186
|
+
}
|