orchestr8 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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +186 -0
- data/Rakefile +11 -0
- data/app/assets/stylesheets/orchestr8/application.css +436 -0
- data/app/controllers/orchestr8/application_controller.rb +7 -0
- data/app/controllers/orchestr8/steps_controller.rb +12 -0
- data/app/controllers/orchestr8/workflows_controller.rb +46 -0
- data/app/models/orchestr8/step.rb +70 -0
- data/app/models/orchestr8/step_dependency.rb +12 -0
- data/app/models/orchestr8/workflow.rb +76 -0
- data/app/views/layouts/orchestr8/application.html.erb +206 -0
- data/app/views/orchestr8/workflows/_step_node.html.erb +35 -0
- data/app/views/orchestr8/workflows/index.html.erb +71 -0
- data/app/views/orchestr8/workflows/show.html.erb +56 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20260407000000_create_orchestr8_tables.rb +45 -0
- data/lib/generators/orchestr8/install/install_generator.rb +13 -0
- data/lib/orchestr8/callback_handler.rb +36 -0
- data/lib/orchestr8/configuration.rb +11 -0
- data/lib/orchestr8/dsl.rb +74 -0
- data/lib/orchestr8/engine.rb +17 -0
- data/lib/orchestr8/scheduler.rb +80 -0
- data/lib/orchestr8/stepable.rb +42 -0
- data/lib/orchestr8/test_helper.rb +10 -0
- data/lib/orchestr8/version.rb +5 -0
- data/lib/orchestr8.rb +39 -0
- metadata +99 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Orchestr8
|
|
4
|
+
class WorkflowsController < ApplicationController
|
|
5
|
+
helper_method :compute_layers
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@workflows = Workflow.order(created_at: :desc)
|
|
9
|
+
@workflows = @workflows.by_status(params[:status]) if params[:status].present?
|
|
10
|
+
@workflows = @workflows.by_type(params[:type]) if params[:type].present?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def show
|
|
14
|
+
@workflow = Workflow.find(params[:id])
|
|
15
|
+
@steps = @workflow.steps.includes(:dependencies, :dependents)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def retry
|
|
19
|
+
workflow = Workflow.find(params[:id])
|
|
20
|
+
workflow.retry!
|
|
21
|
+
redirect_to workflow_path(workflow), notice: "Retrying failed steps"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def compute_layers(steps)
|
|
27
|
+
return [] if steps.empty?
|
|
28
|
+
|
|
29
|
+
remaining = steps.to_a.dup
|
|
30
|
+
layers = []
|
|
31
|
+
|
|
32
|
+
until remaining.empty?
|
|
33
|
+
layer = remaining.select do |step|
|
|
34
|
+
step.dependencies.all? { |dep| layers.flatten.include?(dep) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
break if layer.empty?
|
|
38
|
+
|
|
39
|
+
layers << layer
|
|
40
|
+
remaining -= layer
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
layers
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Orchestr8
|
|
4
|
+
class Step < ActiveRecord::Base
|
|
5
|
+
self.table_name = "orchestr8_steps"
|
|
6
|
+
|
|
7
|
+
STATUSES = %w[pending enqueued running completed failed].freeze
|
|
8
|
+
FAILURE_MODES = %w[halt continue].freeze
|
|
9
|
+
|
|
10
|
+
belongs_to :workflow, class_name: "Orchestr8::Workflow", inverse_of: :steps
|
|
11
|
+
|
|
12
|
+
has_many :step_dependencies,
|
|
13
|
+
class_name: "Orchestr8::StepDependency",
|
|
14
|
+
foreign_key: :step_id,
|
|
15
|
+
dependent: :destroy
|
|
16
|
+
|
|
17
|
+
has_many :dependencies,
|
|
18
|
+
through: :step_dependencies,
|
|
19
|
+
source: :dependency
|
|
20
|
+
|
|
21
|
+
has_many :dependent_links,
|
|
22
|
+
class_name: "Orchestr8::StepDependency",
|
|
23
|
+
foreign_key: :dependency_id,
|
|
24
|
+
dependent: :destroy
|
|
25
|
+
|
|
26
|
+
has_many :dependents,
|
|
27
|
+
through: :dependent_links,
|
|
28
|
+
source: :step
|
|
29
|
+
|
|
30
|
+
validates :name, presence: true, uniqueness: { scope: :workflow_id }
|
|
31
|
+
validates :job_class, presence: true
|
|
32
|
+
validates :status, inclusion: { in: STATUSES }
|
|
33
|
+
validates :on_failure, inclusion: { in: FAILURE_MODES }
|
|
34
|
+
|
|
35
|
+
STATUSES.each do |status_name|
|
|
36
|
+
define_method(:"#{status_name}?") { status == status_name }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def halt_on_failure?
|
|
40
|
+
on_failure == "halt"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def ready?
|
|
44
|
+
return false unless pending?
|
|
45
|
+
|
|
46
|
+
dependencies.all? { |dep| dep.completed? || (dep.failed? && !dep.halt_on_failure?) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def retry!
|
|
50
|
+
update!(status: "pending", error: nil, started_at: nil, finished_at: nil, active_job_id: nil)
|
|
51
|
+
workflow.update!(status: "running", finished_at: nil) if workflow.failed?
|
|
52
|
+
Scheduler.call(workflow)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
after_update_commit :broadcast_status_change, if: :saved_change_to_status?
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def broadcast_status_change
|
|
60
|
+
Turbo::StreamsChannel.broadcast_replace_to(
|
|
61
|
+
"orchestr8_workflow_#{workflow_id}",
|
|
62
|
+
target: "step-#{id}",
|
|
63
|
+
partial: "orchestr8/workflows/step_node",
|
|
64
|
+
locals: { step: self }
|
|
65
|
+
)
|
|
66
|
+
rescue StandardError
|
|
67
|
+
# Turbo broadcast is best-effort; do not fail if ActionCable is not configured
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Orchestr8
|
|
4
|
+
class StepDependency < ActiveRecord::Base
|
|
5
|
+
self.table_name = "orchestr8_step_dependencies"
|
|
6
|
+
|
|
7
|
+
belongs_to :step, class_name: "Orchestr8::Step"
|
|
8
|
+
belongs_to :dependency, class_name: "Orchestr8::Step"
|
|
9
|
+
|
|
10
|
+
validates :dependency_id, uniqueness: { scope: :step_id }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Orchestr8
|
|
4
|
+
class Workflow < ActiveRecord::Base
|
|
5
|
+
self.table_name = "orchestr8_workflows"
|
|
6
|
+
include Orchestr8::Dsl
|
|
7
|
+
|
|
8
|
+
STATUSES = %w[pending running completed failed].freeze
|
|
9
|
+
|
|
10
|
+
has_many :steps,
|
|
11
|
+
class_name: "Orchestr8::Step",
|
|
12
|
+
foreign_key: :workflow_id,
|
|
13
|
+
dependent: :destroy,
|
|
14
|
+
inverse_of: :workflow
|
|
15
|
+
|
|
16
|
+
validates :status, inclusion: { in: STATUSES }
|
|
17
|
+
|
|
18
|
+
STATUSES.each do |status_name|
|
|
19
|
+
define_method(:"#{status_name}?") { status == status_name }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
scope :by_status, ->(status) { where(status: status) }
|
|
23
|
+
scope :by_type, ->(type) { where(type: type) }
|
|
24
|
+
|
|
25
|
+
def self.create!(arguments: {}, globals: {}, **attrs)
|
|
26
|
+
workflow = new(type: name, arguments: arguments, globals: globals, **attrs)
|
|
27
|
+
workflow.save!
|
|
28
|
+
create_steps_from_definitions!(workflow)
|
|
29
|
+
create_dependencies_from_definitions!(workflow)
|
|
30
|
+
Scheduler.call(workflow)
|
|
31
|
+
workflow.reload
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def steps_by_name
|
|
35
|
+
steps.index_by { |s| s.name.to_sym }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def retry!
|
|
39
|
+
transaction do
|
|
40
|
+
steps.where(status: "failed").find_each do |step|
|
|
41
|
+
step.update!(status: "pending", error: nil, started_at: nil, finished_at: nil, active_job_id: nil)
|
|
42
|
+
end
|
|
43
|
+
update!(status: "running", finished_at: nil) if failed?
|
|
44
|
+
end
|
|
45
|
+
Scheduler.call(self)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def create_steps_from_definitions!(workflow)
|
|
52
|
+
step_definitions.each do |step_name, definition|
|
|
53
|
+
workflow.steps.create!(
|
|
54
|
+
name: step_name.to_s,
|
|
55
|
+
job_class: definition[:job].to_s,
|
|
56
|
+
on_failure: definition[:on_failure].to_s,
|
|
57
|
+
queue: definition[:queue]
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def create_dependencies_from_definitions!(workflow)
|
|
63
|
+
steps_map = workflow.steps_by_name
|
|
64
|
+
|
|
65
|
+
step_definitions.each do |step_name, definition|
|
|
66
|
+
definition[:after].each do |dep_name|
|
|
67
|
+
StepDependency.create!(
|
|
68
|
+
step: steps_map[step_name],
|
|
69
|
+
dependency: steps_map[dep_name]
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
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">
|
|
6
|
+
<title>Orchestr8</title>
|
|
7
|
+
<%= csrf_meta_tags %>
|
|
8
|
+
<style>
|
|
9
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
10
|
+
|
|
11
|
+
.orchestr8 {
|
|
12
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
13
|
+
font-size: 14px;
|
|
14
|
+
color: #1a1a2e;
|
|
15
|
+
background: #f5f5f7;
|
|
16
|
+
min-height: 100vh;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.orchestr8-header {
|
|
20
|
+
background: #1a1a2e;
|
|
21
|
+
color: #fff;
|
|
22
|
+
padding: 16px 24px;
|
|
23
|
+
display: flex;
|
|
24
|
+
align-items: center;
|
|
25
|
+
justify-content: space-between;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.orchestr8-header h1 { margin: 0; font-size: 20px; font-weight: 700; }
|
|
29
|
+
.orchestr8-header h1 a { color: #fff; text-decoration: none; }
|
|
30
|
+
.orchestr8-header h1 a:hover { text-decoration: underline; }
|
|
31
|
+
|
|
32
|
+
.orchestr8-main {
|
|
33
|
+
padding: 24px;
|
|
34
|
+
max-width: 1280px;
|
|
35
|
+
margin: 0 auto;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.orchestr8-notice {
|
|
39
|
+
background: #d1fae5; border: 1px solid #6ee7b7; border-radius: 6px;
|
|
40
|
+
color: #065f46; padding: 12px 16px; margin-bottom: 20px;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.orchestr8-alert {
|
|
44
|
+
background: #fee2e2; border: 1px solid #fca5a5; border-radius: 6px;
|
|
45
|
+
color: #991b1b; padding: 12px 16px; margin-bottom: 20px;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.orchestr8-filters {
|
|
49
|
+
display: flex; align-items: center; gap: 12px;
|
|
50
|
+
margin-bottom: 20px; flex-wrap: wrap;
|
|
51
|
+
}
|
|
52
|
+
.orchestr8-filters label { font-weight: 600; color: #374151; }
|
|
53
|
+
.orchestr8-filters select {
|
|
54
|
+
padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px;
|
|
55
|
+
background: #fff; font-size: 14px; cursor: pointer; color: #374151;
|
|
56
|
+
}
|
|
57
|
+
.orchestr8-filters select:focus {
|
|
58
|
+
outline: none; border-color: #6366f1;
|
|
59
|
+
box-shadow: 0 0 0 2px rgba(99,102,241,0.2);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.orchestr8-table {
|
|
63
|
+
width: 100%; border-collapse: collapse; background: #fff;
|
|
64
|
+
border-radius: 8px; overflow: hidden;
|
|
65
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
66
|
+
}
|
|
67
|
+
.orchestr8-table thead { background: #f9fafb; }
|
|
68
|
+
.orchestr8-table th {
|
|
69
|
+
padding: 12px 16px; text-align: left; font-weight: 600;
|
|
70
|
+
color: #6b7280; font-size: 12px; text-transform: uppercase;
|
|
71
|
+
letter-spacing: 0.05em; border-bottom: 1px solid #e5e7eb;
|
|
72
|
+
}
|
|
73
|
+
.orchestr8-table td {
|
|
74
|
+
padding: 12px 16px; border-bottom: 1px solid #f3f4f6; color: #374151;
|
|
75
|
+
}
|
|
76
|
+
.orchestr8-table tbody tr:last-child td { border-bottom: none; }
|
|
77
|
+
.orchestr8-table tbody tr:hover { background: #f9fafb; cursor: pointer; }
|
|
78
|
+
.orchestr8-table a { color: #6366f1; text-decoration: none; font-weight: 500; }
|
|
79
|
+
.orchestr8-table a:hover { text-decoration: underline; }
|
|
80
|
+
|
|
81
|
+
.orchestr8-badge {
|
|
82
|
+
display: inline-flex; align-items: center; padding: 3px 10px;
|
|
83
|
+
border-radius: 9999px; font-size: 12px; font-weight: 600; text-transform: lowercase;
|
|
84
|
+
}
|
|
85
|
+
.orchestr8-badge--pending { background: #f3f4f6; color: #6b7280; }
|
|
86
|
+
.orchestr8-badge--enqueued { background: #ede9fe; color: #7c3aed; }
|
|
87
|
+
.orchestr8-badge--running { background: #dbeafe; color: #1d4ed8; }
|
|
88
|
+
.orchestr8-badge--completed { background: #d1fae5; color: #065f46; }
|
|
89
|
+
.orchestr8-badge--failed { background: #fee2e2; color: #991b1b; }
|
|
90
|
+
|
|
91
|
+
.orchestr8-progress { display: flex; align-items: center; gap: 8px; }
|
|
92
|
+
.orchestr8-progress-bar {
|
|
93
|
+
flex: 1; height: 8px; background: #e5e7eb;
|
|
94
|
+
border-radius: 9999px; overflow: hidden; min-width: 80px;
|
|
95
|
+
}
|
|
96
|
+
.orchestr8-progress-fill {
|
|
97
|
+
height: 100%; background: #6366f1;
|
|
98
|
+
border-radius: 9999px; transition: width 0.3s ease;
|
|
99
|
+
}
|
|
100
|
+
.orchestr8-progress-text { font-size: 12px; color: #6b7280; white-space: nowrap; }
|
|
101
|
+
|
|
102
|
+
.orchestr8-btn {
|
|
103
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
104
|
+
padding: 8px 16px; border-radius: 6px; font-size: 14px;
|
|
105
|
+
font-weight: 500; cursor: pointer; border: none;
|
|
106
|
+
text-decoration: none; transition: background 0.15s, box-shadow 0.15s;
|
|
107
|
+
}
|
|
108
|
+
.orchestr8-btn--primary { background: #6366f1; color: #fff; }
|
|
109
|
+
.orchestr8-btn--primary:hover { background: #4f46e5; color: #fff; }
|
|
110
|
+
.orchestr8-btn--secondary { background: #f3f4f6; color: #374151; }
|
|
111
|
+
.orchestr8-btn--secondary:hover { background: #e5e7eb; color: #374151; }
|
|
112
|
+
.orchestr8-btn--danger { background: #fee2e2; color: #991b1b; }
|
|
113
|
+
.orchestr8-btn--danger:hover { background: #fecaca; color: #7f1d1d; }
|
|
114
|
+
.orchestr8-btn--sm { padding: 4px 10px; font-size: 12px; }
|
|
115
|
+
|
|
116
|
+
.orchestr8-page-header {
|
|
117
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
118
|
+
margin-bottom: 24px; flex-wrap: wrap; gap: 12px;
|
|
119
|
+
}
|
|
120
|
+
.orchestr8-page-header h2 {
|
|
121
|
+
margin: 0; font-size: 22px; font-weight: 700; color: #1a1a2e;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.orchestr8-detail-header {
|
|
125
|
+
background: #fff; border-radius: 8px; padding: 20px 24px;
|
|
126
|
+
margin-bottom: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
127
|
+
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
|
128
|
+
}
|
|
129
|
+
.orchestr8-detail-header h2 {
|
|
130
|
+
margin: 0; font-size: 20px; font-weight: 700; color: #1a1a2e; flex: 1;
|
|
131
|
+
}
|
|
132
|
+
.orchestr8-detail-meta { font-size: 12px; color: #6b7280; margin-top: 4px; }
|
|
133
|
+
|
|
134
|
+
.orchestr8-dag {
|
|
135
|
+
background: #fff; border-radius: 8px; padding: 24px;
|
|
136
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow-x: auto;
|
|
137
|
+
}
|
|
138
|
+
.orchestr8-dag-layers {
|
|
139
|
+
display: flex; align-items: center; gap: 0; min-width: fit-content;
|
|
140
|
+
}
|
|
141
|
+
.orchestr8-dag-layer {
|
|
142
|
+
display: flex; flex-direction: column;
|
|
143
|
+
align-items: center; gap: 12px; padding: 0 8px;
|
|
144
|
+
}
|
|
145
|
+
.orchestr8-dag-arrow {
|
|
146
|
+
display: flex; align-items: center; color: #d1d5db;
|
|
147
|
+
font-size: 24px; padding: 0 4px; align-self: center;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.orchestr8-node {
|
|
151
|
+
border: 2px solid #e5e7eb; border-radius: 8px; padding: 12px 16px;
|
|
152
|
+
min-width: 180px; max-width: 220px; background: #fff;
|
|
153
|
+
transition: border-color 0.2s, box-shadow 0.2s;
|
|
154
|
+
}
|
|
155
|
+
.orchestr8-node:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.12); }
|
|
156
|
+
.orchestr8-node--pending { border-color: #d1d5db; background: #f9fafb; }
|
|
157
|
+
.orchestr8-node--enqueued { border-color: #a78bfa; background: #faf5ff; }
|
|
158
|
+
.orchestr8-node--running {
|
|
159
|
+
border-color: #60a5fa; background: #eff6ff;
|
|
160
|
+
animation: orchestr8-pulse 2s infinite;
|
|
161
|
+
}
|
|
162
|
+
.orchestr8-node--completed { border-color: #34d399; background: #f0fdf4; }
|
|
163
|
+
.orchestr8-node--failed { border-color: #f87171; background: #fff5f5; }
|
|
164
|
+
|
|
165
|
+
@keyframes orchestr8-pulse {
|
|
166
|
+
0%, 100% { box-shadow: 0 0 0 0 rgba(96,165,250,0.4); }
|
|
167
|
+
50% { box-shadow: 0 0 0 6px rgba(96,165,250,0); }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.orchestr8-node-header {
|
|
171
|
+
display: flex; align-items: center;
|
|
172
|
+
justify-content: space-between; margin-bottom: 8px;
|
|
173
|
+
}
|
|
174
|
+
.orchestr8-node-name { font-weight: 700; font-size: 13px; color: #1a1a2e; }
|
|
175
|
+
.orchestr8-node-job { font-size: 11px; color: #6b7280; margin-bottom: 8px; word-break: break-all; }
|
|
176
|
+
.orchestr8-node-meta {
|
|
177
|
+
display: flex; align-items: center;
|
|
178
|
+
justify-content: space-between; font-size: 11px; color: #9ca3af;
|
|
179
|
+
}
|
|
180
|
+
.orchestr8-node-error {
|
|
181
|
+
margin-top: 8px; font-size: 11px; color: #ef4444;
|
|
182
|
+
background: #fee2e2; border-radius: 4px; padding: 4px 8px; word-break: break-word;
|
|
183
|
+
}
|
|
184
|
+
.orchestr8-node-actions { margin-top: 10px; display: flex; gap: 6px; }
|
|
185
|
+
|
|
186
|
+
.orchestr8-empty { text-align: center; padding: 48px 24px; color: #9ca3af; }
|
|
187
|
+
.orchestr8-empty p { font-size: 16px; margin: 0; }
|
|
188
|
+
</style>
|
|
189
|
+
</head>
|
|
190
|
+
<body>
|
|
191
|
+
<div class="orchestr8">
|
|
192
|
+
<header class="orchestr8-header">
|
|
193
|
+
<h1><%= link_to "Orchestr8", orchestr8.root_path %></h1>
|
|
194
|
+
</header>
|
|
195
|
+
<main class="orchestr8-main">
|
|
196
|
+
<% if notice.present? %>
|
|
197
|
+
<div class="orchestr8-notice"><%= notice %></div>
|
|
198
|
+
<% end %>
|
|
199
|
+
<% if alert.present? %>
|
|
200
|
+
<div class="orchestr8-alert"><%= alert %></div>
|
|
201
|
+
<% end %>
|
|
202
|
+
<%= yield %>
|
|
203
|
+
</main>
|
|
204
|
+
</div>
|
|
205
|
+
</body>
|
|
206
|
+
</html>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<div id="step-<%= step.id %>" class="orchestr8-node orchestr8-node--<%= step.status %>">
|
|
2
|
+
<div class="orchestr8-node-header">
|
|
3
|
+
<span class="orchestr8-node-name"><%= step.name %></span>
|
|
4
|
+
<span class="orchestr8-badge orchestr8-badge--<%= step.status %>"><%= step.status %></span>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<div class="orchestr8-node-job"><%= step.job_class %></div>
|
|
8
|
+
|
|
9
|
+
<div class="orchestr8-node-meta">
|
|
10
|
+
<% if step.on_failure == "continue" %>
|
|
11
|
+
<span title="This step will not halt the workflow on failure">⚠ continues</span>
|
|
12
|
+
<% else %>
|
|
13
|
+
<span></span>
|
|
14
|
+
<% end %>
|
|
15
|
+
<% if step.started_at %>
|
|
16
|
+
<% duration = step.finished_at ? (step.finished_at - step.started_at).round : (Time.current - step.started_at).round %>
|
|
17
|
+
<span><%= duration %>s</span>
|
|
18
|
+
<% end %>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<% if step.error.present? %>
|
|
22
|
+
<div class="orchestr8-node-error" title="<%= step.error %>">
|
|
23
|
+
<%= truncate(step.error, length: 80) %>
|
|
24
|
+
</div>
|
|
25
|
+
<% end %>
|
|
26
|
+
|
|
27
|
+
<% if step.failed? %>
|
|
28
|
+
<div class="orchestr8-node-actions">
|
|
29
|
+
<%= button_to "Retry",
|
|
30
|
+
orchestr8.retry_workflow_step_path(step.workflow_id, step),
|
|
31
|
+
method: :post,
|
|
32
|
+
class: "orchestr8-btn orchestr8-btn--danger orchestr8-btn--sm" %>
|
|
33
|
+
</div>
|
|
34
|
+
<% end %>
|
|
35
|
+
</div>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<div class="orchestr8-page-header">
|
|
2
|
+
<h2>Workflows</h2>
|
|
3
|
+
</div>
|
|
4
|
+
|
|
5
|
+
<div class="orchestr8-filters">
|
|
6
|
+
<label for="status-filter">Status:</label>
|
|
7
|
+
<%= form_with url: orchestr8.workflows_path, method: :get, local: true do |f| %>
|
|
8
|
+
<%= f.select :status,
|
|
9
|
+
[["All", ""], ["Pending", "pending"], ["Running", "running"],
|
|
10
|
+
["Completed", "completed"], ["Failed", "failed"]],
|
|
11
|
+
{ selected: params[:status] },
|
|
12
|
+
{ id: "status-filter", onchange: "this.form.submit()" } %>
|
|
13
|
+
<% if params[:type].present? %>
|
|
14
|
+
<%= f.hidden_field :type, value: params[:type] %>
|
|
15
|
+
<% end %>
|
|
16
|
+
<% end %>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<% if @workflows.empty? %>
|
|
20
|
+
<div class="orchestr8-empty">
|
|
21
|
+
<p>No workflows found.</p>
|
|
22
|
+
</div>
|
|
23
|
+
<% else %>
|
|
24
|
+
<table class="orchestr8-table">
|
|
25
|
+
<thead>
|
|
26
|
+
<tr>
|
|
27
|
+
<th>ID</th>
|
|
28
|
+
<th>Workflow Type</th>
|
|
29
|
+
<th>Status</th>
|
|
30
|
+
<th>Progress</th>
|
|
31
|
+
<th>Duration</th>
|
|
32
|
+
<th>Started</th>
|
|
33
|
+
</tr>
|
|
34
|
+
</thead>
|
|
35
|
+
<tbody>
|
|
36
|
+
<% @workflows.each do |workflow| %>
|
|
37
|
+
<% total = workflow.steps.size %>
|
|
38
|
+
<% completed = workflow.steps.count { |s| %w[enqueued running completed].include?(s.status) } %>
|
|
39
|
+
<% pct = total > 0 ? (completed.to_f / total * 100).round : 0 %>
|
|
40
|
+
<% duration = if workflow.finished_at && workflow.started_at
|
|
41
|
+
seconds = (workflow.finished_at - workflow.started_at).round
|
|
42
|
+
"#{seconds}s"
|
|
43
|
+
elsif workflow.started_at
|
|
44
|
+
seconds = (Time.current - workflow.started_at).round
|
|
45
|
+
"#{seconds}s"
|
|
46
|
+
else
|
|
47
|
+
"-"
|
|
48
|
+
end %>
|
|
49
|
+
<tr onclick="window.location='<%= orchestr8.workflow_path(workflow) %>'" style="cursor:pointer;">
|
|
50
|
+
<td><%= link_to "##{workflow.id}", orchestr8.workflow_path(workflow) %></td>
|
|
51
|
+
<td><%= workflow.type %></td>
|
|
52
|
+
<td>
|
|
53
|
+
<span class="orchestr8-badge orchestr8-badge--<%= workflow.status %>">
|
|
54
|
+
<%= workflow.status %>
|
|
55
|
+
</span>
|
|
56
|
+
</td>
|
|
57
|
+
<td>
|
|
58
|
+
<div class="orchestr8-progress">
|
|
59
|
+
<div class="orchestr8-progress-bar">
|
|
60
|
+
<div class="orchestr8-progress-fill" style="width: <%= pct %>%;"></div>
|
|
61
|
+
</div>
|
|
62
|
+
<span class="orchestr8-progress-text"><%= completed %>/<%= total %></span>
|
|
63
|
+
</div>
|
|
64
|
+
</td>
|
|
65
|
+
<td><%= duration %></td>
|
|
66
|
+
<td><%= workflow.started_at ? workflow.started_at.strftime("%Y-%m-%d %H:%M:%S") : "-" %></td>
|
|
67
|
+
</tr>
|
|
68
|
+
<% end %>
|
|
69
|
+
</tbody>
|
|
70
|
+
</table>
|
|
71
|
+
<% end %>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<% layers = compute_layers(@steps) %>
|
|
2
|
+
|
|
3
|
+
<div class="orchestr8-detail-header">
|
|
4
|
+
<div style="flex: 1;">
|
|
5
|
+
<h2><%= @workflow.type %></h2>
|
|
6
|
+
<div class="orchestr8-detail-meta">
|
|
7
|
+
ID: #<%= @workflow.id %>
|
|
8
|
+
·
|
|
9
|
+
<% if @workflow.started_at %>
|
|
10
|
+
Started: <%= @workflow.started_at.strftime("%Y-%m-%d %H:%M:%S") %>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% if @workflow.finished_at %>
|
|
13
|
+
·
|
|
14
|
+
Finished: <%= @workflow.finished_at.strftime("%Y-%m-%d %H:%M:%S") %>
|
|
15
|
+
·
|
|
16
|
+
Duration: <%= (@workflow.finished_at - @workflow.started_at).round %>s
|
|
17
|
+
<% end %>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<span class="orchestr8-badge orchestr8-badge--<%= @workflow.status %>">
|
|
22
|
+
<%= @workflow.status %>
|
|
23
|
+
</span>
|
|
24
|
+
|
|
25
|
+
<% if @workflow.failed? %>
|
|
26
|
+
<%= button_to "Retry Workflow",
|
|
27
|
+
orchestr8.retry_workflow_path(@workflow),
|
|
28
|
+
method: :post,
|
|
29
|
+
class: "orchestr8-btn orchestr8-btn--danger" %>
|
|
30
|
+
<% end %>
|
|
31
|
+
|
|
32
|
+
<%= link_to "← Back", orchestr8.workflows_path, class: "orchestr8-btn orchestr8-btn--secondary" %>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<% if defined?(turbo_stream_from) %>
|
|
36
|
+
<%= turbo_stream_from "orchestr8_workflow_#{@workflow.id}" %>
|
|
37
|
+
<% end %>
|
|
38
|
+
|
|
39
|
+
<div class="orchestr8-dag">
|
|
40
|
+
<% if @steps.empty? %>
|
|
41
|
+
<div class="orchestr8-empty"><p>No steps found for this workflow.</p></div>
|
|
42
|
+
<% else %>
|
|
43
|
+
<div class="orchestr8-dag-layers">
|
|
44
|
+
<% layers.each_with_index do |layer, idx| %>
|
|
45
|
+
<% if idx > 0 %>
|
|
46
|
+
<div class="orchestr8-dag-arrow">→</div>
|
|
47
|
+
<% end %>
|
|
48
|
+
<div class="orchestr8-dag-layer">
|
|
49
|
+
<% layer.each do |step| %>
|
|
50
|
+
<%= render "orchestr8/workflows/step_node", step: step %>
|
|
51
|
+
<% end %>
|
|
52
|
+
</div>
|
|
53
|
+
<% end %>
|
|
54
|
+
</div>
|
|
55
|
+
<% end %>
|
|
56
|
+
</div>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Orchestr8::Engine.routes.draw do
|
|
4
|
+
resources :workflows, only: [:index, :show] do
|
|
5
|
+
member do
|
|
6
|
+
post :retry
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
resources :steps, only: [] do
|
|
10
|
+
member do
|
|
11
|
+
post :retry
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
root to: "workflows#index"
|
|
17
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateOrchestr8Tables < ActiveRecord::Migration[7.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :orchestr8_workflows do |t|
|
|
6
|
+
t.string :type, null: false
|
|
7
|
+
t.string :status, null: false, default: "pending"
|
|
8
|
+
t.json :arguments, default: {}
|
|
9
|
+
t.json :globals, default: {}
|
|
10
|
+
t.datetime :started_at
|
|
11
|
+
t.datetime :finished_at
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
add_index :orchestr8_workflows, :type
|
|
16
|
+
add_index :orchestr8_workflows, :status
|
|
17
|
+
|
|
18
|
+
create_table :orchestr8_steps do |t|
|
|
19
|
+
t.references :workflow, null: false, foreign_key: { to_table: :orchestr8_workflows }
|
|
20
|
+
t.string :name, null: false
|
|
21
|
+
t.string :job_class, null: false
|
|
22
|
+
t.string :status, null: false, default: "pending"
|
|
23
|
+
t.json :output
|
|
24
|
+
t.text :error
|
|
25
|
+
t.string :queue
|
|
26
|
+
t.string :on_failure, null: false, default: "halt"
|
|
27
|
+
t.string :active_job_id
|
|
28
|
+
t.datetime :started_at
|
|
29
|
+
t.datetime :finished_at
|
|
30
|
+
t.timestamps
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
add_index :orchestr8_steps, [:workflow_id, :status]
|
|
34
|
+
add_index :orchestr8_steps, [:workflow_id, :name], unique: true
|
|
35
|
+
add_index :orchestr8_steps, :active_job_id
|
|
36
|
+
|
|
37
|
+
create_table :orchestr8_step_dependencies do |t|
|
|
38
|
+
t.references :step, null: false, foreign_key: { to_table: :orchestr8_steps }
|
|
39
|
+
t.references :dependency, null: false, foreign_key: { to_table: :orchestr8_steps }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
add_index :orchestr8_step_dependencies, [:step_id, :dependency_id], unique: true,
|
|
43
|
+
name: "idx_orchestr8_step_deps_unique"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Orchestr8
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path("../../../../db/migrate", __dir__)
|
|
7
|
+
|
|
8
|
+
def copy_migrations
|
|
9
|
+
rake "orchestr8:install:migrations"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|