@synkro/ui 0.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/LICENSE +15 -0
- package/README.md +103 -0
- package/dist/dashboard.d.ts +2 -0
- package/dist/dashboard.d.ts.map +1 -0
- package/dist/dashboard.js +920 -0
- package/dist/dashboard.js.map +1 -0
- package/dist/handler.d.ts +7 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +46 -0
- package/dist/handler.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/package.json +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 buemura
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# @synkro/ui
|
|
2
|
+
|
|
3
|
+
Dashboard UI for [@synkro/core](https://github.com/buemura/synkro). Mount it on any HTTP endpoint to visualize your registered events and workflows in real time.
|
|
4
|
+
|
|
5
|
+
## Screenshots
|
|
6
|
+
|
|
7
|
+
### Dashboard
|
|
8
|
+
|
|
9
|
+
Overview of all registered events and workflows with paginated tables.
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
### Event Detail
|
|
14
|
+
|
|
15
|
+
Click any event to view its message metrics — received, completed, and failed counts tracked via Redis.
|
|
16
|
+
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
### Workflow Detail
|
|
20
|
+
|
|
21
|
+
Click any workflow to see a branching flow diagram with SVG connectors (green for success, red for failure) and a detailed steps table.
|
|
22
|
+
|
|
23
|
+

|
|
24
|
+
|
|
25
|
+
## Features
|
|
26
|
+
|
|
27
|
+
- **Events overview** — Paginated table of all standalone events with retry configuration
|
|
28
|
+
- **Event detail** — Click an event to see received/completed/failed message counts (Redis-persisted)
|
|
29
|
+
- **Workflows overview** — Paginated table of all workflows with step counts and callback badges
|
|
30
|
+
- **Workflow detail** — Branching flow diagram with SVG bezier curves for onSuccess/onFailure paths, plus a detailed steps table
|
|
31
|
+
- **Stats at a glance** — Total counts for events, workflows, and workflow steps
|
|
32
|
+
- **Pagination** — 5 items per page with independent pagination for events and workflows
|
|
33
|
+
- **Framework-agnostic** — Works with Express, Fastify, raw Node.js HTTP, or any framework that supports `(IncomingMessage, ServerResponse)` handlers
|
|
34
|
+
- **Zero dependencies** — Self-contained HTML/CSS/JS dashboard with no external assets
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install @synkro/ui
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
import { createDashboardHandler } from "@synkro/ui";
|
|
46
|
+
import { Synkro } from "@synkro/core";
|
|
47
|
+
|
|
48
|
+
const synkro = await Synkro.start({
|
|
49
|
+
transport: "redis",
|
|
50
|
+
connectionUrl: "redis://localhost:6379",
|
|
51
|
+
events: [/* ... */],
|
|
52
|
+
workflows: [/* ... */],
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Express
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
import express from "express";
|
|
60
|
+
|
|
61
|
+
const app = express();
|
|
62
|
+
|
|
63
|
+
app.use("/synkro", createDashboardHandler(synkro, { basePath: "/synkro" }));
|
|
64
|
+
|
|
65
|
+
app.listen(3000);
|
|
66
|
+
// Dashboard available at http://localhost:3000/synkro
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Raw Node.js HTTP
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
import http from "node:http";
|
|
73
|
+
|
|
74
|
+
const server = http.createServer(createDashboardHandler(synkro));
|
|
75
|
+
|
|
76
|
+
server.listen(3000);
|
|
77
|
+
// Dashboard available at http://localhost:3000
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## API
|
|
81
|
+
|
|
82
|
+
### `createDashboardHandler(synkro, options?)`
|
|
83
|
+
|
|
84
|
+
Returns a standard Node.js HTTP request handler `(IncomingMessage, ServerResponse) => void`.
|
|
85
|
+
|
|
86
|
+
**Parameters:**
|
|
87
|
+
|
|
88
|
+
| Parameter | Type | Description |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `synkro` | `Synkro` | A started Synkro instance |
|
|
91
|
+
| `options.basePath` | `string` | Base path where the dashboard is mounted (default: `"/"`) |
|
|
92
|
+
|
|
93
|
+
**Served routes (relative to basePath):**
|
|
94
|
+
|
|
95
|
+
| Route | Description |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `GET /` | Dashboard HTML page |
|
|
98
|
+
| `GET /api/introspection` | JSON payload with all registered events and workflows |
|
|
99
|
+
| `GET /api/events/:type` | JSON payload with message metrics for a specific event |
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
ISC
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dashboard.d.ts","sourceRoot":"","sources":["../src/dashboard.ts"],"names":[],"mappings":"AAAA,wBAAgB,gBAAgB,IAAI,MAAM,CAs5BzC"}
|
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
export function getDashboardHtml() {
|
|
2
|
+
return `<!DOCTYPE html>
|
|
3
|
+
<html lang="en">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
+
<title>Synkro Dashboard</title>
|
|
8
|
+
<style>
|
|
9
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
10
|
+
|
|
11
|
+
:root {
|
|
12
|
+
--bg: #0a0a0f;
|
|
13
|
+
--surface: #12121a;
|
|
14
|
+
--surface-hover: #1a1a25;
|
|
15
|
+
--border: #1e1e2e;
|
|
16
|
+
--text: #e2e2e8;
|
|
17
|
+
--text-muted: #6e6e82;
|
|
18
|
+
--accent: #7c6af6;
|
|
19
|
+
--accent-dim: #7c6af620;
|
|
20
|
+
--success: #34d399;
|
|
21
|
+
--success-dim: #34d39920;
|
|
22
|
+
--danger: #f87171;
|
|
23
|
+
--danger-dim: #f8717120;
|
|
24
|
+
--warning: #fbbf24;
|
|
25
|
+
--warning-dim: #fbbf2420;
|
|
26
|
+
--radius: 10px;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
body {
|
|
30
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
31
|
+
background: var(--bg);
|
|
32
|
+
color: var(--text);
|
|
33
|
+
line-height: 1.5;
|
|
34
|
+
min-height: 100vh;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.container {
|
|
38
|
+
max-width: 1200px;
|
|
39
|
+
margin: 0 auto;
|
|
40
|
+
padding: 32px 24px;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
header {
|
|
44
|
+
display: flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
justify-content: space-between;
|
|
47
|
+
margin-bottom: 40px;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.logo {
|
|
51
|
+
display: flex;
|
|
52
|
+
align-items: center;
|
|
53
|
+
gap: 12px;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.logo-icon {
|
|
57
|
+
width: 36px;
|
|
58
|
+
height: 36px;
|
|
59
|
+
background: var(--accent);
|
|
60
|
+
border-radius: 8px;
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
justify-content: center;
|
|
64
|
+
font-weight: 700;
|
|
65
|
+
font-size: 16px;
|
|
66
|
+
color: #fff;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.logo h1 {
|
|
70
|
+
font-size: 22px;
|
|
71
|
+
font-weight: 600;
|
|
72
|
+
letter-spacing: -0.5px;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.logo h1 span { color: var(--text-muted); font-weight: 400; font-size: 14px; margin-left: 8px; }
|
|
76
|
+
|
|
77
|
+
.btn {
|
|
78
|
+
background: var(--surface);
|
|
79
|
+
border: 1px solid var(--border);
|
|
80
|
+
color: var(--text);
|
|
81
|
+
padding: 8px 16px;
|
|
82
|
+
border-radius: 8px;
|
|
83
|
+
cursor: pointer;
|
|
84
|
+
font-size: 13px;
|
|
85
|
+
transition: background 0.15s;
|
|
86
|
+
text-decoration: none;
|
|
87
|
+
display: inline-flex;
|
|
88
|
+
align-items: center;
|
|
89
|
+
gap: 6px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.btn:hover { background: var(--surface-hover); }
|
|
93
|
+
|
|
94
|
+
.stats {
|
|
95
|
+
display: grid;
|
|
96
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
97
|
+
gap: 16px;
|
|
98
|
+
margin-bottom: 40px;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.stat-card {
|
|
102
|
+
background: var(--surface);
|
|
103
|
+
border: 1px solid var(--border);
|
|
104
|
+
border-radius: var(--radius);
|
|
105
|
+
padding: 20px;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.stat-card .label { font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
|
109
|
+
.stat-card .value { font-size: 32px; font-weight: 700; margin-top: 4px; }
|
|
110
|
+
.stat-card.accent .value { color: var(--accent); }
|
|
111
|
+
.stat-card.success .value { color: var(--success); }
|
|
112
|
+
.stat-card.danger .value { color: var(--danger); }
|
|
113
|
+
|
|
114
|
+
.section { margin-bottom: 40px; }
|
|
115
|
+
|
|
116
|
+
.section-header {
|
|
117
|
+
font-size: 16px;
|
|
118
|
+
font-weight: 600;
|
|
119
|
+
margin-bottom: 16px;
|
|
120
|
+
display: flex;
|
|
121
|
+
align-items: center;
|
|
122
|
+
gap: 8px;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.section-header .count {
|
|
126
|
+
background: var(--accent-dim);
|
|
127
|
+
color: var(--accent);
|
|
128
|
+
font-size: 12px;
|
|
129
|
+
padding: 2px 8px;
|
|
130
|
+
border-radius: 99px;
|
|
131
|
+
font-weight: 500;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* Events Table */
|
|
135
|
+
.events-table {
|
|
136
|
+
width: 100%;
|
|
137
|
+
border-collapse: collapse;
|
|
138
|
+
background: var(--surface);
|
|
139
|
+
border: 1px solid var(--border);
|
|
140
|
+
border-radius: var(--radius);
|
|
141
|
+
overflow: hidden;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.events-table th {
|
|
145
|
+
text-align: left;
|
|
146
|
+
padding: 12px 16px;
|
|
147
|
+
font-size: 11px;
|
|
148
|
+
text-transform: uppercase;
|
|
149
|
+
letter-spacing: 0.5px;
|
|
150
|
+
color: var(--text-muted);
|
|
151
|
+
border-bottom: 1px solid var(--border);
|
|
152
|
+
background: var(--surface);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.events-table td {
|
|
156
|
+
padding: 12px 16px;
|
|
157
|
+
border-bottom: 1px solid var(--border);
|
|
158
|
+
font-size: 14px;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.events-table tr:last-child td { border-bottom: none; }
|
|
162
|
+
.events-table tr.clickable { cursor: pointer; transition: background 0.15s; }
|
|
163
|
+
.events-table tr.clickable:hover { background: var(--surface-hover); }
|
|
164
|
+
|
|
165
|
+
.event-type {
|
|
166
|
+
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
167
|
+
font-size: 13px;
|
|
168
|
+
color: var(--accent);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.badge {
|
|
172
|
+
display: inline-block;
|
|
173
|
+
font-size: 11px;
|
|
174
|
+
padding: 2px 8px;
|
|
175
|
+
border-radius: 99px;
|
|
176
|
+
font-weight: 500;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.badge-retry {
|
|
180
|
+
background: var(--warning-dim);
|
|
181
|
+
color: var(--warning);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.badge-none {
|
|
185
|
+
background: var(--border);
|
|
186
|
+
color: var(--text-muted);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* Workflow Cards */
|
|
190
|
+
.workflow-card {
|
|
191
|
+
background: var(--surface);
|
|
192
|
+
border: 1px solid var(--border);
|
|
193
|
+
border-radius: var(--radius);
|
|
194
|
+
padding: 24px;
|
|
195
|
+
margin-bottom: 16px;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.workflow-name {
|
|
199
|
+
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
200
|
+
font-size: 15px;
|
|
201
|
+
font-weight: 600;
|
|
202
|
+
margin-bottom: 20px;
|
|
203
|
+
color: var(--accent);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.workflow-callbacks {
|
|
207
|
+
display: flex;
|
|
208
|
+
gap: 12px;
|
|
209
|
+
margin-top: 16px;
|
|
210
|
+
padding-top: 16px;
|
|
211
|
+
border-top: 1px solid var(--border);
|
|
212
|
+
flex-wrap: wrap;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.callback-tag {
|
|
216
|
+
font-size: 12px;
|
|
217
|
+
padding: 4px 10px;
|
|
218
|
+
border-radius: 6px;
|
|
219
|
+
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.callback-complete { background: var(--accent-dim); color: var(--accent); }
|
|
223
|
+
.callback-success { background: var(--success-dim); color: var(--success); }
|
|
224
|
+
.callback-failure { background: var(--danger-dim); color: var(--danger); }
|
|
225
|
+
|
|
226
|
+
/* Workflow Flow Diagram */
|
|
227
|
+
.workflow-flow {
|
|
228
|
+
position: relative;
|
|
229
|
+
overflow-x: auto;
|
|
230
|
+
padding: 8px 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.workflow-flow svg {
|
|
234
|
+
position: absolute;
|
|
235
|
+
top: 0;
|
|
236
|
+
left: 0;
|
|
237
|
+
pointer-events: none;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.flow-grid {
|
|
241
|
+
display: grid;
|
|
242
|
+
grid-auto-flow: column;
|
|
243
|
+
grid-template-rows: auto auto auto;
|
|
244
|
+
gap: 16px 40px;
|
|
245
|
+
align-items: center;
|
|
246
|
+
position: relative;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.flow-node {
|
|
250
|
+
background: var(--bg);
|
|
251
|
+
border: 1px solid var(--border);
|
|
252
|
+
border-radius: 8px;
|
|
253
|
+
padding: 12px 16px;
|
|
254
|
+
min-width: 140px;
|
|
255
|
+
text-align: center;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.flow-node.branch-success { border-color: var(--success); border-width: 1px; }
|
|
259
|
+
.flow-node.branch-failure { border-color: var(--danger); border-width: 1px; }
|
|
260
|
+
|
|
261
|
+
.flow-node .node-type {
|
|
262
|
+
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
263
|
+
font-size: 12px;
|
|
264
|
+
font-weight: 500;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.flow-node .node-label {
|
|
268
|
+
font-size: 10px;
|
|
269
|
+
margin-top: 4px;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.flow-node .node-label.label-success { color: var(--success); }
|
|
273
|
+
.flow-node .node-label.label-failure { color: var(--danger); }
|
|
274
|
+
|
|
275
|
+
.flow-spacer {
|
|
276
|
+
visibility: hidden;
|
|
277
|
+
min-width: 140px;
|
|
278
|
+
padding: 12px 16px;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.empty-state {
|
|
282
|
+
text-align: center;
|
|
283
|
+
padding: 48px;
|
|
284
|
+
color: var(--text-muted);
|
|
285
|
+
background: var(--surface);
|
|
286
|
+
border: 1px solid var(--border);
|
|
287
|
+
border-radius: var(--radius);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.empty-state p { font-size: 14px; }
|
|
291
|
+
|
|
292
|
+
.loading {
|
|
293
|
+
text-align: center;
|
|
294
|
+
padding: 80px;
|
|
295
|
+
color: var(--text-muted);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/* Event Detail */
|
|
299
|
+
.back-link {
|
|
300
|
+
color: var(--text-muted);
|
|
301
|
+
text-decoration: none;
|
|
302
|
+
font-size: 13px;
|
|
303
|
+
display: inline-flex;
|
|
304
|
+
align-items: center;
|
|
305
|
+
gap: 6px;
|
|
306
|
+
margin-bottom: 24px;
|
|
307
|
+
cursor: pointer;
|
|
308
|
+
transition: color 0.15s;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
.back-link:hover { color: var(--text); }
|
|
312
|
+
|
|
313
|
+
.detail-header {
|
|
314
|
+
display: flex;
|
|
315
|
+
align-items: center;
|
|
316
|
+
justify-content: space-between;
|
|
317
|
+
margin-bottom: 32px;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.detail-title {
|
|
321
|
+
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
322
|
+
font-size: 20px;
|
|
323
|
+
font-weight: 600;
|
|
324
|
+
color: var(--accent);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.detail-badge { margin-left: 12px; }
|
|
328
|
+
|
|
329
|
+
/* Pagination */
|
|
330
|
+
.pagination {
|
|
331
|
+
display: flex;
|
|
332
|
+
align-items: center;
|
|
333
|
+
justify-content: space-between;
|
|
334
|
+
padding: 12px 16px;
|
|
335
|
+
background: var(--surface);
|
|
336
|
+
border: 1px solid var(--border);
|
|
337
|
+
border-top: none;
|
|
338
|
+
border-radius: 0 0 var(--radius) var(--radius);
|
|
339
|
+
font-size: 13px;
|
|
340
|
+
color: var(--text-muted);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.pagination-buttons {
|
|
344
|
+
display: flex;
|
|
345
|
+
gap: 8px;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.pagination-btn {
|
|
349
|
+
background: var(--bg);
|
|
350
|
+
border: 1px solid var(--border);
|
|
351
|
+
color: var(--text);
|
|
352
|
+
padding: 4px 12px;
|
|
353
|
+
border-radius: 6px;
|
|
354
|
+
cursor: pointer;
|
|
355
|
+
font-size: 12px;
|
|
356
|
+
transition: background 0.15s;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
.pagination-btn:hover:not(:disabled) { background: var(--surface-hover); }
|
|
360
|
+
.pagination-btn:disabled { opacity: 0.3; cursor: default; }
|
|
361
|
+
|
|
362
|
+
.events-table.has-pagination { border-radius: var(--radius) var(--radius) 0 0; }
|
|
363
|
+
</style>
|
|
364
|
+
</head>
|
|
365
|
+
<body>
|
|
366
|
+
<div class="container">
|
|
367
|
+
<header>
|
|
368
|
+
<div class="logo">
|
|
369
|
+
<div class="logo-icon">S</div>
|
|
370
|
+
<h1>Synkro <span>Dashboard</span></h1>
|
|
371
|
+
</div>
|
|
372
|
+
<button class="btn" id="header-action">Refresh</button>
|
|
373
|
+
</header>
|
|
374
|
+
<div id="content">
|
|
375
|
+
<div class="loading">Loading...</div>
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
|
|
379
|
+
<script>
|
|
380
|
+
function getBase() {
|
|
381
|
+
const p = window.location.pathname;
|
|
382
|
+
return p.endsWith('/') ? p : p + '/';
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
let cachedIntrospection = null;
|
|
386
|
+
var PAGE_SIZE = 5;
|
|
387
|
+
var eventsPage = 0;
|
|
388
|
+
var workflowsPage = 0;
|
|
389
|
+
|
|
390
|
+
async function fetchIntrospection() {
|
|
391
|
+
const res = await fetch(getBase() + 'api/introspection');
|
|
392
|
+
cachedIntrospection = await res.json();
|
|
393
|
+
return cachedIntrospection;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function fetchEventMetrics(eventType) {
|
|
397
|
+
const res = await fetch(getBase() + 'api/events/' + encodeURIComponent(eventType));
|
|
398
|
+
return await res.json();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function route() {
|
|
402
|
+
const hash = window.location.hash || '#/';
|
|
403
|
+
const eventMatch = hash.match(/^#\\/events\\/(.+)$/);
|
|
404
|
+
const workflowMatch = hash.match(/^#\\/workflows\\/(.+)$/);
|
|
405
|
+
|
|
406
|
+
if (eventMatch) {
|
|
407
|
+
const eventType = decodeURIComponent(eventMatch[1]);
|
|
408
|
+
showEventDetail(eventType);
|
|
409
|
+
} else if (workflowMatch) {
|
|
410
|
+
const workflowName = decodeURIComponent(workflowMatch[1]);
|
|
411
|
+
showWorkflowDetail(workflowName);
|
|
412
|
+
} else {
|
|
413
|
+
showDashboard();
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function showDashboard() {
|
|
418
|
+
const btn = document.getElementById('header-action');
|
|
419
|
+
btn.textContent = 'Refresh';
|
|
420
|
+
btn.onclick = function() { eventsPage = 0; workflowsPage = 0; showDashboard(); };
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
const data = await fetchIntrospection();
|
|
424
|
+
renderDashboard(data);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
document.getElementById('content').innerHTML =
|
|
427
|
+
'<div class="empty-state"><p>Failed to load data. Check the console for errors.</p></div>';
|
|
428
|
+
console.error('Synkro Dashboard: Failed to fetch introspection data', err);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function showEventDetail(eventType) {
|
|
433
|
+
const btn = document.getElementById('header-action');
|
|
434
|
+
btn.textContent = 'Refresh';
|
|
435
|
+
btn.onclick = () => showEventDetail(eventType);
|
|
436
|
+
|
|
437
|
+
document.getElementById('content').innerHTML = '<div class="loading">Loading...</div>';
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
if (!cachedIntrospection) await fetchIntrospection();
|
|
441
|
+
const eventInfo = cachedIntrospection.events.find(e => e.type === eventType);
|
|
442
|
+
const metrics = await fetchEventMetrics(eventType);
|
|
443
|
+
renderEventDetail(eventType, eventInfo, metrics);
|
|
444
|
+
} catch (err) {
|
|
445
|
+
document.getElementById('content').innerHTML =
|
|
446
|
+
'<div class="empty-state"><p>Failed to load event data.</p></div>';
|
|
447
|
+
console.error('Synkro Dashboard: Failed to fetch event metrics', err);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function showWorkflowDetail(workflowName) {
|
|
452
|
+
const btn = document.getElementById('header-action');
|
|
453
|
+
btn.textContent = 'Refresh';
|
|
454
|
+
btn.onclick = () => showWorkflowDetail(workflowName);
|
|
455
|
+
|
|
456
|
+
document.getElementById('content').innerHTML = '<div class="loading">Loading...</div>';
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
if (!cachedIntrospection) await fetchIntrospection();
|
|
460
|
+
const wf = cachedIntrospection.workflows.find(w => w.name === workflowName);
|
|
461
|
+
if (!wf) {
|
|
462
|
+
document.getElementById('content').innerHTML =
|
|
463
|
+
'<div class="empty-state"><p>Workflow not found.</p></div>';
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
renderWorkflowDetail(wf);
|
|
467
|
+
} catch (err) {
|
|
468
|
+
document.getElementById('content').innerHTML =
|
|
469
|
+
'<div class="empty-state"><p>Failed to load workflow data.</p></div>';
|
|
470
|
+
console.error('Synkro Dashboard: Failed to fetch workflow data', err);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function renderWorkflowDetail(wf) {
|
|
475
|
+
let html = '';
|
|
476
|
+
|
|
477
|
+
html += '<a class="back-link" onclick="window.location.hash=\\'#/\\'">\u2190 Back to Dashboard</a>';
|
|
478
|
+
|
|
479
|
+
html += '<div class="detail-header">';
|
|
480
|
+
html += '<div>';
|
|
481
|
+
html += '<div class="detail-title">' + esc(wf.name) + '</div>';
|
|
482
|
+
html += '</div>';
|
|
483
|
+
html += '</div>';
|
|
484
|
+
|
|
485
|
+
// Stats
|
|
486
|
+
var branchTargets = new Set();
|
|
487
|
+
for (var s = 0; s < wf.steps.length; s++) {
|
|
488
|
+
if (wf.steps[s].onSuccess) branchTargets.add(wf.steps[s].onSuccess);
|
|
489
|
+
if (wf.steps[s].onFailure) branchTargets.add(wf.steps[s].onFailure);
|
|
490
|
+
}
|
|
491
|
+
var mainCount = wf.steps.filter(function(st) { return !branchTargets.has(st.type); }).length;
|
|
492
|
+
|
|
493
|
+
html += '<div class="stats">';
|
|
494
|
+
html += statCard('Total Steps', wf.steps.length, 'accent');
|
|
495
|
+
html += statCard('Main Flow', mainCount);
|
|
496
|
+
html += statCard('Branches', branchTargets.size);
|
|
497
|
+
html += '</div>';
|
|
498
|
+
|
|
499
|
+
// Flow diagram
|
|
500
|
+
html += '<div class="section">';
|
|
501
|
+
html += '<div class="section-header">Flow Diagram</div>';
|
|
502
|
+
html += workflowCard(wf);
|
|
503
|
+
html += '</div>';
|
|
504
|
+
|
|
505
|
+
// Steps table
|
|
506
|
+
html += '<div class="section">';
|
|
507
|
+
html += '<div class="section-header">Steps <span class="count">' + wf.steps.length + '</span></div>';
|
|
508
|
+
html += '<table class="events-table">';
|
|
509
|
+
html += '<thead><tr><th>Step</th><th>Retries</th><th>On Success</th><th>On Failure</th></tr></thead>';
|
|
510
|
+
html += '<tbody>';
|
|
511
|
+
for (var i = 0; i < wf.steps.length; i++) {
|
|
512
|
+
var step = wf.steps[i];
|
|
513
|
+
var retryBadge = step.retry
|
|
514
|
+
? '<span class="badge badge-retry">' + step.retry.maxRetries + ' retries</span>'
|
|
515
|
+
: '<span class="badge badge-none">No retry</span>';
|
|
516
|
+
var successBadge = step.onSuccess
|
|
517
|
+
? '<span class="badge badge-retry">' + esc(step.onSuccess) + '</span>'
|
|
518
|
+
: '<span class="badge badge-none">\u2014</span>';
|
|
519
|
+
var failBadge = step.onFailure
|
|
520
|
+
? '<span class="badge" style="background:var(--danger-dim);color:var(--danger)">' + esc(step.onFailure) + '</span>'
|
|
521
|
+
: '<span class="badge badge-none">\u2014</span>';
|
|
522
|
+
html += '<tr><td class="event-type">' + esc(step.type) + '</td><td>' + retryBadge + '</td><td>' + successBadge + '</td><td>' + failBadge + '</td></tr>';
|
|
523
|
+
}
|
|
524
|
+
html += '</tbody></table>';
|
|
525
|
+
html += '</div>';
|
|
526
|
+
|
|
527
|
+
document.getElementById('content').innerHTML = html;
|
|
528
|
+
requestAnimationFrame(drawFlowConnections);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function renderDashboard(data) {
|
|
532
|
+
const { events, workflows } = data;
|
|
533
|
+
let html = '';
|
|
534
|
+
|
|
535
|
+
// Stats
|
|
536
|
+
html += '<div class="stats">';
|
|
537
|
+
html += statCard('Events', events.length);
|
|
538
|
+
html += statCard('Workflows', workflows.length);
|
|
539
|
+
const totalSteps = workflows.reduce((sum, w) => sum + w.steps.length, 0);
|
|
540
|
+
html += statCard('Workflow Steps', totalSteps);
|
|
541
|
+
html += '</div>';
|
|
542
|
+
|
|
543
|
+
// Events
|
|
544
|
+
html += '<div class="section">';
|
|
545
|
+
html += '<div class="section-header">Events <span class="count">' + events.length + '</span></div>';
|
|
546
|
+
if (events.length === 0) {
|
|
547
|
+
html += '<div class="empty-state"><p>No events registered</p></div>';
|
|
548
|
+
} else {
|
|
549
|
+
var eTotalPages = Math.ceil(events.length / PAGE_SIZE);
|
|
550
|
+
if (eventsPage >= eTotalPages) eventsPage = eTotalPages - 1;
|
|
551
|
+
var eStart = eventsPage * PAGE_SIZE;
|
|
552
|
+
var eSlice = events.slice(eStart, eStart + PAGE_SIZE);
|
|
553
|
+
var needsEPag = events.length > PAGE_SIZE;
|
|
554
|
+
|
|
555
|
+
html += '<table class="events-table' + (needsEPag ? ' has-pagination' : '') + '">';
|
|
556
|
+
html += '<thead><tr><th>Event Type</th><th>Retries</th></tr></thead>';
|
|
557
|
+
html += '<tbody>';
|
|
558
|
+
for (var ei = 0; ei < eSlice.length; ei++) {
|
|
559
|
+
var event = eSlice[ei];
|
|
560
|
+
var retryBadge = event.retry
|
|
561
|
+
? '<span class="badge badge-retry">' + event.retry.maxRetries + ' retries</span>'
|
|
562
|
+
: '<span class="badge badge-none">No retry</span>';
|
|
563
|
+
html += '<tr class="clickable" onclick="window.location.hash=\\'#/events/' + encodeURIComponent(event.type) + '\\'"><td class="event-type">' + esc(event.type) + '</td><td>' + retryBadge + '</td></tr>';
|
|
564
|
+
}
|
|
565
|
+
html += '</tbody></table>';
|
|
566
|
+
|
|
567
|
+
if (needsEPag) {
|
|
568
|
+
html += '<div class="pagination">';
|
|
569
|
+
html += '<span>' + (eStart + 1) + '\u2013' + Math.min(eStart + PAGE_SIZE, events.length) + ' of ' + events.length + '</span>';
|
|
570
|
+
html += '<div class="pagination-buttons">';
|
|
571
|
+
html += '<button class="pagination-btn" id="events-prev"' + (eventsPage === 0 ? ' disabled' : '') + '>\u2190 Prev</button>';
|
|
572
|
+
html += '<button class="pagination-btn" id="events-next"' + (eventsPage >= eTotalPages - 1 ? ' disabled' : '') + '>Next \u2192</button>';
|
|
573
|
+
html += '</div></div>';
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
html += '</div>';
|
|
577
|
+
|
|
578
|
+
// Workflows
|
|
579
|
+
html += '<div class="section">';
|
|
580
|
+
html += '<div class="section-header">Workflows <span class="count">' + workflows.length + '</span></div>';
|
|
581
|
+
if (workflows.length === 0) {
|
|
582
|
+
html += '<div class="empty-state"><p>No workflows registered</p></div>';
|
|
583
|
+
} else {
|
|
584
|
+
var wTotalPages = Math.ceil(workflows.length / PAGE_SIZE);
|
|
585
|
+
if (workflowsPage >= wTotalPages) workflowsPage = wTotalPages - 1;
|
|
586
|
+
var wStart = workflowsPage * PAGE_SIZE;
|
|
587
|
+
var wSlice = workflows.slice(wStart, wStart + PAGE_SIZE);
|
|
588
|
+
var needsWPag = workflows.length > PAGE_SIZE;
|
|
589
|
+
|
|
590
|
+
html += '<table class="events-table' + (needsWPag ? ' has-pagination' : '') + '">';
|
|
591
|
+
html += '<thead><tr><th>Workflow Name</th><th>Steps</th><th>Callbacks</th></tr></thead>';
|
|
592
|
+
html += '<tbody>';
|
|
593
|
+
for (var wi = 0; wi < wSlice.length; wi++) {
|
|
594
|
+
var wf = wSlice[wi];
|
|
595
|
+
var callbacks = [];
|
|
596
|
+
if (wf.onComplete) callbacks.push('onComplete');
|
|
597
|
+
if (wf.onSuccess) callbacks.push('onSuccess');
|
|
598
|
+
if (wf.onFailure) callbacks.push('onFailure');
|
|
599
|
+
var callbacksHtml = callbacks.length > 0
|
|
600
|
+
? callbacks.map(function(c) { return '<span class="badge badge-' + (c === 'onComplete' ? 'none' : c === 'onSuccess' ? 'retry' : 'none') + '">' + c + '</span>'; }).join(' ')
|
|
601
|
+
: '<span class="badge badge-none">None</span>';
|
|
602
|
+
html += '<tr class="clickable" onclick="window.location.hash=\\'#/workflows/' + encodeURIComponent(wf.name) + '\\'"><td class="event-type">' + esc(wf.name) + '</td><td>' + wf.steps.length + ' steps</td><td>' + callbacksHtml + '</td></tr>';
|
|
603
|
+
}
|
|
604
|
+
html += '</tbody></table>';
|
|
605
|
+
|
|
606
|
+
if (needsWPag) {
|
|
607
|
+
html += '<div class="pagination">';
|
|
608
|
+
html += '<span>' + (wStart + 1) + '\u2013' + Math.min(wStart + PAGE_SIZE, workflows.length) + ' of ' + workflows.length + '</span>';
|
|
609
|
+
html += '<div class="pagination-buttons">';
|
|
610
|
+
html += '<button class="pagination-btn" id="workflows-prev"' + (workflowsPage === 0 ? ' disabled' : '') + '>\u2190 Prev</button>';
|
|
611
|
+
html += '<button class="pagination-btn" id="workflows-next"' + (workflowsPage >= wTotalPages - 1 ? ' disabled' : '') + '>Next \u2192</button>';
|
|
612
|
+
html += '</div></div>';
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
html += '</div>';
|
|
616
|
+
|
|
617
|
+
document.getElementById('content').innerHTML = html;
|
|
618
|
+
|
|
619
|
+
// Bind pagination buttons
|
|
620
|
+
var eprev = document.getElementById('events-prev');
|
|
621
|
+
var enext = document.getElementById('events-next');
|
|
622
|
+
var wprev = document.getElementById('workflows-prev');
|
|
623
|
+
var wnext = document.getElementById('workflows-next');
|
|
624
|
+
if (eprev) eprev.onclick = function() { eventsPage--; renderDashboard(data); };
|
|
625
|
+
if (enext) enext.onclick = function() { eventsPage++; renderDashboard(data); };
|
|
626
|
+
if (wprev) wprev.onclick = function() { workflowsPage--; renderDashboard(data); };
|
|
627
|
+
if (wnext) wnext.onclick = function() { workflowsPage++; renderDashboard(data); };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function drawFlowConnections() {
|
|
631
|
+
var flows = document.querySelectorAll('.workflow-flow');
|
|
632
|
+
flows.forEach(function(flow) {
|
|
633
|
+
var grid = flow.querySelector('.flow-grid');
|
|
634
|
+
if (!grid) return;
|
|
635
|
+
|
|
636
|
+
var nodes = grid.querySelectorAll('.flow-node');
|
|
637
|
+
if (nodes.length === 0) return;
|
|
638
|
+
|
|
639
|
+
// Remove old SVG
|
|
640
|
+
var oldSvg = flow.querySelector('svg');
|
|
641
|
+
if (oldSvg) oldSvg.remove();
|
|
642
|
+
|
|
643
|
+
var flowRect = flow.getBoundingClientRect();
|
|
644
|
+
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
645
|
+
svg.setAttribute('width', grid.scrollWidth);
|
|
646
|
+
svg.setAttribute('height', grid.scrollHeight);
|
|
647
|
+
svg.style.width = grid.scrollWidth + 'px';
|
|
648
|
+
svg.style.height = grid.scrollHeight + 'px';
|
|
649
|
+
|
|
650
|
+
var gridRect = grid.getBoundingClientRect();
|
|
651
|
+
|
|
652
|
+
function nodeRect(id) {
|
|
653
|
+
var el = grid.querySelector('[data-id="' + id + '"]');
|
|
654
|
+
if (!el) return null;
|
|
655
|
+
var r = el.getBoundingClientRect();
|
|
656
|
+
return {
|
|
657
|
+
left: r.left - gridRect.left,
|
|
658
|
+
right: r.right - gridRect.left,
|
|
659
|
+
top: r.top - gridRect.top,
|
|
660
|
+
bottom: r.bottom - gridRect.top,
|
|
661
|
+
cx: (r.left + r.right) / 2 - gridRect.left,
|
|
662
|
+
cy: (r.top + r.bottom) / 2 - gridRect.top
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function makePath(d, color, dashed) {
|
|
667
|
+
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
668
|
+
path.setAttribute('d', d);
|
|
669
|
+
path.setAttribute('fill', 'none');
|
|
670
|
+
path.setAttribute('stroke', color);
|
|
671
|
+
path.setAttribute('stroke-width', '2');
|
|
672
|
+
if (dashed) path.setAttribute('stroke-dasharray', '6 4');
|
|
673
|
+
svg.appendChild(path);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function makeArrowHead(x, y, color) {
|
|
677
|
+
var poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
|
678
|
+
poly.setAttribute('points', (x - 6) + ',' + (y - 4) + ' ' + x + ',' + y + ' ' + (x - 6) + ',' + (y + 4));
|
|
679
|
+
poly.setAttribute('fill', color);
|
|
680
|
+
svg.appendChild(poly);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Find how many main columns exist
|
|
684
|
+
var mainNodes = grid.querySelectorAll('[data-id^="main-"]');
|
|
685
|
+
var colCount = mainNodes.length;
|
|
686
|
+
|
|
687
|
+
for (var col = 0; col < colCount; col++) {
|
|
688
|
+
var main = nodeRect('main-' + col);
|
|
689
|
+
var nextMain = nodeRect('main-' + (col + 1));
|
|
690
|
+
var succBranch = nodeRect('branch-success-' + col);
|
|
691
|
+
var failBranch = nodeRect('branch-failure-' + col);
|
|
692
|
+
|
|
693
|
+
var seqColor = '#6e6e82';
|
|
694
|
+
var successColor = '#34d399';
|
|
695
|
+
var failColor = '#f87171';
|
|
696
|
+
|
|
697
|
+
if (succBranch && failBranch) {
|
|
698
|
+
// Has branches: draw curves from main to success (up) and failure (down)
|
|
699
|
+
// Main -> Success branch (curve up-right)
|
|
700
|
+
var sx = main.right;
|
|
701
|
+
var sy = main.cy;
|
|
702
|
+
var ex = succBranch.left;
|
|
703
|
+
var ey = succBranch.cy;
|
|
704
|
+
var cpx = sx + (ex - sx) * 0.5;
|
|
705
|
+
makePath('M' + sx + ',' + sy + ' C' + cpx + ',' + sy + ' ' + cpx + ',' + ey + ' ' + ex + ',' + ey, successColor);
|
|
706
|
+
makeArrowHead(ex, ey, successColor);
|
|
707
|
+
|
|
708
|
+
// Main -> Failure branch (curve down-right)
|
|
709
|
+
ey = failBranch.cy;
|
|
710
|
+
ex = failBranch.left;
|
|
711
|
+
makePath('M' + sx + ',' + sy + ' C' + cpx + ',' + sy + ' ' + cpx + ',' + ey + ' ' + ex + ',' + ey, failColor);
|
|
712
|
+
makeArrowHead(ex, ey, failColor);
|
|
713
|
+
|
|
714
|
+
// Success branch -> next main (curve down-right to converge)
|
|
715
|
+
if (nextMain) {
|
|
716
|
+
sx = succBranch.right;
|
|
717
|
+
sy = succBranch.cy;
|
|
718
|
+
ex = nextMain.left;
|
|
719
|
+
ey = nextMain.cy;
|
|
720
|
+
cpx = sx + (ex - sx) * 0.5;
|
|
721
|
+
makePath('M' + sx + ',' + sy + ' C' + cpx + ',' + sy + ' ' + cpx + ',' + ey + ' ' + ex + ',' + ey, successColor, true);
|
|
722
|
+
|
|
723
|
+
// Failure branch -> next main (curve up-right to converge)
|
|
724
|
+
sx = failBranch.right;
|
|
725
|
+
sy = failBranch.cy;
|
|
726
|
+
makePath('M' + sx + ',' + sy + ' C' + cpx + ',' + sy + ' ' + cpx + ',' + ey + ' ' + ex + ',' + ey, failColor, true);
|
|
727
|
+
makeArrowHead(ex, ey, seqColor);
|
|
728
|
+
}
|
|
729
|
+
} else if (succBranch) {
|
|
730
|
+
// Only success branch
|
|
731
|
+
var sx = main.right; var sy = main.cy;
|
|
732
|
+
var ex = succBranch.left; var ey = succBranch.cy;
|
|
733
|
+
var cpx = sx + (ex - sx) * 0.5;
|
|
734
|
+
makePath('M' + sx + ',' + sy + ' C' + cpx + ',' + sy + ' ' + cpx + ',' + ey + ' ' + ex + ',' + ey, successColor);
|
|
735
|
+
makeArrowHead(ex, ey, successColor);
|
|
736
|
+
if (nextMain) {
|
|
737
|
+
sx = succBranch.right; sy = succBranch.cy;
|
|
738
|
+
ex = nextMain.left; ey = nextMain.cy;
|
|
739
|
+
cpx = sx + (ex - sx) * 0.5;
|
|
740
|
+
makePath('M' + sx + ',' + sy + ' C' + cpx + ',' + sy + ' ' + cpx + ',' + ey + ' ' + ex + ',' + ey, successColor, true);
|
|
741
|
+
makeArrowHead(ex, ey, seqColor);
|
|
742
|
+
}
|
|
743
|
+
// Also draw sequential from main to next if no failure branch
|
|
744
|
+
if (nextMain) {
|
|
745
|
+
sx = main.right; sy = main.cy;
|
|
746
|
+
ex = nextMain.left; ey = nextMain.cy;
|
|
747
|
+
makePath('M' + sx + ',' + sy + ' L' + ex + ',' + ey, seqColor);
|
|
748
|
+
makeArrowHead(ex, ey, seqColor);
|
|
749
|
+
}
|
|
750
|
+
} else if (failBranch) {
|
|
751
|
+
// Only failure branch
|
|
752
|
+
var sx = main.right; var sy = main.cy;
|
|
753
|
+
var ex = failBranch.left; var ey = failBranch.cy;
|
|
754
|
+
var cpx = sx + (ex - sx) * 0.5;
|
|
755
|
+
makePath('M' + sx + ',' + sy + ' C' + cpx + ',' + sy + ' ' + cpx + ',' + ey + ' ' + ex + ',' + ey, failColor);
|
|
756
|
+
makeArrowHead(ex, ey, failColor);
|
|
757
|
+
if (nextMain) {
|
|
758
|
+
sx = failBranch.right; sy = failBranch.cy;
|
|
759
|
+
ex = nextMain.left; ey = nextMain.cy;
|
|
760
|
+
cpx = sx + (ex - sx) * 0.5;
|
|
761
|
+
makePath('M' + sx + ',' + sy + ' C' + cpx + ',' + sy + ' ' + cpx + ',' + ey + ' ' + ex + ',' + ey, failColor, true);
|
|
762
|
+
makeArrowHead(ex, ey, seqColor);
|
|
763
|
+
}
|
|
764
|
+
// Also draw sequential from main to next
|
|
765
|
+
if (nextMain) {
|
|
766
|
+
sx = main.right; sy = main.cy;
|
|
767
|
+
ex = nextMain.left; ey = nextMain.cy;
|
|
768
|
+
makePath('M' + sx + ',' + sy + ' L' + ex + ',' + ey, seqColor);
|
|
769
|
+
makeArrowHead(ex, ey, seqColor);
|
|
770
|
+
}
|
|
771
|
+
} else if (nextMain) {
|
|
772
|
+
// No branches - straight sequential arrow
|
|
773
|
+
var sx = main.right;
|
|
774
|
+
var sy = main.cy;
|
|
775
|
+
var ex = nextMain.left;
|
|
776
|
+
var ey = nextMain.cy;
|
|
777
|
+
makePath('M' + sx + ',' + sy + ' L' + ex + ',' + ey, seqColor);
|
|
778
|
+
makeArrowHead(ex, ey, seqColor);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
grid.insertBefore(svg, grid.firstChild);
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function renderEventDetail(eventType, eventInfo, metrics) {
|
|
787
|
+
let html = '';
|
|
788
|
+
|
|
789
|
+
html += '<a class="back-link" onclick="window.location.hash=\\'#/\\'">\u2190 Back to Dashboard</a>';
|
|
790
|
+
|
|
791
|
+
html += '<div class="detail-header">';
|
|
792
|
+
html += '<div>';
|
|
793
|
+
html += '<div class="detail-title">' + esc(eventType) + '</div>';
|
|
794
|
+
if (eventInfo && eventInfo.retry) {
|
|
795
|
+
html += '<span class="badge badge-retry detail-badge">' + eventInfo.retry.maxRetries + ' retries</span>';
|
|
796
|
+
}
|
|
797
|
+
html += '</div>';
|
|
798
|
+
html += '</div>';
|
|
799
|
+
|
|
800
|
+
html += '<div class="stats">';
|
|
801
|
+
html += statCard('Received', metrics.received, 'accent');
|
|
802
|
+
html += statCard('Completed', metrics.completed, 'success');
|
|
803
|
+
html += statCard('Failed', metrics.failed, 'danger');
|
|
804
|
+
html += '</div>';
|
|
805
|
+
|
|
806
|
+
document.getElementById('content').innerHTML = html;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function statCard(label, value, variant) {
|
|
810
|
+
const cls = variant ? ' ' + variant : '';
|
|
811
|
+
return '<div class="stat-card' + cls + '"><div class="label">' + label + '</div><div class="value">' + value + '</div></div>';
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function workflowCard(wf) {
|
|
815
|
+
let html = '<div class="workflow-card" data-workflow="' + esc(wf.name) + '">';
|
|
816
|
+
|
|
817
|
+
// Identify branch targets (steps referenced by onSuccess/onFailure)
|
|
818
|
+
var branchTargets = new Set();
|
|
819
|
+
var branchMap = {}; // parentType -> { onSuccess: stepType, onFailure: stepType }
|
|
820
|
+
for (var s = 0; s < wf.steps.length; s++) {
|
|
821
|
+
var st = wf.steps[s];
|
|
822
|
+
if (st.onSuccess) { branchTargets.add(st.onSuccess); branchMap[st.type] = branchMap[st.type] || {}; branchMap[st.type].onSuccess = st.onSuccess; }
|
|
823
|
+
if (st.onFailure) { branchTargets.add(st.onFailure); branchMap[st.type] = branchMap[st.type] || {}; branchMap[st.type].onFailure = st.onFailure; }
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Build main flow (skip branch targets)
|
|
827
|
+
var mainSteps = [];
|
|
828
|
+
for (var s = 0; s < wf.steps.length; s++) {
|
|
829
|
+
if (!branchTargets.has(wf.steps[s].type)) {
|
|
830
|
+
mainSteps.push(wf.steps[s]);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Find branch step data by type
|
|
835
|
+
function findStep(type) {
|
|
836
|
+
for (var s = 0; s < wf.steps.length; s++) {
|
|
837
|
+
if (wf.steps[s].type === type) return wf.steps[s];
|
|
838
|
+
}
|
|
839
|
+
return null;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Build grid: 3 rows, columns advance separately for branches
|
|
843
|
+
// Row 1 = success branches, Row 2 = main flow, Row 3 = failure branches
|
|
844
|
+
html += '<div class="workflow-flow"><div class="flow-grid">';
|
|
845
|
+
|
|
846
|
+
var gridCol = 1;
|
|
847
|
+
for (var col = 0; col < mainSteps.length; col++) {
|
|
848
|
+
var ms = mainSteps[col];
|
|
849
|
+
var branches = branchMap[ms.type];
|
|
850
|
+
|
|
851
|
+
// Main step column
|
|
852
|
+
html += '<div class="flow-spacer" style="grid-row:1;grid-column:' + gridCol + '"></div>';
|
|
853
|
+
html += '<div class="flow-node" data-id="main-' + col + '" style="grid-row:2;grid-column:' + gridCol + '">';
|
|
854
|
+
html += '<div class="node-type">' + esc(ms.type) + '</div>';
|
|
855
|
+
if (ms.retry) html += '<span class="badge badge-retry">' + ms.retry.maxRetries + ' retries</span>';
|
|
856
|
+
html += '</div>';
|
|
857
|
+
html += '<div class="flow-spacer" style="grid-row:3;grid-column:' + gridCol + '"></div>';
|
|
858
|
+
|
|
859
|
+
// If this step has branches, add them in the next column
|
|
860
|
+
if (branches && (branches.onSuccess || branches.onFailure)) {
|
|
861
|
+
gridCol++;
|
|
862
|
+
|
|
863
|
+
if (branches.onSuccess) {
|
|
864
|
+
var succStep = findStep(branches.onSuccess);
|
|
865
|
+
html += '<div class="flow-node branch-success" data-id="branch-success-' + col + '" style="grid-row:1;grid-column:' + gridCol + '">';
|
|
866
|
+
html += '<div class="node-type">' + esc(branches.onSuccess) + '</div>';
|
|
867
|
+
html += '<div class="node-label label-success">on success</div>';
|
|
868
|
+
if (succStep && succStep.retry) html += '<span class="badge badge-retry">' + succStep.retry.maxRetries + ' retries</span>';
|
|
869
|
+
html += '</div>';
|
|
870
|
+
} else {
|
|
871
|
+
html += '<div class="flow-spacer" style="grid-row:1;grid-column:' + gridCol + '"></div>';
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Empty middle row for branches column
|
|
875
|
+
html += '<div class="flow-spacer" style="grid-row:2;grid-column:' + gridCol + '"></div>';
|
|
876
|
+
|
|
877
|
+
if (branches.onFailure) {
|
|
878
|
+
var failStep = findStep(branches.onFailure);
|
|
879
|
+
html += '<div class="flow-node branch-failure" data-id="branch-failure-' + col + '" style="grid-row:3;grid-column:' + gridCol + '">';
|
|
880
|
+
html += '<div class="node-type">' + esc(branches.onFailure) + '</div>';
|
|
881
|
+
html += '<div class="node-label label-failure">on failure</div>';
|
|
882
|
+
if (failStep && failStep.retry) html += '<span class="badge badge-retry">' + failStep.retry.maxRetries + ' retries</span>';
|
|
883
|
+
html += '</div>';
|
|
884
|
+
} else {
|
|
885
|
+
html += '<div class="flow-spacer" style="grid-row:3;grid-column:' + gridCol + '"></div>';
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
gridCol++;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
html += '</div></div>';
|
|
893
|
+
|
|
894
|
+
// Workflow-level callbacks
|
|
895
|
+
var hasCallbacks = wf.onComplete || wf.onSuccess || wf.onFailure;
|
|
896
|
+
if (hasCallbacks) {
|
|
897
|
+
html += '<div class="workflow-callbacks">';
|
|
898
|
+
if (wf.onComplete) html += '<span class="callback-tag callback-complete">onComplete \u2192 ' + esc(wf.onComplete) + '</span>';
|
|
899
|
+
if (wf.onSuccess) html += '<span class="callback-tag callback-success">onSuccess \u2192 ' + esc(wf.onSuccess) + '</span>';
|
|
900
|
+
if (wf.onFailure) html += '<span class="callback-tag callback-failure">onFailure \u2192 ' + esc(wf.onFailure) + '</span>';
|
|
901
|
+
html += '</div>';
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
html += '</div>';
|
|
905
|
+
return html;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function esc(str) {
|
|
909
|
+
const div = document.createElement('div');
|
|
910
|
+
div.textContent = str;
|
|
911
|
+
return div.innerHTML;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
window.addEventListener('hashchange', route);
|
|
915
|
+
route();
|
|
916
|
+
</script>
|
|
917
|
+
</body>
|
|
918
|
+
</html>`;
|
|
919
|
+
}
|
|
920
|
+
//# sourceMappingURL=dashboard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dashboard.js","sourceRoot":"","sources":["../src/dashboard.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,gBAAgB;IAC9B,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAo5BD,CAAC;AACT,CAAC"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { Synkro } from "@synkro/core";
|
|
3
|
+
export type DashboardOptions = {
|
|
4
|
+
basePath?: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function createDashboardHandler(synkro: Synkro, options?: DashboardOptions): (req: IncomingMessage, res: ServerResponse) => void;
|
|
7
|
+
//# sourceMappingURL=handler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AACjE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAI3C,MAAM,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,MAAM,EACd,OAAO,CAAC,EAAE,gBAAgB,GACzB,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,KAAK,IAAI,CA6CrD"}
|
package/dist/handler.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { getDashboardHtml } from "./dashboard.js";
|
|
2
|
+
export function createDashboardHandler(synkro, options) {
|
|
3
|
+
const basePath = normalizeBasePath(options?.basePath ?? "/");
|
|
4
|
+
return (req, res) => {
|
|
5
|
+
if (req.method !== "GET") {
|
|
6
|
+
res.writeHead(405);
|
|
7
|
+
res.end("Method Not Allowed");
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const url = req.url ?? "/";
|
|
11
|
+
const path = basePath ? url.replace(basePath, "") || "/" : url;
|
|
12
|
+
if (path === "/" || path === "") {
|
|
13
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
14
|
+
res.end(getDashboardHtml());
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (path === "/api/introspection") {
|
|
18
|
+
const data = synkro.introspect();
|
|
19
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
20
|
+
res.end(JSON.stringify(data));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const eventMetricsMatch = path.match(/^\/api\/events\/(.+)$/);
|
|
24
|
+
if (eventMetricsMatch?.[1]) {
|
|
25
|
+
const eventType = decodeURIComponent(eventMetricsMatch[1]);
|
|
26
|
+
synkro
|
|
27
|
+
.getEventMetrics(eventType)
|
|
28
|
+
.then((data) => {
|
|
29
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
30
|
+
res.end(JSON.stringify(data));
|
|
31
|
+
})
|
|
32
|
+
.catch(() => {
|
|
33
|
+
res.writeHead(500);
|
|
34
|
+
res.end("Internal Server Error");
|
|
35
|
+
});
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
res.writeHead(404);
|
|
39
|
+
res.end("Not Found");
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function normalizeBasePath(path) {
|
|
43
|
+
const normalized = "/" + path.replace(/^\/+|\/+$/g, "");
|
|
44
|
+
return normalized === "/" ? "" : normalized;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=handler.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handler.js","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAMlD,MAAM,UAAU,sBAAsB,CACpC,MAAc,EACd,OAA0B;IAE1B,MAAM,QAAQ,GAAG,iBAAiB,CAAC,OAAO,EAAE,QAAQ,IAAI,GAAG,CAAC,CAAC;IAE7D,OAAO,CAAC,GAAoB,EAAE,GAAmB,EAAE,EAAE;QACnD,IAAI,GAAG,CAAC,MAAM,KAAK,KAAK,EAAE,CAAC;YACzB,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;YACnB,GAAG,CAAC,GAAG,CAAC,oBAAoB,CAAC,CAAC;YAC9B,OAAO;QACT,CAAC;QAED,MAAM,GAAG,GAAG,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC;QAC3B,MAAM,IAAI,GAAG,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC;QAE/D,IAAI,IAAI,KAAK,GAAG,IAAI,IAAI,KAAK,EAAE,EAAE,CAAC;YAChC,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,cAAc,EAAE,0BAA0B,EAAE,CAAC,CAAC;YACnE,GAAG,CAAC,GAAG,CAAC,gBAAgB,EAAE,CAAC,CAAC;YAC5B,OAAO;QACT,CAAC;QAED,IAAI,IAAI,KAAK,oBAAoB,EAAE,CAAC;YAClC,MAAM,IAAI,GAAG,MAAM,CAAC,UAAU,EAAE,CAAC;YACjC,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE,CAAC,CAAC;YAC3D,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC;YAC9B,OAAO;QACT,CAAC;QAED,MAAM,iBAAiB,GAAG,IAAI,CAAC,KAAK,CAAC,uBAAuB,CAAC,CAAC;QAC9D,IAAI,iBAAiB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC3B,MAAM,SAAS,GAAG,kBAAkB,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,CAAC;YAC3D,MAAM;iBACH,eAAe,CAAC,SAAS,CAAC;iBAC1B,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE;gBACb,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE,CAAC,CAAC;gBAC3D,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC;YAChC,CAAC,CAAC;iBACD,KAAK,CAAC,GAAG,EAAE;gBACV,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;gBACnB,GAAG,CAAC,GAAG,CAAC,uBAAuB,CAAC,CAAC;YACnC,CAAC,CAAC,CAAC;YACL,OAAO;QACT,CAAC;QAED,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;QACnB,GAAG,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IACvB,CAAC,CAAC;AACJ,CAAC;AAED,SAAS,iBAAiB,CAAC,IAAY;IACrC,MAAM,UAAU,GAAG,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC;IACxD,OAAO,UAAU,KAAK,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC;AAC9C,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC;AACtD,YAAY,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,cAAc,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@synkro/ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Dashboard UI for @synkro/core",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"keywords": [
|
|
19
|
+
"workflow",
|
|
20
|
+
"state-machine",
|
|
21
|
+
"orchestrator",
|
|
22
|
+
"event-driven",
|
|
23
|
+
"dashboard"
|
|
24
|
+
],
|
|
25
|
+
"author": "buemura",
|
|
26
|
+
"license": "ISC",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/buemura/synkro.git"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/buemura/synkro/packages/ui#readme",
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@synkro/core": "^0.7.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^25.3.3",
|
|
37
|
+
"typescript": "^5.7.0",
|
|
38
|
+
"@synkro/core": "0.7.0"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc",
|
|
42
|
+
"type-check": "tsc --noEmit"
|
|
43
|
+
}
|
|
44
|
+
}
|