@prsm/devtools 1.0.2 → 1.2.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.
@@ -4,11 +4,17 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>prsm devtools</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Mono:wght@400;700&display=swap"
11
+ rel="stylesheet"
12
+ />
7
13
  <style>
8
- body { margin: 0; background: #0a0a0a; }
14
+ body { margin: 0; background: #ffffff; }
9
15
  </style>
10
- <script type="module" crossorigin src="./assets/index-CkwmeR8W.js"></script>
11
- <link rel="stylesheet" crossorigin href="./assets/index-p_4czaPS.css">
16
+ <script type="module" crossorigin src="./assets/index-DWT8hGf_.js"></script>
17
+ <link rel="stylesheet" crossorigin href="./assets/index-B7_KPm6_.css">
12
18
  </head>
13
19
  <body>
14
20
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prsm/devtools",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "description": "Read-only Express middleware dashboard for observing @prsm infrastructure at runtime",
5
5
  "type": "module",
6
6
  "exports": {
@@ -31,11 +31,15 @@
31
31
  "express": "^4.21.0"
32
32
  },
33
33
  "devDependencies": {
34
- "@prsm/cron": "^1.0.3",
35
- "@prsm/limit": "^1.1.1",
34
+ "@prsm/cells": "^1.2.0",
35
+ "@prsm/cron": "^1.1.0",
36
+ "@prsm/limit": "^1.2.0",
37
+ "@prsm/lock": "^1.1.0",
36
38
  "@prsm/queue": "^3.0.8",
39
+ "@prsm/workflow": "^3.1.0",
37
40
  "cors": "^2.8.6",
38
- "dotenv": "^16.4.7"
41
+ "dotenv": "^16.4.7",
42
+ "pg": "^8.13.1"
39
43
  },
40
44
  "engines": {
41
45
  "node": ">=18"
package/src/index.js CHANGED
@@ -1,11 +1,17 @@
1
- import { Router, static as serveStatic } from 'express'
1
+ import { Router, static as serveStatic, json } from 'express'
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { resolve, dirname } from 'node:path'
4
- import { existsSync } from 'node:fs'
4
+ import { existsSync, readFileSync } from 'node:fs'
5
5
 
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url))
7
7
  const clientDir = resolve(__dirname, '..', 'dist', 'client')
8
8
 
9
+ function normalizeCellGraphs(cells) {
10
+ if (!cells) return null
11
+ if (typeof cells.cell === 'function') return { default: cells }
12
+ return cells
13
+ }
14
+
9
15
  function patternToString(p) {
10
16
  if (typeof p === 'string') return p
11
17
  if (p instanceof RegExp) return p.toString()
@@ -23,8 +29,10 @@ function patternToString(p) {
23
29
  */
24
30
  export function prsmDevtools(options = {}) {
25
31
  const router = Router()
26
- const { queue, cron, limit, workflow, realtime } = options
32
+ const { queue, cron, limit, workflow, realtime, lock } = options
33
+ const cellGraphs = normalizeCellGraphs(options.cells)
27
34
  const sseClients = new Set()
35
+ const jsonBody = json()
28
36
 
29
37
  function broadcast(event, data) {
30
38
  const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
@@ -44,6 +52,14 @@ export function prsmDevtools(options = {}) {
44
52
  cron.on('error', (data) => broadcast('cron:error', { name: data.name, error: data.error?.message }))
45
53
  }
46
54
 
55
+ if (cellGraphs) {
56
+ for (const [graphName, g] of Object.entries(cellGraphs)) {
57
+ g.on((name, value, state) => {
58
+ broadcast('cells:change', { graph: graphName, name, value, status: state.status, updatedAt: state.updatedAt })
59
+ })
60
+ }
61
+ }
62
+
47
63
  if (workflow) {
48
64
  for (const event of [
49
65
  'execution:queued',
@@ -68,6 +84,8 @@ export function prsmDevtools(options = {}) {
68
84
  limit: limit ? Object.keys(limit) : [],
69
85
  workflow: !!workflow,
70
86
  realtime: !!realtime,
87
+ cells: cellGraphs ? Object.keys(cellGraphs) : [],
88
+ lock: lock ? Object.keys(lock) : [],
71
89
  })
72
90
  })
73
91
 
@@ -95,6 +113,18 @@ export function prsmDevtools(options = {}) {
95
113
  }))
96
114
  res.json({ jobs })
97
115
  })
116
+
117
+ router.post('/api/cron/:name/run', async (req, res) => {
118
+ if (typeof cron.run !== 'function') {
119
+ return res.status(400).json({ error: 'This @prsm/cron version does not support manual runs' })
120
+ }
121
+ try {
122
+ const result = await cron.run(req.params.name)
123
+ res.json(result)
124
+ } catch (err) {
125
+ res.status(400).json({ error: err.message })
126
+ }
127
+ })
98
128
  }
