Skip to content

Commit 98365e1

Browse files
Interactive UI for workflows (#4604)
* displays launchers in workflow as an interactive DAG * allows addition and deletion of both launchers and edges connecting launchers * added dag class in client side js to detect any cyclic dependency immediately * uses grid logic to keep each launcher separated in a fixed spot, not allowing another to take its place. * uses scroll feature to allow the whole graph to be viewed and modified regardless of zoom/screen size * has custom zoom feature to accommodate various graph sizes, with a max size of 32x32 launchers * expands click area around edges to make edge selection easier
1 parent 82a943d commit 98365e1

File tree

8 files changed

+868
-62
lines changed

8 files changed

+868
-62
lines changed

apps/dashboard/app/assets/stylesheets/application.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ small.form-text {
178178
@import "support_ticket";
179179
@import "data_tables";
180180
@import "projects";
181+
@import "workflows";
181182
@import "scripts";
182183
@import "common";
183184
@import "module_browser";
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
$cols: 16;
2+
$rows: 16;
3+
// Now we can increse grid size in 1:1 ratio to keep workspace wrapper size same
4+
$cell_w: 204; // (204+20) * 4 = 900px width for workspace wrapper
5+
$cell_h: 130; // (130+20) * 4 = 600px height for workspace wrapper
6+
$gap: 20;
7+
8+
:root {
9+
--bg: #f7f7fb;
10+
--ink: #222;
11+
--ink-muted: #666;
12+
--accent: #2266ff;
13+
--accent-weak: #cfe0ff;
14+
--danger: #b71c1c;
15+
--danger-weak: #c628285c;
16+
--box-selected: #ffecb3;
17+
// To pass on the variable to javascript
18+
--grid_cols: #{$cols};
19+
--grid_rows: #{$rows};
20+
--cell_w: #{$cell_w};
21+
--cell_h: #{$cell_h};
22+
--gap: #{$gap};
23+
}
24+
25+
html, body {
26+
height: 100%;
27+
margin: 0;
28+
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
29+
color: var(--ink);
30+
}
31+
32+
.app {
33+
display: grid;
34+
grid-template-rows: auto 1fr;
35+
height: 100%;
36+
background: var(--bg);
37+
}
38+
39+
.toolbar {
40+
display: flex;
41+
gap: .5rem;
42+
align-items: center;
43+
padding: .5rem .75rem;
44+
border-bottom: 1px solid #e6e8ee;
45+
background: #fff;
46+
position: sticky;
47+
top: 0;
48+
z-index: 5;
49+
}
50+
51+
.toolbar button {
52+
border: 1.5px solid #d5d8e0;
53+
background: #fff;
54+
border-radius: 10px;
55+
padding: .35rem .7rem;
56+
cursor: pointer;
57+
font-weight: 600;
58+
transition: all 0.15s ease-in-out;
59+
}
60+
61+
.toolbar button.active {
62+
border-color: var(--accent);
63+
box-shadow: 0 0 0 4px var(--accent-weak);
64+
transform: scale(0.98);
65+
}
66+
67+
.toolbar .danger:active {
68+
border-color: var(--danger);
69+
box-shadow: 0 0 0 4px var(--danger-weak);
70+
transform: scale(0.98);
71+
}
72+
73+
.hint {
74+
margin-left: auto;
75+
color: var(--ink-muted);
76+
font-size: .9rem;
77+
}
78+
79+
.workspace-wrapper {
80+
position: relative;
81+
width: 100%;
82+
height: 600px;
83+
overflow: hidden;
84+
border: 1px solid #ddd;
85+
}
86+
87+
.zoom-controls {
88+
position: absolute;
89+
left: 2px;
90+
top: 50%;
91+
transform: translateY(-50%);
92+
display: flex;
93+
flex-direction: column;
94+
gap: 0.5rem;
95+
z-index: 20;
96+
user-select: none;
97+
}
98+
99+
.zoom-controls button {
100+
width: 45px;
101+
height: 30px;
102+
border-radius: 8px;
103+
border: 1px solid #d5d8e0;
104+
background: #fff;
105+
font-weight: 700;
106+
cursor: pointer;
107+
padding: 0;
108+
display: inline-flex;
109+
align-items: center;
110+
justify-content: center;
111+
}
112+
113+
.zoom-controls button:active {
114+
transform: translateY(1px);
115+
}
116+
117+
.zoom-controls button[aria-pressed="true"] {
118+
box-shadow: 0 0 0 3px var(--accent-weak);
119+
border-color: var(--accent);
120+
}
121+
122+
.workspace {
123+
position: absolute;
124+
top: 0;
125+
left: 0;
126+
right: 0;
127+
bottom: 0;
128+
overflow: auto;
129+
background: var(--bg);
130+
}
131+
132+
.stage-zoom {
133+
width: 100%;
134+
height: 100%;
135+
transform-origin: top left;
136+
will-change: transform;
137+
}
138+
139+
.stage {
140+
display: grid;
141+
grid-template-columns: repeat(#{$cols}, #{$cell_w});
142+
grid-template-rows: repeat(#{$rows}, #{$cell_h});
143+
gap: #{$gap};
144+
background: repeating-conic-gradient(#fafbff 0% 25%, #f5f7ff 0% 50%) 50%/20px 20px;
145+
padding: 20px;
146+
// count * width + (count-1) * gap
147+
min-width: calc(#{$cols} * #{$cell_w}px + (#{$cols} - 1) * #{$gap}px);
148+
min-height: calc(#{$rows} * #{$cell_h}px + (#{$rows} - 1) * #{$gap}px);
149+
position: relative;
150+
}
151+
152+
svg.edges {
153+
position: absolute;
154+
inset: 0;
155+
pointer-events: none;
156+
z-index: 1;
157+
}
158+
159+
.edge {
160+
stroke: var(--accent);
161+
stroke-width: 2;
162+
marker-end: url(#arrow);
163+
pointer-events: none;
164+
}
165+
166+
.edge.click-area {
167+
stroke: transparent;
168+
stroke-width: 20px;
169+
pointer-events: stroke;
170+
cursor: pointer;
171+
marker-end: none;
172+
}
173+
174+
.edge.selected {
175+
stroke: var(--danger);
176+
stroke-width: 3;
177+
filter: drop-shadow(0 0 4px var(--danger-weak));
178+
}
179+
180+
.launcher-box {
181+
position: absolute;
182+
background: #fff;
183+
border-radius: 10px;
184+
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
185+
user-select: none;
186+
padding: 0.5rem;
187+
transform-origin: top left;
188+
transition: transform 0.15s;
189+
will-change: transform;
190+
}
191+
192+
.launcher-title-grab {
193+
font-size: 1em;
194+
font-weight: bold;
195+
cursor: grab;
196+
user-select: none;
197+
}
198+
199+
.launcher-box.selected {
200+
outline: 3px solid var(--accent-weak);
201+
background: var(--box-selected);
202+
}
203+
204+
.launcher-box.connect-queued {
205+
outline: 3px dashed var(--accent);
206+
}

apps/dashboard/app/controllers/launchers_controller.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ def submit
7575
end
7676
end
7777

78+
# GET /projects/:project_id/launchers/:id/render_button
79+
def render_button
80+
launcher = Launcher.find(show_launcher_params[:id], @project.directory)
81+
@valid_project = Launcher.clusters?
82+
@remove_delete_button = true
83+
render(partial: 'projects/launcher_buttons', locals: { launcher: launcher })
84+
end
85+
7886
private
7987

8088
def find_launcher
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// This class will help us to detect if any new edge can lead to cycle or not
2+
// Thus we can alert user to avoid that edge thus resolving cycle issue on UI
3+
4+
// Directed Acyclic Graph
5+
export class DAG {
6+
#launcher_list;
7+
#adjacency_list;
8+
#visited;
9+
#stack;
10+
#has_cycle;
11+
12+
constructor() {
13+
this.#launcher_list = new Set();
14+
this.#adjacency_list = {};
15+
}
16+
17+
addEdge(fromId, toId) {
18+
if (!this.#launcher_list.has(fromId)) {
19+
this.#launcher_list.add(fromId);
20+
}
21+
22+
if (!this.#launcher_list.has(toId)) {
23+
this.#launcher_list.add(toId);
24+
}
25+
26+
if (!this.#adjacency_list[fromId]) {
27+
this.#adjacency_list[fromId] = [];
28+
}
29+
this.#adjacency_list[fromId].push(toId);
30+
31+
this.#has_cycle = false;
32+
this.#visited = new Set();
33+
this.#stack = new Set();
34+
this.#launcher_list.forEach(l => this.#detectCycle(l));
35+
36+
// Remove the added edge from the adjacency list if cycle detected
37+
if (this.#has_cycle === true) {
38+
this.#adjacency_list[fromId].pop();
39+
}
40+
}
41+
42+
removeEdge(fromId, toId) {
43+
if (!this.#adjacency_list[fromId]) return;
44+
45+
if (this.#adjacency_list[fromId].includes(toId)) {
46+
this.#adjacency_list[fromId] = this.#adjacency_list[fromId].filter(x => x !== toId);
47+
}
48+
}
49+
50+
removeLauncher(id) {
51+
this.#launcher_list.delete(id);
52+
delete this.#adjacency_list[id];
53+
54+
for (const from in this.#adjacency_list) {
55+
this.#adjacency_list[from] = this.#adjacency_list[from].filter(x => x !== id);
56+
}
57+
}
58+
59+
hasCycle() {
60+
return this.#has_cycle;
61+
}
62+
63+
// Basic dfs on graph to find a cycle
64+
#detectCycle(id) {
65+
if (this.#stack.has(id)) {
66+
this.#has_cycle = true;
67+
return;
68+
}
69+
if (this.#visited.has(id)) return;
70+
71+
this.#stack.add(id);
72+
this.#visited.add(id);
73+
for (const l of this.#adjacency_list[id] || []) {
74+
this.#detectCycle(l);
75+
}
76+
this.#stack.delete(id);
77+
}
78+
}

0 commit comments

Comments
 (0)