@auser/workflow-graph-web 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/dist/index.d.ts +186 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +190 -0
- package/dist/index.js.map +1 -0
- package/package.json +37 -0
- package/src/index.ts +385 -0
- package/src/wasm.d.ts +19 -0
- package/wasm/workflow_graph_web.js +1079 -0
- package/wasm/workflow_graph_web_bg.wasm +0 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workflow-graph — Interactive workflow DAG visualizer
|
|
3
|
+
*
|
|
4
|
+
* TypeScript wrapper around the WASM module.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface Workflow {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
trigger: string;
|
|
11
|
+
jobs: Job[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Job {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
status: 'queued' | 'running' | 'success' | 'failure' | 'skipped' | 'cancelled';
|
|
18
|
+
command: string;
|
|
19
|
+
duration_secs?: number;
|
|
20
|
+
started_at?: number;
|
|
21
|
+
depends_on: string[];
|
|
22
|
+
output?: string;
|
|
23
|
+
required_labels?: string[];
|
|
24
|
+
max_retries?: number;
|
|
25
|
+
attempt?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── Theme types ─────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export interface ThemeColors {
|
|
31
|
+
success?: string;
|
|
32
|
+
failure?: string;
|
|
33
|
+
running?: string;
|
|
34
|
+
queued?: string;
|
|
35
|
+
skipped?: string;
|
|
36
|
+
cancelled?: string;
|
|
37
|
+
node_bg?: string;
|
|
38
|
+
node_border?: string;
|
|
39
|
+
text?: string;
|
|
40
|
+
text_secondary?: string;
|
|
41
|
+
bg?: string;
|
|
42
|
+
graph_bg?: string;
|
|
43
|
+
edge?: string;
|
|
44
|
+
junction?: string;
|
|
45
|
+
highlight?: string;
|
|
46
|
+
selected?: string;
|
|
47
|
+
header_text?: string;
|
|
48
|
+
header_trigger?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ThemeFonts {
|
|
52
|
+
family?: string;
|
|
53
|
+
size_name?: number;
|
|
54
|
+
size_duration?: number;
|
|
55
|
+
size_header?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ThemeLayout {
|
|
59
|
+
node_width?: number;
|
|
60
|
+
node_height?: number;
|
|
61
|
+
node_radius?: number;
|
|
62
|
+
h_gap?: number;
|
|
63
|
+
v_gap?: number;
|
|
64
|
+
header_height?: number;
|
|
65
|
+
padding?: number;
|
|
66
|
+
junction_dot_radius?: number;
|
|
67
|
+
status_icon_radius?: number;
|
|
68
|
+
status_icon_margin?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type LayoutDirection = 'LeftToRight' | 'TopToBottom';
|
|
72
|
+
|
|
73
|
+
/** Per-edge style override. */
|
|
74
|
+
export interface EdgeStyle {
|
|
75
|
+
/** CSS color for this edge (overrides theme default). */
|
|
76
|
+
color?: string;
|
|
77
|
+
/** Line width in px. */
|
|
78
|
+
width?: number;
|
|
79
|
+
/** Dash pattern array (e.g., [5, 3] for dashed). Empty = solid. */
|
|
80
|
+
dash?: number[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Internationalization labels for status text and duration formatting. */
|
|
84
|
+
export interface Labels {
|
|
85
|
+
queued?: string;
|
|
86
|
+
running?: string;
|
|
87
|
+
success?: string;
|
|
88
|
+
failure?: string;
|
|
89
|
+
skipped?: string;
|
|
90
|
+
cancelled?: string;
|
|
91
|
+
/** Duration format for minutes+seconds. Use {m} and {s} placeholders. Default: "{m}m {s}s" */
|
|
92
|
+
duration_minutes?: string;
|
|
93
|
+
/** Duration format for seconds only. Use {s} placeholder. Default: "{s}s" */
|
|
94
|
+
duration_seconds?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Theme configuration. All fields are optional — omitted fields use light-theme defaults. */
|
|
98
|
+
export interface ThemeConfig {
|
|
99
|
+
colors?: ThemeColors;
|
|
100
|
+
fonts?: ThemeFonts;
|
|
101
|
+
layout?: ThemeLayout;
|
|
102
|
+
direction?: LayoutDirection;
|
|
103
|
+
labels?: Labels;
|
|
104
|
+
/** Per-edge style overrides keyed by "from_id->to_id". */
|
|
105
|
+
edge_styles?: Record<string, EdgeStyle>;
|
|
106
|
+
/** Show the minimap overlay. Default: false. */
|
|
107
|
+
minimap?: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Custom node render callback.
|
|
112
|
+
* Called for each node during rendering.
|
|
113
|
+
* @param x - Node x position
|
|
114
|
+
* @param y - Node y position
|
|
115
|
+
* @param w - Node width
|
|
116
|
+
* @param h - Node height
|
|
117
|
+
* @param job - The job data object
|
|
118
|
+
* @returns `true` to skip default node rendering, `false` to render default on top.
|
|
119
|
+
*/
|
|
120
|
+
export type OnRenderNode = (
|
|
121
|
+
x: number,
|
|
122
|
+
y: number,
|
|
123
|
+
w: number,
|
|
124
|
+
h: number,
|
|
125
|
+
job: Job,
|
|
126
|
+
) => boolean;
|
|
127
|
+
|
|
128
|
+
export interface GraphOptions {
|
|
129
|
+
onNodeClick?: (jobId: string) => void;
|
|
130
|
+
onNodeHover?: (jobId: string | null) => void;
|
|
131
|
+
onCanvasClick?: () => void;
|
|
132
|
+
onSelectionChange?: (selectedIds: string[]) => void;
|
|
133
|
+
onNodeDragEnd?: (jobId: string, x: number, y: number) => void;
|
|
134
|
+
/** Called when an edge is clicked. Requires bezier hit testing. */
|
|
135
|
+
onEdgeClick?: (fromId: string, toId: string) => void;
|
|
136
|
+
/** Custom node rendering callback. */
|
|
137
|
+
onRenderNode?: OnRenderNode;
|
|
138
|
+
/** Custom theme configuration. */
|
|
139
|
+
theme?: ThemeConfig;
|
|
140
|
+
/** Automatically resize the canvas when the container resizes. */
|
|
141
|
+
autoResize?: boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** WASM module interface — typed subset of exported functions. */
|
|
145
|
+
interface WasmModule {
|
|
146
|
+
default(moduleOrPath?: string | URL | Request | RequestInfo): Promise<void>;
|
|
147
|
+
render_workflow(
|
|
148
|
+
canvasId: string, json: string,
|
|
149
|
+
onNodeClick: ((jobId: string) => void) | null,
|
|
150
|
+
onNodeHover: ((jobId: string | null) => void) | null,
|
|
151
|
+
onCanvasClick: (() => void) | null,
|
|
152
|
+
onSelectionChange: ((ids: string[]) => void) | null,
|
|
153
|
+
onNodeDragEnd: ((jobId: string, x: number, y: number) => void) | null,
|
|
154
|
+
themeJson: string | null,
|
|
155
|
+
): void;
|
|
156
|
+
update_workflow_data(canvasId: string, json: string): void;
|
|
157
|
+
set_theme(canvasId: string, json: string): void;
|
|
158
|
+
set_auto_resize(canvasId: string, enabled: boolean): void;
|
|
159
|
+
set_on_edge_click(canvasId: string, cb: (fromId: string, toId: string) => void): void;
|
|
160
|
+
set_on_render_node(canvasId: string, cb: OnRenderNode): void;
|
|
161
|
+
select_node(canvasId: string, jobId: string): void;
|
|
162
|
+
deselect_all(canvasId: string): void;
|
|
163
|
+
reset_layout(canvasId: string): void;
|
|
164
|
+
zoom_to_fit(canvasId: string): void;
|
|
165
|
+
set_zoom(canvasId: string, level: number): void;
|
|
166
|
+
get_node_positions(canvasId: string): Record<string, [number, number]>;
|
|
167
|
+
set_node_positions(canvasId: string, json: string): void;
|
|
168
|
+
destroy(canvasId: string): void;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let wasmModule: WasmModule | null = null;
|
|
172
|
+
let customWasmUrl: string | URL | undefined;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Configure the URL to the WASM binary. Call this before creating any WorkflowGraph instances.
|
|
176
|
+
* Only needed if the default resolution doesn't work in your environment.
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* ```typescript
|
|
180
|
+
* setWasmUrl('/assets/workflow_graph_web_bg.wasm');
|
|
181
|
+
* // or from a CDN:
|
|
182
|
+
* setWasmUrl('https://cdn.example.com/wasm/workflow_graph_web_bg.wasm');
|
|
183
|
+
* ```
|
|
184
|
+
*/
|
|
185
|
+
export function setWasmUrl(url: string | URL): void {
|
|
186
|
+
customWasmUrl = url;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function ensureWasm(): Promise<WasmModule> {
|
|
190
|
+
if (wasmModule) return wasmModule;
|
|
191
|
+
// Dynamic import of the WASM glue code bundled in wasm/
|
|
192
|
+
// @ts-expect-error — external wasm-pack artifact, not a TS module
|
|
193
|
+
const mod: WasmModule = await import('../wasm/workflow_graph_web.js');
|
|
194
|
+
await mod.default(customWasmUrl);
|
|
195
|
+
wasmModule = mod;
|
|
196
|
+
return wasmModule;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Interactive workflow DAG graph component.
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* ```typescript
|
|
204
|
+
* const graph = new WorkflowGraph(document.getElementById('container')!, {
|
|
205
|
+
* onNodeClick: (jobId) => console.log('clicked', jobId),
|
|
206
|
+
* theme: darkTheme,
|
|
207
|
+
* autoResize: true,
|
|
208
|
+
* });
|
|
209
|
+
* graph.setWorkflow(workflowData);
|
|
210
|
+
* ```
|
|
211
|
+
*/
|
|
212
|
+
export class WorkflowGraph {
|
|
213
|
+
private canvasId: string;
|
|
214
|
+
private canvas: HTMLCanvasElement;
|
|
215
|
+
private options: GraphOptions;
|
|
216
|
+
private initialized = false;
|
|
217
|
+
|
|
218
|
+
constructor(container: HTMLElement, options: GraphOptions = {}) {
|
|
219
|
+
this.canvasId = `gg-${Math.random().toString(36).slice(2, 9)}`;
|
|
220
|
+
this.canvas = document.createElement('canvas');
|
|
221
|
+
this.canvas.id = this.canvasId;
|
|
222
|
+
this.canvas.style.display = 'block';
|
|
223
|
+
this.canvas.setAttribute('role', 'img');
|
|
224
|
+
this.canvas.setAttribute('aria-label', 'Workflow DAG visualization');
|
|
225
|
+
this.canvas.tabIndex = 0;
|
|
226
|
+
container.appendChild(this.canvas);
|
|
227
|
+
this.options = options;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Render a workflow. Call this on initial load. */
|
|
231
|
+
async setWorkflow(workflow: Workflow): Promise<void> {
|
|
232
|
+
const wasm = await ensureWasm();
|
|
233
|
+
const json = JSON.stringify(workflow);
|
|
234
|
+
const themeJson = this.options.theme ? JSON.stringify(this.options.theme) : null;
|
|
235
|
+
wasm.render_workflow(
|
|
236
|
+
this.canvasId,
|
|
237
|
+
json,
|
|
238
|
+
this.options.onNodeClick || null,
|
|
239
|
+
this.options.onNodeHover || null,
|
|
240
|
+
this.options.onCanvasClick || null,
|
|
241
|
+
this.options.onSelectionChange || null,
|
|
242
|
+
this.options.onNodeDragEnd || null,
|
|
243
|
+
themeJson,
|
|
244
|
+
);
|
|
245
|
+
this.initialized = true;
|
|
246
|
+
|
|
247
|
+
// Wire up optional callbacks that use separate WASM functions
|
|
248
|
+
if (this.options.onEdgeClick) {
|
|
249
|
+
wasm.set_on_edge_click(this.canvasId, this.options.onEdgeClick);
|
|
250
|
+
}
|
|
251
|
+
if (this.options.onRenderNode) {
|
|
252
|
+
wasm.set_on_render_node(this.canvasId, this.options.onRenderNode);
|
|
253
|
+
}
|
|
254
|
+
if (this.options.autoResize) {
|
|
255
|
+
wasm.set_auto_resize(this.canvasId, true);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Update workflow data without resetting positions or zoom. */
|
|
260
|
+
async updateStatus(workflow: Workflow): Promise<void> {
|
|
261
|
+
const wasm = await ensureWasm();
|
|
262
|
+
if (this.initialized) {
|
|
263
|
+
wasm.update_workflow_data(this.canvasId, JSON.stringify(workflow));
|
|
264
|
+
} else {
|
|
265
|
+
await this.setWorkflow(workflow);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Update the theme at runtime. Triggers re-layout if dimensions or direction changed. */
|
|
270
|
+
async setTheme(theme: ThemeConfig): Promise<void> {
|
|
271
|
+
const wasm = await ensureWasm();
|
|
272
|
+
wasm.set_theme(this.canvasId, JSON.stringify(theme));
|
|
273
|
+
this.options.theme = theme;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Enable or disable auto-resize via ResizeObserver. */
|
|
277
|
+
async setAutoResize(enabled: boolean): Promise<void> {
|
|
278
|
+
const wasm = await ensureWasm();
|
|
279
|
+
wasm.set_auto_resize(this.canvasId, enabled);
|
|
280
|
+
this.options.autoResize = enabled;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Programmatically select a node. */
|
|
284
|
+
async selectNode(jobId: string): Promise<void> {
|
|
285
|
+
const wasm = await ensureWasm();
|
|
286
|
+
wasm.select_node(this.canvasId, jobId);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Deselect all nodes. */
|
|
290
|
+
async deselectAll(): Promise<void> {
|
|
291
|
+
const wasm = await ensureWasm();
|
|
292
|
+
wasm.deselect_all(this.canvasId);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Reset node positions to auto-layout. */
|
|
296
|
+
async resetLayout(): Promise<void> {
|
|
297
|
+
const wasm = await ensureWasm();
|
|
298
|
+
wasm.reset_layout(this.canvasId);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Zoom to fit the entire graph. */
|
|
302
|
+
async zoomToFit(): Promise<void> {
|
|
303
|
+
const wasm = await ensureWasm();
|
|
304
|
+
wasm.zoom_to_fit(this.canvasId);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Set zoom level (0.25 to 4.0). */
|
|
308
|
+
async setZoom(level: number): Promise<void> {
|
|
309
|
+
const wasm = await ensureWasm();
|
|
310
|
+
wasm.set_zoom(this.canvasId, level);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Get current node positions for persistence. */
|
|
314
|
+
async getNodePositions(): Promise<Record<string, [number, number]>> {
|
|
315
|
+
const wasm = await ensureWasm();
|
|
316
|
+
return wasm.get_node_positions(this.canvasId);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Restore previously saved node positions. */
|
|
320
|
+
async setNodePositions(positions: Record<string, [number, number]>): Promise<void> {
|
|
321
|
+
const wasm = await ensureWasm();
|
|
322
|
+
wasm.set_node_positions(this.canvasId, JSON.stringify(positions));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Clean up event listeners, resize observer, and remove the canvas. */
|
|
326
|
+
async destroy(): Promise<void> {
|
|
327
|
+
const wasm = await ensureWasm();
|
|
328
|
+
wasm.destroy(this.canvasId);
|
|
329
|
+
this.canvas.remove();
|
|
330
|
+
this.initialized = false;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ─── Preset themes ───────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
/** GitHub Actions dark mode theme preset. */
|
|
337
|
+
export const darkTheme: ThemeConfig = {
|
|
338
|
+
colors: {
|
|
339
|
+
success: '#3fb950',
|
|
340
|
+
failure: '#f85149',
|
|
341
|
+
running: '#d29922',
|
|
342
|
+
queued: '#8b949e',
|
|
343
|
+
skipped: '#8b949e',
|
|
344
|
+
cancelled: '#8b949e',
|
|
345
|
+
node_bg: '#161b22',
|
|
346
|
+
node_border: '#30363d',
|
|
347
|
+
text: '#e6edf3',
|
|
348
|
+
text_secondary: '#8b949e',
|
|
349
|
+
bg: '#0d1117',
|
|
350
|
+
graph_bg: '#161b22',
|
|
351
|
+
edge: '#30363d',
|
|
352
|
+
junction: '#484f58',
|
|
353
|
+
highlight: '#58a6ff',
|
|
354
|
+
selected: '#58a6ff',
|
|
355
|
+
header_text: '#e6edf3',
|
|
356
|
+
header_trigger: '#8b949e',
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
/** Default GitHub Actions light theme (no-op, but useful for toggling back). */
|
|
361
|
+
export const lightTheme: ThemeConfig = {};
|
|
362
|
+
|
|
363
|
+
/** WCAG AA high-contrast theme for accessibility. */
|
|
364
|
+
export const highContrastTheme: ThemeConfig = {
|
|
365
|
+
colors: {
|
|
366
|
+
success: '#008000',
|
|
367
|
+
failure: '#ff0000',
|
|
368
|
+
running: '#ff8c00',
|
|
369
|
+
queued: '#555555',
|
|
370
|
+
skipped: '#555555',
|
|
371
|
+
cancelled: '#555555',
|
|
372
|
+
node_bg: '#ffffff',
|
|
373
|
+
node_border: '#000000',
|
|
374
|
+
text: '#000000',
|
|
375
|
+
text_secondary: '#333333',
|
|
376
|
+
bg: '#ffffff',
|
|
377
|
+
graph_bg: '#f0f0f0',
|
|
378
|
+
edge: '#000000',
|
|
379
|
+
junction: '#000000',
|
|
380
|
+
highlight: '#0000ff',
|
|
381
|
+
selected: '#0000ff',
|
|
382
|
+
header_text: '#000000',
|
|
383
|
+
header_trigger: '#333333',
|
|
384
|
+
},
|
|
385
|
+
};
|
package/src/wasm.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Type declarations for the WASM glue module
|
|
2
|
+
declare module '../wasm/workflow_graph_web.js' {
|
|
3
|
+
const init: (moduleOrPath?: string | URL | Request | RequestInfo) => Promise<void>;
|
|
4
|
+
export default init;
|
|
5
|
+
export function render_workflow(...args: unknown[]): void;
|
|
6
|
+
export function update_workflow_data(...args: unknown[]): void;
|
|
7
|
+
export function set_theme(...args: unknown[]): void;
|
|
8
|
+
export function set_auto_resize(...args: unknown[]): void;
|
|
9
|
+
export function set_on_edge_click(...args: unknown[]): void;
|
|
10
|
+
export function set_on_render_node(...args: unknown[]): void;
|
|
11
|
+
export function select_node(...args: unknown[]): void;
|
|
12
|
+
export function deselect_all(...args: unknown[]): void;
|
|
13
|
+
export function reset_layout(...args: unknown[]): void;
|
|
14
|
+
export function zoom_to_fit(...args: unknown[]): void;
|
|
15
|
+
export function set_zoom(...args: unknown[]): void;
|
|
16
|
+
export function get_node_positions(...args: unknown[]): unknown;
|
|
17
|
+
export function set_node_positions(...args: unknown[]): void;
|
|
18
|
+
export function destroy(...args: unknown[]): void;
|
|
19
|
+
}
|