99
129
 
100
130
  if (limit) {
@@ -102,13 +132,45 @@ export function prsmDevtools(options = {}) {
102
132
  res.json({ limiters: Object.keys(limit) })
103
133
  })
104
134
 
135
+ router.get('/api/limits/:name/keys', async (req, res) => {
136
+ const limiter = limit[req.params.name]
137
+ if (!limiter) return res.status(404).json({ error: 'Limiter not found' })
138
+ if (!limiter.keys) {
139
+ return res.status(400).json({ error: 'This @prsm/limit version does not support key listing' })
140
+ }
141
+ try {
142
+ const n = req.query.limit ? Number(req.query.limit) : undefined
143
+ const keys = await limiter.keys(n ? { limit: n } : undefined)
144
+ res.json({ keys })
145
+ } catch (err) {
146
+ res.status(500).json({ error: err.message })
147
+ }
148
+ })
149
+
105
150
  router.get('/api/limits/:name/peek/:key', async (req, res) => {
106
151
  const limiter = limit[req.params.name]
107
152
  if (!limiter) return res.status(404).json({ error: 'Limiter not found' })
108
153
  if (!limiter.peek) return res.status(400).json({ error: 'Limiter does not support peek' })
109
154
 
110
- const result = await limiter.peek(req.params.key)
111
- res.json(result)
155
+ try {
156
+ const result = await limiter.peek(req.params.key)
157
+ res.json(result)
158
+ } catch (err) {
159
+ res.status(500).json({ error: err.message })
160
+ }
161
+ })
162
+
163
+ router.post('/api/limits/:name/reset/:key', async (req, res) => {
164
+ const limiter = limit[req.params.name]
165
+ if (!limiter) return res.status(404).json({ error: 'Limiter not found' })
166
+ if (!limiter.reset) return res.status(400).json({ error: 'Limiter does not support reset' })
167
+
168
+ try {
169
+ await limiter.reset(req.params.key)
170
+ res.json({ ok: true })
171
+ } catch (err) {
172
+ res.status(500).json({ error: err.message })
173
+ }
112
174
  })
113
175
  }
114
176
 
@@ -152,6 +214,47 @@ export function prsmDevtools(options = {}) {
152
214
  if (!execution) return res.status(404).json({ error: 'Execution not found' })
153
215
  res.json({ execution })
154
216
  })
217
+
218
+ router.post('/api/workflow/start', jsonBody, async (req, res) => {
219
+ const { name, version, input } = req.body || {}
220
+ if (!name || typeof name !== 'string') {
221
+ return res.status(400).json({ error: 'name is required' })
222
+ }
223
+ try {
224
+ const execution = await workflow.start(name, input ?? {}, version ? { version } : undefined)
225
+ res.json({ id: execution.id })
226
+ } catch (err) {
227
+ res.status(400).json({ error: err.message })
228
+ }
229
+ })
230
+
231
+ router.post('/api/workflow/executions/:id/signal', jsonBody, async (req, res) => {
232
+ try {
233
+ const execution = await workflow.signal(req.params.id, req.body?.payload ?? {})
234
+ res.json({ execution })
235
+ } catch (err) {
236
+ const status = err.name === 'AlreadySignaledError' ? 409 : 400
237
+ res.status(status).json({ error: err.message })
238
+ }
239
+ })
240
+
241
+ router.post('/api/workflow/executions/:id/cancel', jsonBody, async (req, res) => {
242
+ try {
243
+ const execution = await workflow.cancel(req.params.id, req.body?.reason)
244
+ res.json({ execution })
245
+ } catch (err) {
246
+ res.status(400).json({ error: err.message })
247
+ }
248
+ })
249
+
250
+ router.post('/api/workflow/executions/:id/resume', async (req, res) => {
251
+ try {
252
+ const execution = await workflow.resume(req.params.id)
253
+ res.json({ execution })
254
+ } catch (err) {
255
+ res.status(400).json({ error: err.message })
256
+ }
257
+ })
155
258
  }
