@prsm/devtools 1.0.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/LICENSE +21 -0
- package/README.md +134 -0
- package/dist/client/assets/index-CkwmeR8W.js +176 -0
- package/dist/client/assets/index-p_4czaPS.css +1 -0
- package/dist/client/index.html +16 -0
- package/package.json +43 -0
- package/src/index.js +339 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--bg: #0a0a0a;--bg-surface: #111111;--bg-raised: #1a1a1a;--bg-hover: #222222;--bg-active: #2a2a2a;--border: #2a2a2a;--border-subtle: #1e1e1e;--text: #d4d4d4;--text-bright: #e8e8e8;--text-muted: #555555;--accent: #34d399;--accent-dim: rgba(52, 211, 153, .12);--accent-text: #2dd4a2;--color-blue: #6ad;--color-red: #c55;--color-yellow: #b93;--color-green: #5a9;--syn-string: #a5d6a7;--syn-number: #4dd0e1;--syn-boolean: #ce93d8;--syn-null: #666666;--syn-key: #b0b0b0;--syn-bracket: #555555}*{box-sizing:border-box;margin:0;padding:0}body{font-family:SF Mono,Fira Code,JetBrains Mono,Cascadia Code,monospace;font-size:12px;line-height:1.5;color:var(--text);background:var(--bg);-webkit-font-smoothing:antialiased}::selection{background:var(--accent-dim);color:var(--accent-text)}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:#3a3a3a}.app{display:flex;flex-direction:column;height:100vh;overflow:hidden}.top-bar{display:flex;align-items:center;justify-content:space-between;padding:0 16px;height:40px;border-bottom:1px solid var(--border);background:var(--bg-surface);flex-shrink:0}.top-bar-left{display:flex;align-items:center;gap:12px}.logo{font-size:11px;font-weight:600;letter-spacing:.5px;text-transform:uppercase;color:var(--text-muted)}.logo span{color:var(--accent)}.top-bar-right{display:flex;align-items:center;gap:12px}.nav-bar{display:flex;align-items:center;gap:0;border-bottom:1px solid var(--border);background:var(--bg-surface);flex-shrink:0;padding:0 16px}.nav-bar a{padding:8px 16px;font-size:11px;color:var(--text-muted);cursor:pointer;border-bottom:2px solid transparent;-webkit-user-select:none;user-select:none;text-decoration:none}.nav-bar a:hover{color:var(--text)}.nav-bar a.active{color:var(--accent-text);border-bottom-color:var(--accent)}.page-scroll{flex:1;overflow-y:auto}.page-content{max-width:1100px;padding:20px 24px 40px}.tab-bar{display:flex;align-items:center;gap:0;border-bottom:1px solid var(--border);background:var(--bg-surface);flex-shrink:0;padding:0 16px}.tab{padding:8px 16px;font-size:11px;color:var(--text-muted);cursor:pointer;border-bottom:2px solid transparent;-webkit-user-select:none;user-select:none}.tab:hover{color:var(--text)}.tab.active{color:var(--accent-text);border-bottom-color:var(--accent)}.tab .count{margin-left:6px;font-size:10px;color:var(--text-muted)}.tab.active .count{color:var(--accent)}.sidebar{width:280px;min-width:280px;border-right:1px solid var(--border);overflow-y:auto;background:var(--bg-surface)}.content{flex:1;overflow-y:auto;padding:16px}.sidebar-section{border-bottom:1px solid var(--border-subtle)}.sidebar-header{padding:8px 12px;font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted);background:var(--bg)}.sidebar-item{display:flex;align-items:center;justify-content:space-between;padding:6px 12px;cursor:pointer;border-left:2px solid transparent}.sidebar-item:hover{background:var(--bg-hover)}.sidebar-item.active{background:var(--accent-dim);border-left-color:var(--accent)}.sidebar-item .label{font-size:11px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sidebar-item.active .label{color:var(--accent-text)}.sidebar-item .meta{font-size:10px;color:var(--text-muted);flex-shrink:0;margin-left:8px}.badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;font-size:10px;border-radius:9px;background:#1f1f1f;color:#999}.badge.accent{background:var(--accent-dim);color:var(--accent)}.section{margin-bottom:20px}.section-title{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted);margin-bottom:8px}.card{background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;overflow:hidden}.card+.card{margin-top:8px}.card-header{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:var(--bg);border-bottom:1px solid var(--border-subtle);font-size:11px}.card-header .name{color:var(--text-bright)}.card-body{padding:8px 12px}.kv-row{display:flex;align-items:baseline;padding:2px 0;font-size:11px}.kv-key{color:var(--text-muted);min-width:100px;flex-shrink:0}.kv-value{color:var(--text);word-break:break-all}.member-row{display:flex;align-items:center;justify-content:space-between;padding:4px 12px;font-size:11px;border-bottom:1px solid var(--border-subtle)}.member-row:last-child{border-bottom:none}.member-id{color:var(--text);font-size:11px}.member-presence{font-size:10px;color:var(--accent)}.tag{display:inline-block;padding:1px 6px;font-size:10px;border-radius:3px;background:#1f1f1f;color:#999;margin:1px 2px}.tag.accent{background:var(--accent-dim);color:var(--accent)}.empty{padding:24px;text-align:center;color:var(--text-muted);font-size:11px}.view-hint{font-size:11px;color:var(--text-muted);margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid var(--border-subtle)}.no-presence{font-size:10px;color:#333}.pattern-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px}.json-view{font-size:11px;line-height:1.6;white-space:pre-wrap;word-break:break-all}.json-view .shiki{background:transparent!important;padding:0;margin:0}.json-view .shiki code{font-family:inherit;font-size:inherit}.select{background:var(--bg-raised);border:1px solid var(--border);color:var(--text);font-family:inherit;font-size:11px;padding:4px 8px;border-radius:3px;outline:none;cursor:pointer}.select:focus{border-color:var(--accent)}.conn-link{cursor:pointer;text-decoration:underline;text-decoration-color:var(--border);text-underline-offset:2px}.conn-link:hover{color:var(--accent-text);text-decoration-color:var(--accent)}.exposed-row{display:flex;align-items:center;flex-wrap:wrap;gap:3px;padding:4px 12px;border-bottom:1px solid var(--border-subtle)}.exposed-row:last-child{border-bottom:none}.exposed-label{font-size:10px;color:var(--text-muted);min-width:70px;flex-shrink:0}.pulse{width:6px;height:6px;border-radius:50%;background:var(--accent)}.pulse.disconnected{background:#ef4444}.instance-id{font-size:10px;color:var(--text-muted)}section[data-v-1330d5ac]{margin-top:28px}.subsystems[data-v-1330d5ac]{display:flex;gap:8px;margin-bottom:20px}.chip[data-v-1330d5ac]{padding:5px 14px;font-size:11px;font-weight:500;color:var(--text-muted);background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;font-family:inherit;cursor:pointer}.chip.on[data-v-1330d5ac]{color:var(--text);border-color:#333}.chip.off[data-v-1330d5ac]{color:var(--text-muted);border-color:var(--border-subtle);opacity:.55}.chip[data-v-1330d5ac]:disabled{cursor:default;opacity:.35}.stream[data-v-1330d5ac]{background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:4px;max-height:520px;overflow-y:auto}.event-row[data-v-1330d5ac]{display:flex;gap:0;align-items:center;padding:0;border-bottom:1px solid var(--bg-raised);font-size:11px}.event-row[data-v-1330d5ac]:last-child{border-bottom:none}.event-source[data-v-1330d5ac]{width:72px;padding:6px 8px;color:var(--text-muted);text-align:right;border-right:1px solid var(--bg-raised);flex-shrink:0}.event-action[data-v-1330d5ac]{width:152px;padding:6px 8px;color:var(--text-muted);flex-shrink:0}.event-action.complete[data-v-1330d5ac],.event-action.fire[data-v-1330d5ac],.event-action.execution-succeeded[data-v-1330d5ac],.event-action.step-succeeded[data-v-1330d5ac]{color:var(--color-green)}.event-action.step-routed[data-v-1330d5ac]{color:var(--color-blue)}.event-action.failed[data-v-1330d5ac],.event-action.error[data-v-1330d5ac],.event-action.execution-failed[data-v-1330d5ac],.event-action.step-failed[data-v-1330d5ac],.event-action.execution-lease-lost[data-v-1330d5ac]{color:var(--color-red)}.event-action.retry[data-v-1330d5ac],.event-action.step-retry[data-v-1330d5ac]{color:var(--color-yellow)}.event-data[data-v-1330d5ac]{flex:1;min-width:0;padding:6px 8px;color:#666;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.event-time[data-v-1330d5ac]{width:80px;padding:6px 8px;color:var(--text-muted);text-align:right;flex-shrink:0}.gauges[data-v-74a2dfa0]{display:flex;gap:12px;margin-bottom:8px}.gauge[data-v-74a2dfa0]{flex:1;padding:16px;background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:4px}.gauge-value[data-v-74a2dfa0]{display:block;font-size:28px;font-weight:600;color:var(--text-muted)}.gauge-value.lit[data-v-74a2dfa0]{color:var(--color-blue)}.gauge-value.warn[data-v-74a2dfa0]{color:var(--color-red)}.gauge-label[data-v-74a2dfa0]{display:block;font-size:10px;color:var(--text-muted);margin-top:4px;letter-spacing:.3px;text-transform:uppercase}.session-note[data-v-74a2dfa0]{font-size:10px;color:#333;margin-bottom:24px}section[data-v-74a2dfa0]{margin-top:24px}.stream[data-v-74a2dfa0]{background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:4px;max-height:400px;overflow-y:auto}.event-row[data-v-74a2dfa0]{display:flex;gap:0;align-items:center;border-bottom:1px solid var(--bg-raised);font-size:11px}.event-row[data-v-74a2dfa0]:last-child{border-bottom:none}.event-action[data-v-74a2dfa0]{width:72px;padding:6px 10px;color:var(--text-muted);flex-shrink:0}.event-action.complete[data-v-74a2dfa0]{color:var(--color-green)}.event-action.failed[data-v-74a2dfa0]{color:var(--color-red)}.event-action.retry[data-v-74a2dfa0]{color:var(--color-yellow)}.event-action.new[data-v-74a2dfa0]{color:var(--text-muted)}.event-action.drain[data-v-74a2dfa0]{color:#666}.event-data[data-v-74a2dfa0]{flex:1;padding:6px 8px;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.event-time[data-v-74a2dfa0]{width:80px;padding:6px 8px;color:#333;text-align:right;flex-shrink:0}.job-row[data-v-6d545d29]{display:flex;align-items:center;padding:0;border-bottom:1px solid var(--border-subtle);font-size:11px}.job-row[data-v-6d545d29]:last-child{border-bottom:none}.job-row.header[data-v-6d545d29]{font-size:10px;color:var(--text-muted);letter-spacing:.3px;text-transform:uppercase;border-bottom:1px solid var(--border)}.job-row.header span[data-v-6d545d29]{padding:8px 12px}.col-name[data-v-6d545d29]{flex:1;padding:10px 12px}.col-next[data-v-6d545d29]{width:100px;padding:10px 12px;color:#666;text-align:right}.col-in[data-v-6d545d29]{width:60px;padding:10px 12px;color:var(--text-muted);text-align:right}.col-in.soon[data-v-6d545d29]{color:var(--color-blue)}.stream[data-v-6d545d29]{background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:4px;max-height:400px;overflow-y:auto}.event-row[data-v-6d545d29]{display:flex;align-items:center;border-bottom:1px solid var(--bg-raised);font-size:11px}.event-row[data-v-6d545d29]:last-child{border-bottom:none}.event-action[data-v-6d545d29]{width:52px;padding:6px 10px;color:var(--text-muted);flex-shrink:0}.event-action.fire[data-v-6d545d29]{color:var(--color-green)}.event-action.error[data-v-6d545d29]{color:var(--color-red)}.event-name[data-v-6d545d29]{flex:1;padding:6px 8px;color:#666}.event-time[data-v-6d545d29]{width:80px;padding:6px 8px;color:#333;text-align:right;flex-shrink:0}.limiter-list[data-v-22c855ec]{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:24px}section[data-v-22c855ec]{margin-top:24px}.desc[data-v-22c855ec]{font-size:11px;color:var(--text-muted);margin-bottom:12px}.peek-form[data-v-22c855ec]{display:flex;gap:8px}.input[data-v-22c855ec]{padding:6px 10px;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;font-size:11px;color:var(--text);flex:1;font-family:inherit;outline:none}.input[data-v-22c855ec]::placeholder{color:#333}.input[data-v-22c855ec]:focus{border-color:var(--accent)}.btn[data-v-22c855ec]{padding:6px 16px;background:var(--bg-raised);color:#999;border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:11px;font-family:inherit}.btn[data-v-22c855ec]:hover{color:var(--text)}.btn[data-v-22c855ec]:disabled{opacity:.3;cursor:default}.error[data-v-22c855ec]{margin-top:10px;color:var(--color-red);font-size:11px}.graph-shell[data-v-6098c89d]{background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:4px;overflow:hidden;position:relative;cursor:grab;touch-action:none}.graph-shell[data-v-6098c89d]:active{cursor:grabbing}.graph-shell svg[data-v-6098c89d]{user-select:none;-webkit-user-select:none}.graph-shell.fullscreen[data-v-6098c89d]{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1000;border-radius:0;border:none;background:var(--bg)}.graph-controls[data-v-6098c89d]{position:absolute;top:10px;right:10px;z-index:2;display:flex;gap:4px}.graph-controls button[data-v-6098c89d]{padding:4px 10px;background:var(--bg-raised);border:1px solid var(--border);border-radius:4px;color:#888;font-size:11px;font-family:inherit;cursor:pointer}.graph-controls button[data-v-6098c89d]:hover{background:var(--bg-hover);color:#ccc}.graph[data-v-6098c89d]{width:100%;min-height:420px;display:block}.fullscreen .graph[data-v-6098c89d]{min-height:100vh}.edge[data-v-6098c89d]{fill:none;stroke:#232323;stroke-width:3}.edge-seen[data-v-6098c89d]{stroke:#3b3b3b}.edge-active[data-v-6098c89d]{stroke:#8fb3ff}.edge-label[data-v-6098c89d]{fill:#4a4a4a;font-size:11px;font-family:inherit}.node[data-v-6098c89d]{cursor:pointer}.node rect[data-v-6098c89d]{fill:#151515;stroke:#262626;stroke-width:1.5}.node.selected rect[data-v-6098c89d]{stroke:#6b7280}.node.current rect[data-v-6098c89d],.node.running rect[data-v-6098c89d]{stroke:#5b8cff}.node.succeeded rect[data-v-6098c89d],.node.decision-success rect[data-v-6098c89d]{stroke:#3d8f63}.node.failed rect[data-v-6098c89d]{stroke:#a14a4a}.node.activity .node-type[data-v-6098c89d]{fill:#7aa2f7}.node.decision .node-type[data-v-6098c89d]{fill:#e0af68}.node.succeed .node-type[data-v-6098c89d]{fill:#73daca}.node.fail .node-type[data-v-6098c89d]{fill:#f7768e}.node-name[data-v-6098c89d]{fill:var(--text-bright);font-size:13px;font-weight:600}.node-type[data-v-6098c89d],.node-meta[data-v-6098c89d]{font-family:inherit;font-size:11px}.node-meta[data-v-6098c89d]{fill:#5f5f5f}.workflow-layout[data-v-124a0c49]{display:grid;grid-template-columns:280px 1fr;gap:16px}.wf-sidebar[data-v-124a0c49]{display:flex;flex-direction:column;gap:8px}.wf-sidebar .card.active[data-v-124a0c49]{border-color:var(--accent)}.detail[data-v-124a0c49]{display:flex;flex-direction:column;gap:16px}.headline[data-v-124a0c49]{display:flex;justify-content:space-between;align-items:flex-end}.headline-name[data-v-124a0c49]{font-size:14px;font-weight:600;color:var(--text-bright)}.headline-sub[data-v-124a0c49]{margin-top:2px;color:var(--text-muted);font-size:11px}.headline-stats[data-v-124a0c49]{display:flex;gap:10px;color:var(--text-muted);font-size:11px}.filters[data-v-24631062]{display:flex;gap:8px;margin-bottom:16px}.execution-layout[data-v-24631062]{display:grid;grid-template-columns:240px minmax(0,1fr) 340px;gap:14px;align-items:start}.list[data-v-24631062]{background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:4px;overflow:hidden;max-height:calc(100vh - 240px);overflow-y:auto}.execution-row[data-v-24631062]{padding:10px 12px;border-bottom:1px solid var(--bg-raised);cursor:pointer}.execution-row[data-v-24631062]:last-child{border-bottom:none}.execution-row.active[data-v-24631062]{background:var(--bg-raised)}.execution-main[data-v-24631062]{display:flex;justify-content:space-between;gap:8px}.execution-workflow[data-v-24631062]{color:var(--text-bright);font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.execution-id[data-v-24631062]{color:#666;font-size:11px;flex-shrink:0}.execution-meta[data-v-24631062]{display:flex;justify-content:space-between;gap:6px;margin-top:4px;color:var(--text-muted);font-size:10px}.execution-row.running .execution-workflow[data-v-24631062]{color:#8fb3ff}.execution-row.failed .execution-workflow[data-v-24631062]{color:#d87a7a}.execution-row.succeeded .execution-workflow[data-v-24631062]{color:#7ec49b}.headline[data-v-24631062]{margin-bottom:14px}.headline-name[data-v-24631062]{font-size:14px;font-weight:600;color:var(--text-bright)}.headline-sub[data-v-24631062]{margin-top:2px;color:var(--text-muted);font-size:11px}.center[data-v-24631062]{display:flex;flex-direction:column;gap:12px;min-width:0}.inspector[data-v-24631062]{display:flex;flex-direction:column;gap:10px;max-height:calc(100vh - 240px);overflow-y:auto}.step-hint[data-v-24631062]{color:#383838;font-size:10px;margin-bottom:8px;font-style:italic}.timeline[data-v-24631062]{max-height:420px;overflow-y:auto}.timeline-row[data-v-24631062]{display:flex;gap:6px;align-items:baseline;border-bottom:1px solid var(--border-subtle);padding:6px 12px;font-size:10px;white-space:nowrap}.timeline-row[data-v-24631062]:last-child{border-bottom:none}.timeline-type[data-v-24631062]{color:#b1b1b1;flex-shrink:0}.timeline-step[data-v-24631062]{color:#666;flex:1;overflow:hidden;text-overflow:ellipsis}.timeline-time[data-v-24631062]{color:var(--text-muted);flex-shrink:0}.realtime-shell[data-v-d6b8bed2]{display:flex;flex-direction:column;flex:1;overflow:hidden}.rt-main[data-v-d6b8bed2]{display:flex;flex:1;overflow:hidden}.tab-bar-right[data-v-d6b8bed2]{margin-left:auto;display:flex;align-items:center;gap:8px}.tab-bar a[data-v-d6b8bed2]{text-decoration:none}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>prsm devtools</title>
|
|
7
|
+
<style>
|
|
8
|
+
body { margin: 0; background: #0a0a0a; }
|
|
9
|
+
</style>
|
|
10
|
+
<script type="module" crossorigin src="./assets/index-CkwmeR8W.js"></script>
|
|
11
|
+
<link rel="stylesheet" crossorigin href="./assets/index-p_4czaPS.css">
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="app"></div>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@prsm/devtools",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Read-only Express middleware dashboard for observing @prsm infrastructure at runtime",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"default": "./src/index.js"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"dist/client"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build:client": "cd client && npm run build",
|
|
17
|
+
"prepublishOnly": "npm run build:client"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"devtools",
|
|
21
|
+
"dashboard",
|
|
22
|
+
"queue",
|
|
23
|
+
"cron",
|
|
24
|
+
"rate-limit",
|
|
25
|
+
"realtime",
|
|
26
|
+
"websocket",
|
|
27
|
+
"observability"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"express": "^4.21.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@prsm/cron": "^1.0.0",
|
|
35
|
+
"@prsm/limit": "^1.1.0",
|
|
36
|
+
"@prsm/queue": "^2.0.0",
|
|
37
|
+
"cors": "^2.8.6",
|
|
38
|
+
"dotenv": "^16.4.7"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { Router, static as serveStatic } from 'express'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { resolve, dirname } from 'node:path'
|
|
4
|
+
import { existsSync } from 'node:fs'
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
const clientDir = resolve(__dirname, '..', 'dist', 'client')
|
|
8
|
+
|
|
9
|
+
function patternToString(p) {
|
|
10
|
+
if (typeof p === 'string') return p
|
|
11
|
+
if (p instanceof RegExp) return p.toString()
|
|
12
|
+
return String(p)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {Object} options
|
|
17
|
+
* @param {import('@prsm/queue').default} [options.queue]
|
|
18
|
+
* @param {import('@prsm/cron').Cron} [options.cron]
|
|
19
|
+
* @param {Object<string, Object>} [options.limit] - named limiters
|
|
20
|
+
* @param {import('@prsm/workflow').WorkflowEngine} [options.workflow]
|
|
21
|
+
* @param {Object} [options.realtime] - RealtimeServer instance
|
|
22
|
+
* @returns {import('express').Router}
|
|
23
|
+
*/
|
|
24
|
+
export function prsmDevtools(options = {}) {
|
|
25
|
+
const router = Router()
|
|
26
|
+
const { queue, cron, limit, workflow, realtime } = options
|
|
27
|
+
const sseClients = new Set()
|
|
28
|
+
|
|
29
|
+
function broadcast(event, data) {
|
|
30
|
+
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
|
31
|
+
for (const res of sseClients) {
|
|
32
|
+
res.write(msg)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (queue) {
|
|
37
|
+
for (const event of ['new', 'complete', 'retry', 'failed', 'drain']) {
|
|
38
|
+
queue.on(event, (data) => broadcast(`queue:${event}`, data ?? {}))
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (cron) {
|
|
43
|
+
cron.on('fire', (data) => broadcast('cron:fire', { name: data.name, tickId: data.tickId }))
|
|
44
|
+
cron.on('error', (data) => broadcast('cron:error', { name: data.name, error: data.error?.message }))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (workflow) {
|
|
48
|
+
for (const event of [
|
|
49
|
+
'execution:queued',
|
|
50
|
+
'execution:succeeded',
|
|
51
|
+
'execution:failed',
|
|
52
|
+
'execution:canceled',
|
|
53
|
+
'execution:lease-lost',
|
|
54
|
+
'step:started',
|
|
55
|
+
'step:succeeded',
|
|
56
|
+
'step:routed',
|
|
57
|
+
'step:retry',
|
|
58
|
+
'step:failed',
|
|
59
|
+
]) {
|
|
60
|
+
workflow.on(event, (data) => broadcast(`workflow:${event}`, data ?? {}))
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
router.get('/api/config', (_req, res) => {
|
|
65
|
+
res.json({
|
|
66
|
+
queue: !!queue,
|
|
67
|
+
cron: !!cron,
|
|
68
|
+
limit: limit ? Object.keys(limit) : [],
|
|
69
|
+
workflow: !!workflow,
|
|
70
|
+
realtime: !!realtime,
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
router.get('/api/events', (req, res) => {
|
|
75
|
+
res.setHeader('Content-Type', 'text/event-stream')
|
|
76
|
+
res.setHeader('Cache-Control', 'no-cache')
|
|
77
|
+
res.setHeader('Connection', 'keep-alive')
|
|
78
|
+
res.flushHeaders()
|
|
79
|
+
|
|
80
|
+
sseClients.add(res)
|
|
81
|
+
req.on('close', () => sseClients.delete(res))
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
if (queue) {
|
|
85
|
+
router.get('/api/queue', (_req, res) => {
|
|
86
|
+
res.json({ inFlight: queue.inFlight })
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (cron) {
|
|
91
|
+
router.get('/api/cron', (_req, res) => {
|
|
92
|
+
const jobs = cron.jobs.map((name) => ({
|
|
93
|
+
name,
|
|
94
|
+
nextFireTime: cron.nextFireTime(name),
|
|
95
|
+
}))
|
|
96
|
+
res.json({ jobs })
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (limit) {
|
|
101
|
+
router.get('/api/limits', (_req, res) => {
|
|
102
|
+
res.json({ limiters: Object.keys(limit) })
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
router.get('/api/limits/:name/peek/:key', async (req, res) => {
|
|
106
|
+
const limiter = limit[req.params.name]
|
|
107
|
+
if (!limiter) return res.status(404).json({ error: 'Limiter not found' })
|
|
108
|
+
if (!limiter.peek) return res.status(400).json({ error: 'Limiter does not support peek' })
|
|
109
|
+
|
|
110
|
+
const result = await limiter.peek(req.params.key)
|
|
111
|
+
res.json(result)
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (workflow) {
|
|
116
|
+
router.get('/api/workflows', (_req, res) => {
|
|
117
|
+
const workflows = workflow.listWorkflows().map((item) => ({
|
|
118
|
+
...item,
|
|
119
|
+
graph: workflow.describe(item.name, item.version).graph,
|
|
120
|
+
}))
|
|
121
|
+
res.json({ workflows })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
router.get('/api/workflows/describe', (req, res) => {
|
|
125
|
+
const name = req.query.name
|
|
126
|
+
const version = req.query.version
|
|
127
|
+
|
|
128
|
+
if (!name || typeof name !== 'string') {
|
|
129
|
+
return res.status(400).json({ error: 'name is required' })
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const data = workflow.describe(name, typeof version === 'string' ? version : undefined)
|
|
134
|
+
res.json(data)
|
|
135
|
+
} catch (error) {
|
|
136
|
+
res.status(404).json({ error: error.message })
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
router.get('/api/workflow/executions', async (req, res) => {
|
|
141
|
+
const filter = {}
|
|
142
|
+
if (typeof req.query.workflow === 'string' && req.query.workflow) filter.workflow = req.query.workflow
|
|
143
|
+
if (typeof req.query.status === 'string' && req.query.status) filter.status = req.query.status
|
|
144
|
+
if (typeof req.query.limit === 'string' && req.query.limit) filter.limit = Number(req.query.limit)
|
|
145
|
+
|
|
146
|
+
const executions = await workflow.listExecutions(filter)
|
|
147
|
+
res.json({ executions })
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
router.get('/api/workflow/executions/:id', async (req, res) => {
|
|
151
|
+
const execution = await workflow.getExecution(req.params.id)
|
|
152
|
+
if (!execution) return res.status(404).json({ error: 'Execution not found' })
|
|
153
|
+
res.json({ execution })
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (realtime) {
|
|
158
|
+
realtime.onRecordUpdate(({ recordId, value }) => {
|
|
159
|
+
broadcast('realtime:record:update', { recordId, data: value })
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
realtime.onRecordRemoved(({ recordId }) => {
|
|
163
|
+
broadcast('realtime:record:removed', { recordId })
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
router.get('/api/realtime/state', async (_req, res) => {
|
|
167
|
+
try {
|
|
168
|
+
const connIds = await realtime.connectionManager.getAllConnectionIds()
|
|
169
|
+
const allMeta = await realtime.connectionManager._getMetadataForConnectionIds(connIds)
|
|
170
|
+
const localConns = realtime.connectionManager.getLocalConnections()
|
|
171
|
+
const localMap = {}
|
|
172
|
+
for (const conn of localConns) {
|
|
173
|
+
localMap[conn.id] = conn
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const connections = allMeta.map(({ id, metadata }) => {
|
|
177
|
+
const local = localMap[id]
|
|
178
|
+
return {
|
|
179
|
+
id,
|
|
180
|
+
metadata,
|
|
181
|
+
local: !!local,
|
|
182
|
+
latency: local?.latency?.ms ?? null,
|
|
183
|
+
alive: local?.alive ?? null,
|
|
184
|
+
remoteAddress: local?.remoteAddress ?? null,
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
const roomNames = await realtime.roomManager.getAllRooms()
|
|
189
|
+
const rooms = []
|
|
190
|
+
for (const name of roomNames) {
|
|
191
|
+
const members = await realtime.roomManager.getRoomConnectionIds(name)
|
|
192
|
+
const statesMap = await realtime.presenceManager.getAllPresenceStates(name)
|
|
193
|
+
const presence = {}
|
|
194
|
+
statesMap.forEach((state, connId) => { presence[connId] = state })
|
|
195
|
+
rooms.push({ name, members, presence })
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const channels = {}
|
|
199
|
+
for (const [channel, subscribers] of Object.entries(realtime.channelManager.channelSubscriptions)) {
|
|
200
|
+
if (channel.startsWith('mesh:presence:updates:')) continue
|
|
201
|
+
channels[channel] = [...subscribers].map((c) => c.id)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const collections = {}
|
|
205
|
+
realtime.collectionManager.collectionSubscriptions.forEach((subs, collId) => {
|
|
206
|
+
const subscribers = {}
|
|
207
|
+
subs.forEach((info, connId) => { subscribers[connId] = info })
|
|
208
|
+
collections[collId] = { subscribers }
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
const records = {}
|
|
212
|
+
realtime.recordSubscriptionManager.recordSubscriptions.forEach((subs, recordId) => {
|
|
213
|
+
const subscribers = {}
|
|
214
|
+
subs.forEach((mode, connId) => { subscribers[connId] = mode })
|
|
215
|
+
records[recordId] = { subscribers }
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
const exposed = {
|
|
219
|
+
channels: realtime.channelManager.exposedChannels.map(patternToString),
|
|
220
|
+
records: realtime.recordSubscriptionManager.exposedRecords.map(patternToString),
|
|
221
|
+
writableRecords: realtime.recordSubscriptionManager.exposedWritableRecords.map(patternToString),
|
|
222
|
+
collections: realtime.collectionManager.exposedCollections.map((e) => patternToString(e.pattern)),
|
|
223
|
+
presence: realtime.presenceManager.trackedRooms.map(patternToString),
|
|
224
|
+
commands: realtime.commandManager.commands
|
|
225
|
+
? Object.keys(realtime.commandManager.commands).filter((c) => !c.startsWith('mesh/'))
|
|
226
|
+
: [],
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
res.json({ instanceId: realtime.instanceId, connections, rooms, channels, collections, records, exposed })
|
|
230
|
+
} catch (err) {
|
|
231
|
+
res.status(500).json({ error: err.message })
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
router.get('/api/realtime/connection/:id', async (req, res) => {
|
|
236
|
+
try {
|
|
237
|
+
const { id } = req.params
|
|
238
|
+
const metadata = await realtime.connectionManager.getMetadata(id)
|
|
239
|
+
const rooms = await realtime.roomManager.getRoomsForConnection(id)
|
|
240
|
+
|
|
241
|
+
const presence = {}
|
|
242
|
+
for (const room of rooms) {
|
|
243
|
+
const state = await realtime.presenceManager.getPresenceState(id, room)
|
|
244
|
+
if (state) presence[room] = state
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const channels = []
|
|
248
|
+
for (const [channel, subscribers] of Object.entries(realtime.channelManager.channelSubscriptions)) {
|
|
249
|
+
if (channel.startsWith('mesh:presence:updates:')) continue
|
|
250
|
+
for (const conn of subscribers) {
|
|
251
|
+
if (conn.id === id) { channels.push(channel); break }
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const collections = []
|
|
256
|
+
realtime.collectionManager.collectionSubscriptions.forEach((subs, collId) => {
|
|
257
|
+
if (subs.has(id)) collections.push({ id: collId, ...subs.get(id) })
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
const records = []
|
|
261
|
+
realtime.recordSubscriptionManager.recordSubscriptions.forEach((subs, recordId) => {
|
|
262
|
+
if (subs.has(id)) records.push({ id: recordId, mode: subs.get(id) })
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
const local = realtime.connectionManager.getLocalConnection(id)
|
|
266
|
+
|
|
267
|
+
res.json({
|
|
268
|
+
id,
|
|
269
|
+
metadata,
|
|
270
|
+
rooms,
|
|
271
|
+
presence,
|
|
272
|
+
channels,
|
|
273
|
+
collections,
|
|
274
|
+
records,
|
|
275
|
+
local: !!local,
|
|
276
|
+
latency: local?.latency?.ms ?? null,
|
|
277
|
+
alive: local?.alive ?? null,
|
|
278
|
+
remoteAddress: local?.remoteAddress ?? null,
|
|
279
|
+
})
|
|
280
|
+
} catch (err) {
|
|
281
|
+
res.status(500).json({ error: err.message })
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
router.get('/api/realtime/room/:name', async (req, res) => {
|
|
286
|
+
try {
|
|
287
|
+
const { name } = req.params
|
|
288
|
+
const membersWithMeta = await realtime.getRoomMembersWithMetadata(name)
|
|
289
|
+
const statesMap = await realtime.presenceManager.getAllPresenceStates(name)
|
|
290
|
+
const presence = {}
|
|
291
|
+
statesMap.forEach((state, connId) => { presence[connId] = state })
|
|
292
|
+
res.json({ name, members: membersWithMeta, presence })
|
|
293
|
+
} catch (err) {
|
|
294
|
+
res.status(500).json({ error: err.message })
|
|
295
|
+
}
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
router.get('/api/realtime/record/:id', async (req, res) => {
|
|
299
|
+
try {
|
|
300
|
+
const data = await realtime.recordManager.getRecord(req.params.id)
|
|
301
|
+
res.json({ id: req.params.id, data })
|
|
302
|
+
} catch (err) {
|
|
303
|
+
res.status(500).json({ error: err.message })
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
router.get('/api/realtime/collection/:id/records', async (req, res) => {
|
|
308
|
+
try {
|
|
309
|
+
const collId = req.params.id
|
|
310
|
+
const connId = req.query.connId
|
|
311
|
+
if (!connId) return res.status(400).json({ error: 'connId query param required' })
|
|
312
|
+
|
|
313
|
+
const raw = await realtime.redisManager.redis.get(`mesh:collection:${collId}:${connId}`)
|
|
314
|
+
if (!raw) return res.json({ recordIds: [], records: [] })
|
|
315
|
+
|
|
316
|
+
const recordIds = JSON.parse(raw)
|
|
317
|
+
const records = []
|
|
318
|
+
for (const rid of recordIds) {
|
|
319
|
+
const data = await realtime.recordManager.getRecord(rid)
|
|
320
|
+
records.push({ id: rid, data })
|
|
321
|
+
}
|
|
322
|
+
res.json({ recordIds, records })
|
|
323
|
+
} catch (err) {
|
|
324
|
+
res.status(500).json({ error: err.message })
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (existsSync(clientDir)) {
|
|
330
|
+
router.use(serveStatic(clientDir))
|
|
331
|
+
router.get('*', (_req, res) => {
|
|
332
|
+
res.sendFile(resolve(clientDir, 'index.html'))
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return router
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export default prsmDevtools
|