156
259
 
157
260
  if (realtime) {
@@ -326,10 +429,85 @@ export function prsmDevtools(options = {}) {
326
429
  })
327
430
  }
328
431
 
432
+ if (cellGraphs) {
433
+ router.get('/api/cells/:graph', (req, res) => {
434
+ const g = cellGraphs[req.params.graph]
435
+ if (!g) return res.status(404).json({ error: 'graph not found' })
436
+ const topology = g.cells()
437
+ const snapshot = g.snapshot()
438
+ const enriched = topology.map((c) => {
439
+ const state = g.get(c.name)
440
+ return {
441
+ ...c,
442
+ value: snapshot[c.name],
443
+ error: state?.error ? String(state.error.message || state.error) : null,
444
+ updatedAt: state?.updatedAt ?? null,
445
+ computeTime: state?.computeTime ?? null,
446
+ }
447
+ })
448
+ res.json({ graph: req.params.graph, cells: enriched })
449
+ })
450
+
451
+ router.get('/api/cells/:graph/:name/history', (req, res) => {
452
+ const g = cellGraphs[req.params.graph]
453
+ if (!g) return res.status(404).json({ error: 'graph not found' })
454
+ const limit = req.query.limit ? Number(req.query.limit) : undefined
455
+ try {
456
+ const entries = g.history(req.params.name, limit)
457
+ res.json({ graph: req.params.graph, name: req.params.name, entries })
458
+ } catch (err) {
459
+ res.status(500).json({ error: err.message })
460
+ }
461
+ })
462
+ }
463
+
464
+ if (lock) {
465
+ router.get('/api/locks', async (_req, res) => {
466
+ const managers = []
467
+ for (const [name, manager] of Object.entries(lock)) {
468
+ const kind = typeof manager.renew === 'function' ? 'semaphore' : 'mutex'
469
+ try {
470
+ managers.push({ name, kind, locks: await manager.list() })
471
+ } catch (err) {
472
+ managers.push({ name, kind, locks: [], error: err.message })
473
+ }
474
+ }
475
+ res.json({ managers })
476
+ })
477
+
478
+ router.get('/api/locks/:name', async (req, res) => {
479
+ const manager = lock[req.params.name]
480
+ if (!manager) return res.status(404).json({ error: 'Lock manager not found' })
481
+ try {
482
+ const kind = typeof manager.renew === 'function' ? 'semaphore' : 'mutex'
483
+ const locks = await manager.list()
484
+ res.json({ name: req.params.name, kind, locks })
485
+ } catch (err) {
486
+ res.status(500).json({ error: err.message })
487
+ }
488
+ })
489
+
490
+ router.post('/api/locks/:name/release', jsonBody, async (req, res) => {
491
+ const manager = lock[req.params.name]
492
+ if (!manager) return res.status(404).json({ error: 'Lock manager not found' })
493
+ const { key, id } = req.body || {}
494
+ if (!key || !id) return res.status(400).json({ error: 'key and id are required' })
495
+ try {
496
+ const released = await manager.release(key, id)
497
+ res.json({ released })
498
+ } catch (err) {
499
+ res.status(500).json({ error: err.message })
500
+ }
501
+ })
502
+ }
503
+
329
504
  if (existsSync(clientDir)) {
330
- router.use(serveStatic(clientDir))
331
- router.get('*', (_req, res) => {
332
- res.sendFile(resolve(clientDir, 'index.html'))
505
+ const indexPath = resolve(clientDir, 'index.html')
506
+ const indexHtml = readFileSync(indexPath, 'utf8')
507
+ router.use(serveStatic(clientDir, { index: false }))
508
+ router.get('*', (req, res) => {
509
+ const base = req.baseUrl ? `${req.baseUrl}/` : '/'
510
+ res.type('html').send(indexHtml.replace('<head>', `<head>\n <base href="${base}">`))
333
511
  })
334
512
  }
335
513