fun_ci 1.1.0 → 1.2.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 +4 -4
- data/CHANGELOG.md +19 -0
- data/exe/fun-ci-trigger +6 -6
- data/exe/fun-ci-tui +6 -6
- data/lib/fun_ci/cli.rb +17 -16
- data/lib/fun_ci/persistence/database.rb +52 -0
- data/lib/fun_ci/persistence/pipeline_recorder.rb +74 -0
- data/lib/fun_ci/persistence/pipeline_run.rb +59 -0
- data/lib/fun_ci/persistence/stage_job.rb +46 -0
- data/lib/fun_ci/{state_machine.rb → persistence/state_machine.rb} +3 -12
- data/lib/fun_ci/pipeline/background_wrapper.rb +27 -0
- data/lib/fun_ci/pipeline/pipeline_forker.rb +38 -0
- data/lib/fun_ci/pipeline/process_runner.rb +28 -0
- data/lib/fun_ci/pipeline/progress_reporter.rb +31 -0
- data/lib/fun_ci/pipeline/stage_runner.rb +85 -0
- data/lib/fun_ci/pipeline/stale_pipeline_canceller.rb +53 -0
- data/lib/fun_ci/pipeline/trigger.rb +153 -0
- data/lib/fun_ci/setup/hook_writer.rb +75 -0
- data/lib/fun_ci/setup/installer.rb +55 -0
- data/lib/fun_ci/setup/maven_linter_detector.rb +26 -0
- data/lib/fun_ci/setup/project_config.rb +42 -0
- data/lib/fun_ci/setup/project_detector.rb +22 -0
- data/lib/fun_ci/setup/setup_checker.rb +30 -0
- data/lib/fun_ci/setup/template_writer.rb +53 -0
- data/lib/fun_ci/tui/admin_tui.rb +90 -0
- data/lib/fun_ci/tui/animation.rb +49 -0
- data/lib/fun_ci/tui/animation_compositor.rb +107 -0
- data/lib/fun_ci/tui/animation_frames.rb +112 -0
- data/lib/fun_ci/tui/animation_library.rb +46 -0
- data/lib/fun_ci/tui/animation_renderer.rb +144 -0
- data/lib/fun_ci/tui/ansi.rb +34 -0
- data/lib/fun_ci/tui/board_data.rb +53 -0
- data/lib/fun_ci/tui/board_renderer.rb +105 -0
- data/lib/fun_ci/tui/duration_formatter.rb +24 -0
- data/lib/fun_ci/tui/header_animation_manager.rb +71 -0
- data/lib/fun_ci/tui/header_animation_player.rb +45 -0
- data/lib/fun_ci/tui/key_handler.rb +86 -0
- data/lib/fun_ci/tui/looping_animation_player.rb +45 -0
- data/lib/fun_ci/tui/relative_time.rb +22 -0
- data/lib/fun_ci/tui/row_formatter.rb +108 -0
- data/lib/fun_ci/tui/screen.rb +103 -0
- data/lib/fun_ci/tui/spinner.rb +24 -0
- data/lib/fun_ci/tui/stage_change_detector.rb +58 -0
- data/lib/fun_ci/tui/streak_counter.rb +29 -0
- data/lib/fun_ci/tui/terminal_input.rb +69 -0
- data/lib/fun_ci.rb +6 -6
- metadata +41 -37
- data/lib/fun_ci/admin_tui.rb +0 -238
- data/lib/fun_ci/animation.rb +0 -47
- data/lib/fun_ci/animation_compositor.rb +0 -105
- data/lib/fun_ci/animation_frames.rb +0 -111
- data/lib/fun_ci/animation_library.rb +0 -44
- data/lib/fun_ci/animation_renderer.rb +0 -142
- data/lib/fun_ci/ansi.rb +0 -32
- data/lib/fun_ci/background_wrapper.rb +0 -27
- data/lib/fun_ci/board_data.rb +0 -51
- data/lib/fun_ci/database.rb +0 -50
- data/lib/fun_ci/duration_formatter.rb +0 -23
- data/lib/fun_ci/header_animation_manager.rb +0 -69
- data/lib/fun_ci/header_animation_player.rb +0 -43
- data/lib/fun_ci/hook_writer.rb +0 -73
- data/lib/fun_ci/installer.rb +0 -53
- data/lib/fun_ci/looping_animation_player.rb +0 -43
- data/lib/fun_ci/maven_linter_detector.rb +0 -24
- data/lib/fun_ci/pipeline_forker.rb +0 -36
- data/lib/fun_ci/pipeline_recorder.rb +0 -72
- data/lib/fun_ci/pipeline_run.rb +0 -57
- data/lib/fun_ci/progress_reporter.rb +0 -29
- data/lib/fun_ci/project_config.rb +0 -40
- data/lib/fun_ci/project_detector.rb +0 -18
- data/lib/fun_ci/relative_time.rb +0 -20
- data/lib/fun_ci/row_formatter.rb +0 -106
- data/lib/fun_ci/screen.rb +0 -95
- data/lib/fun_ci/setup_checker.rb +0 -28
- data/lib/fun_ci/spinner.rb +0 -22
- data/lib/fun_ci/stage_change_detector.rb +0 -56
- data/lib/fun_ci/stage_job.rb +0 -44
- data/lib/fun_ci/stage_runner.rb +0 -108
- data/lib/fun_ci/stale_pipeline_canceller.rb +0 -51
- data/lib/fun_ci/streak_counter.rb +0 -30
- data/lib/fun_ci/template_writer.rb +0 -51
- data/lib/fun_ci/trigger.rb +0 -150
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ansi"
|
|
4
|
+
require_relative "animation_frames"
|
|
5
|
+
require_relative "row_formatter"
|
|
6
|
+
require_relative "duration_formatter"
|
|
7
|
+
|
|
8
|
+
module FunCi
|
|
9
|
+
module Tui
|
|
10
|
+
module AnimationCompositor
|
|
11
|
+
RESET = Ansi::RESET
|
|
12
|
+
STAGE_STAGGER = { "lint" => 0, "build" => 2, "fast" => 4, "slow" => 6 }.freeze
|
|
13
|
+
|
|
14
|
+
def self.header_overlay(animation, width)
|
|
15
|
+
frames = case animation.type
|
|
16
|
+
when :failure then AnimationFrames.failure_header(width)
|
|
17
|
+
when :success then AnimationFrames.success_header(width)
|
|
18
|
+
when :timeout then AnimationFrames.timeout_header(width)
|
|
19
|
+
else return nil
|
|
20
|
+
end
|
|
21
|
+
frame_at(frames, animation.frame)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.footer_overlay(animation, width)
|
|
25
|
+
frames = case animation.type
|
|
26
|
+
when :failure
|
|
27
|
+
AnimationFrames.failure_footer(animation.stage || "stage", width)
|
|
28
|
+
when :success then AnimationFrames.success_footer(width)
|
|
29
|
+
else return nil
|
|
30
|
+
end
|
|
31
|
+
frame_at(frames, animation.frame)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.stage_column_overlay(animation, stage_text, col)
|
|
35
|
+
case animation.type
|
|
36
|
+
when :stage_pass then color_flash(animation, stage_text, AnimationFrames.stage_pass_colors)
|
|
37
|
+
when :timeout then color_flash(animation, stage_text, AnimationFrames.timeout_colors)
|
|
38
|
+
when :failure then failure_flanks(animation, stage_text)
|
|
39
|
+
when :success then sparkle_sweep(animation, stage_text)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.stage_text_for(run, stage_name)
|
|
44
|
+
stage = run[:stages]&.find { |s| s[:stage] == stage_name }
|
|
45
|
+
return nil unless stage
|
|
46
|
+
|
|
47
|
+
name = RowFormatter::STAGE_NAMES[stage[:stage]] || stage[:stage]
|
|
48
|
+
dur = stage[:duration] ? DurationFormatter.format(stage[:duration]) : "--"
|
|
49
|
+
case stage[:status]
|
|
50
|
+
when "completed" then "#{name} #{dur}"
|
|
51
|
+
when "failed" then "#{name} FAIL #{dur}"
|
|
52
|
+
when "timed_out" then "#{name} TIMEOUT #{dur}"
|
|
53
|
+
else "#{name} --"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.stage_col_for(run, stage_name)
|
|
58
|
+
project = run[:project_path] ? " #{File.basename(run[:project_path])}" : ""
|
|
59
|
+
prefix_len = 2 + 7 + 2 + run[:branch].to_s.length + project.length + 2
|
|
60
|
+
(run[:stages] || []).each do |s|
|
|
61
|
+
return prefix_len + 1 if s[:stage] == stage_name
|
|
62
|
+
prefix_len += stage_text_for(run, s[:stage]).to_s.length + 2
|
|
63
|
+
end
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.color_flash(animation, stage_text, colors)
|
|
68
|
+
idx = [animation.frame, colors.length - 1].min
|
|
69
|
+
"#{colors[idx]}#{Ansi.strip(stage_text)}#{RESET}"
|
|
70
|
+
end
|
|
71
|
+
private_class_method :color_flash
|
|
72
|
+
|
|
73
|
+
def self.failure_flanks(animation, stage_text)
|
|
74
|
+
particles = AnimationFrames.failure_particles
|
|
75
|
+
return nil if animation.frame >= particles.length
|
|
76
|
+
|
|
77
|
+
p = particles[animation.frame]
|
|
78
|
+
plain = Ansi.strip(stage_text)
|
|
79
|
+
return plain if p[:chars].empty?
|
|
80
|
+
|
|
81
|
+
left = p[:chars].reverse
|
|
82
|
+
right = p[:chars]
|
|
83
|
+
"#{p[:color]}#{left}#{RESET} #{Ansi.bold_red(plain)} #{p[:color]}#{right}#{RESET}"
|
|
84
|
+
end
|
|
85
|
+
private_class_method :failure_flanks
|
|
86
|
+
|
|
87
|
+
def self.sparkle_sweep(animation, stage_text)
|
|
88
|
+
plain = Ansi.strip(stage_text)
|
|
89
|
+
stagger = STAGE_STAGGER[animation.stage] || 0
|
|
90
|
+
pos = animation.frame - stagger
|
|
91
|
+
return nil if pos < 0 || pos > plain.length
|
|
92
|
+
|
|
93
|
+
result = plain.chars.map { |c| "\e[32m#{c}" }
|
|
94
|
+
result[pos] = "\e[1;33m#{plain[pos]}" if pos < plain.length
|
|
95
|
+
"#{result.join}#{RESET}"
|
|
96
|
+
end
|
|
97
|
+
private_class_method :sparkle_sweep
|
|
98
|
+
|
|
99
|
+
def self.frame_at(frames, index)
|
|
100
|
+
return nil if index >= frames.length
|
|
101
|
+
|
|
102
|
+
frames[index]
|
|
103
|
+
end
|
|
104
|
+
private_class_method :frame_at
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ansi"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Tui
|
|
7
|
+
module AnimationFrames
|
|
8
|
+
RESET = Ansi::RESET
|
|
9
|
+
FOOTER_HOLD = 8
|
|
10
|
+
|
|
11
|
+
def self.failure_particles
|
|
12
|
+
[
|
|
13
|
+
{ chars: "*", color: "\e[1;31m" },
|
|
14
|
+
{ chars: ".*", color: "\e[1;31m" },
|
|
15
|
+
{ chars: ".+*.", color: "\e[38;5;208m" },
|
|
16
|
+
{ chars: "*.+'", color: "\e[38;5;208m" },
|
|
17
|
+
{ chars: "' .", color: "\e[38;5;52m" },
|
|
18
|
+
{ chars: ".", color: "\e[38;5;52m" },
|
|
19
|
+
{ chars: "", color: "" }
|
|
20
|
+
]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.failure_header(width)
|
|
24
|
+
boom = "BOOM"
|
|
25
|
+
[
|
|
26
|
+
nil,
|
|
27
|
+
banner(boom, width, "\e[48;5;124m", particles: "* "),
|
|
28
|
+
banner(boom, width, "\e[48;5;166m", particles: "* * "),
|
|
29
|
+
banner(boom, width, "\e[48;5;52m", particles: ". * * "),
|
|
30
|
+
banner(boom, width, "\e[48;5;236m", particles: "' . * "),
|
|
31
|
+
banner(boom, width, "\e[48;5;236m", particles: ". "),
|
|
32
|
+
nil
|
|
33
|
+
]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.failure_footer(stage_name, width)
|
|
37
|
+
up = stage_name.upcase
|
|
38
|
+
down = stage_name.downcase
|
|
39
|
+
hold [
|
|
40
|
+
nil,
|
|
41
|
+
center("\e[1;31m>>> #{up} FAILED <<<#{RESET}", ">>> #{up} FAILED <<<", width),
|
|
42
|
+
center("\e[31m>> #{down} failed <<#{RESET}", ">> #{down} failed <<", width),
|
|
43
|
+
center("\e[2;31m> #{down} failed <#{RESET}", "> #{down} failed <", width),
|
|
44
|
+
nil
|
|
45
|
+
]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.success_header(width)
|
|
49
|
+
text = "ALL PASSED"
|
|
50
|
+
[
|
|
51
|
+
nil,
|
|
52
|
+
banner(text, width, "\e[48;5;22m", particles: ""),
|
|
53
|
+
banner(text, width, "\e[48;5;22m", particles: "* * "),
|
|
54
|
+
banner(text, width, "\e[48;5;22m", particles: "* . . "),
|
|
55
|
+
banner(text, width, "\e[48;5;58m", particles: ". ' ' "),
|
|
56
|
+
banner(text, width, "\e[48;5;22m", particles: "' "),
|
|
57
|
+
banner(text, width, "\e[48;5;22m", particles: ""),
|
|
58
|
+
nil
|
|
59
|
+
]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.success_footer(width)
|
|
63
|
+
hold [
|
|
64
|
+
nil,
|
|
65
|
+
center("\e[1;32m* * * NICE! * * *#{RESET}", "* * * NICE! * * *", width),
|
|
66
|
+
center("\e[32m. + . * NICE! * . + .#{RESET}", ". + . * NICE! * . + .", width),
|
|
67
|
+
center("\e[2;32m' . + . nice . + . '#{RESET}", "' . + . nice . + . '", width),
|
|
68
|
+
nil
|
|
69
|
+
]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.timeout_header(width)
|
|
73
|
+
[
|
|
74
|
+
nil,
|
|
75
|
+
banner("TIMED OUT", width, "\e[48;5;58m", particles: ""),
|
|
76
|
+
banner("timed out", width, "\e[48;5;58m", particles: ""),
|
|
77
|
+
nil
|
|
78
|
+
]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.stage_pass_colors
|
|
82
|
+
["\e[1;33m", "\e[1;32m", "\e[32m"]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.timeout_colors
|
|
86
|
+
["\e[1;33m", "\e[33m", "\e[1;33m", "\e[33m"]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.banner(text, width, bg_code, particles: "")
|
|
90
|
+
padded = if particles.empty?
|
|
91
|
+
text.center(width)
|
|
92
|
+
else
|
|
93
|
+
inner = "#{particles} #{text} #{particles.reverse}"
|
|
94
|
+
inner.center(width)
|
|
95
|
+
end
|
|
96
|
+
"#{bg_code}\e[1;37m#{padded}#{RESET}"
|
|
97
|
+
end
|
|
98
|
+
private_class_method :banner
|
|
99
|
+
|
|
100
|
+
def self.hold(frames)
|
|
101
|
+
frames.flat_map { |f| Array.new(FOOTER_HOLD, f) }
|
|
102
|
+
end
|
|
103
|
+
private_class_method :hold
|
|
104
|
+
|
|
105
|
+
def self.center(colored_text, plain_text, width)
|
|
106
|
+
pad = [(width - plain_text.length) / 2, 0].max
|
|
107
|
+
"#{" " * pad}#{colored_text}"
|
|
108
|
+
end
|
|
109
|
+
private_class_method :center
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../animations/explosion"
|
|
4
|
+
require_relative "../animations/success"
|
|
5
|
+
require_relative "../animations/celebrate"
|
|
6
|
+
require_relative "../animations/flash"
|
|
7
|
+
require_relative "../animations/leprechauns"
|
|
8
|
+
require_relative "../animations/yay"
|
|
9
|
+
require_relative "../animations/idle"
|
|
10
|
+
require_relative "../animations/running"
|
|
11
|
+
|
|
12
|
+
module FunCi
|
|
13
|
+
module Tui
|
|
14
|
+
module AnimationLibrary
|
|
15
|
+
FAILURE = [Animations::Explosion::DATA].freeze
|
|
16
|
+
|
|
17
|
+
SUCCESS = [
|
|
18
|
+
Animations::Success::DATA,
|
|
19
|
+
Animations::Celebrate::DATA,
|
|
20
|
+
Animations::Flash::DATA,
|
|
21
|
+
Animations::Leprechauns::DATA,
|
|
22
|
+
Animations::Yay::DATA
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
IDLE = Animations::Idle::DATA
|
|
26
|
+
|
|
27
|
+
RUNNING = Animations::Running::DATA
|
|
28
|
+
|
|
29
|
+
def self.random_failure
|
|
30
|
+
FAILURE.sample
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.random_success
|
|
34
|
+
SUCCESS.sample
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.idle
|
|
38
|
+
IDLE
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.running
|
|
42
|
+
RUNNING
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "animation"
|
|
4
|
+
require_relative "animation_compositor"
|
|
5
|
+
require_relative "animation_library"
|
|
6
|
+
require_relative "header_animation_manager"
|
|
7
|
+
require_relative "stage_change_detector"
|
|
8
|
+
|
|
9
|
+
module FunCi
|
|
10
|
+
module Tui
|
|
11
|
+
class AnimationRenderer
|
|
12
|
+
HEADER_HEIGHT = HeaderAnimationManager::HEADER_HEIGHT
|
|
13
|
+
|
|
14
|
+
def initialize(animation_library: AnimationLibrary)
|
|
15
|
+
@previous_runs = []
|
|
16
|
+
@animations = []
|
|
17
|
+
@header_manager = HeaderAnimationManager.new(animation_library: animation_library)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def render(screen, runs)
|
|
21
|
+
detect_and_queue(runs)
|
|
22
|
+
|
|
23
|
+
screen.save_cursor
|
|
24
|
+
render_header_overlay(screen)
|
|
25
|
+
render_stage_overlays(screen, runs)
|
|
26
|
+
render_footer_overlay(screen, runs)
|
|
27
|
+
screen.restore_cursor
|
|
28
|
+
|
|
29
|
+
advance_all
|
|
30
|
+
expire_finished
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def any_active?
|
|
34
|
+
@animations.any? { |a| !a.finished? } || @header_manager.any_active_event?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def active_count
|
|
38
|
+
@animations.count { |a| !a.finished? }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def detect_and_queue(runs)
|
|
44
|
+
changes = StageChangeDetector.detect(@previous_runs, runs)
|
|
45
|
+
@previous_runs = snapshot(runs)
|
|
46
|
+
changes.each { |change| queue_animation(change, runs) }
|
|
47
|
+
update_running_state(runs)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def update_running_state(runs)
|
|
51
|
+
any_running = runs.any? { |r| r[:status] == "running" }
|
|
52
|
+
any_running ? @header_manager.start_running : @header_manager.stop_running
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def queue_animation(change, runs)
|
|
56
|
+
run_status = runs.find { |r| r[:id] == change.run_id }&.dig(:status)
|
|
57
|
+
|
|
58
|
+
case change.to
|
|
59
|
+
when "failed"
|
|
60
|
+
add_animation(:failure, change.run_id, change.stage)
|
|
61
|
+
@header_manager.trigger_failure
|
|
62
|
+
when "timed_out"
|
|
63
|
+
add_animation(:timeout, change.run_id, change.stage)
|
|
64
|
+
when "completed"
|
|
65
|
+
if run_status == "completed"
|
|
66
|
+
add_animation(:success, change.run_id, change.stage)
|
|
67
|
+
@header_manager.trigger_success
|
|
68
|
+
else
|
|
69
|
+
add_animation(:stage_pass, change.run_id, change.stage)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def add_animation(type, run_id, stage)
|
|
75
|
+
@animations.reject! do |a|
|
|
76
|
+
a.type == type && a.run_id == run_id && a.stage == stage
|
|
77
|
+
end
|
|
78
|
+
@animations << Animation.new(type: type, run_id: run_id, stage: stage)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def render_header_overlay(screen)
|
|
82
|
+
@header_manager.current_lines(screen.width).each_with_index do |line, i|
|
|
83
|
+
screen.write_at(1 + i, 1, line)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def render_footer_overlay(screen, runs)
|
|
88
|
+
anim = highest_priority(:has_footer?)
|
|
89
|
+
return unless anim
|
|
90
|
+
|
|
91
|
+
footer_row = HEADER_HEIGHT + (runs.length * 2) + 1
|
|
92
|
+
overlay = AnimationCompositor.footer_overlay(anim, screen.width)
|
|
93
|
+
screen.write_at(footer_row, 1, "#{overlay}\e[K") if overlay
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def render_stage_overlays(screen, runs)
|
|
97
|
+
@animations.each do |anim|
|
|
98
|
+
next if anim.finished?
|
|
99
|
+
|
|
100
|
+
run_index = runs.index { |r| r[:id] == anim.run_id }
|
|
101
|
+
next unless run_index
|
|
102
|
+
|
|
103
|
+
row = HEADER_HEIGHT + 1 + (run_index * 2)
|
|
104
|
+
run = runs[run_index]
|
|
105
|
+
text = AnimationCompositor.stage_text_for(run, anim.stage)
|
|
106
|
+
next unless text
|
|
107
|
+
|
|
108
|
+
overlay = AnimationCompositor.stage_column_overlay(anim, text, 0)
|
|
109
|
+
next unless overlay
|
|
110
|
+
|
|
111
|
+
col = AnimationCompositor.stage_col_for(run, anim.stage)
|
|
112
|
+
screen.write_at(row, col, overlay) if col
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def highest_priority(predicate)
|
|
117
|
+
@animations
|
|
118
|
+
.select { |a| a.send(predicate) && !a.finished? }
|
|
119
|
+
.max_by(&:priority)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def advance_all
|
|
123
|
+
@animations.each { |a| a.advance! unless a.finished? }
|
|
124
|
+
@header_manager.advance!
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def expire_finished
|
|
128
|
+
@animations.reject!(&:finished?)
|
|
129
|
+
@header_manager.expire_if_finished!
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def snapshot(runs)
|
|
133
|
+
runs.map do |r|
|
|
134
|
+
{
|
|
135
|
+
id: r[:id],
|
|
136
|
+
stages: (r[:stages] || []).map do |s|
|
|
137
|
+
{ stage: s[:stage], status: s[:status] }
|
|
138
|
+
end
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Tui
|
|
5
|
+
module Ansi
|
|
6
|
+
RESET = "\e[0m"
|
|
7
|
+
|
|
8
|
+
def self.green(text) = "\e[32m#{text}#{RESET}"
|
|
9
|
+
def self.bold_green(text) = "\e[1;32m#{text}#{RESET}"
|
|
10
|
+
def self.bold_red(text) = "\e[1;31m#{text}#{RESET}"
|
|
11
|
+
def self.bold_yellow(text) = "\e[1;33m#{text}#{RESET}"
|
|
12
|
+
def self.cyan(text) = "\e[36m#{text}#{RESET}"
|
|
13
|
+
def self.bold_cyan(text) = "\e[1;36m#{text}#{RESET}"
|
|
14
|
+
def self.dim(text) = "\e[2m#{text}#{RESET}"
|
|
15
|
+
def self.bold_white(text) = "\e[1;37m#{text}#{RESET}"
|
|
16
|
+
def self.white(text) = "\e[37m#{text}#{RESET}"
|
|
17
|
+
def self.yellow(text) = "\e[33m#{text}#{RESET}"
|
|
18
|
+
def self.dim_green(text) = "\e[2;32m#{text}#{RESET}"
|
|
19
|
+
def self.dim_red(text) = "\e[2;31m#{text}#{RESET}"
|
|
20
|
+
def self.orange(text) = "\e[38;5;208m#{text}#{RESET}"
|
|
21
|
+
def self.dark_red(text) = "\e[38;5;52m#{text}#{RESET}"
|
|
22
|
+
def self.bg_charcoal(text) = "\e[48;5;236m#{text}#{RESET}"
|
|
23
|
+
def self.bg_dark_red(text) = "\e[48;5;124m#{text}#{RESET}"
|
|
24
|
+
def self.bg_orange(text) = "\e[48;5;166m#{text}#{RESET}"
|
|
25
|
+
def self.bg_very_dark_red(text) = "\e[48;5;52m#{text}#{RESET}"
|
|
26
|
+
def self.bg_dark_green(text) = "\e[48;5;22m#{text}#{RESET}"
|
|
27
|
+
def self.bg_dark_yellow(text) = "\e[48;5;58m#{text}#{RESET}"
|
|
28
|
+
|
|
29
|
+
def self.strip(text)
|
|
30
|
+
text.gsub(/\e\[[0-9;]*[A-Za-z]/, "")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "../persistence/pipeline_run"
|
|
5
|
+
require_relative "../persistence/stage_job"
|
|
6
|
+
require_relative "streak_counter"
|
|
7
|
+
|
|
8
|
+
module FunCi
|
|
9
|
+
module Tui
|
|
10
|
+
class BoardData
|
|
11
|
+
def initialize(db, limit: 15, page_size: nil)
|
|
12
|
+
@db = db
|
|
13
|
+
@page_size = page_size || limit
|
|
14
|
+
@limit = @page_size
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def load_more
|
|
18
|
+
@limit += @page_size
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def runs
|
|
22
|
+
pipeline_runs = Persistence::PipelineRun.recent(@db, limit: @limit)
|
|
23
|
+
pipeline_runs.map { |run| enrich_with_stages(run) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def streak
|
|
27
|
+
pipeline_runs = Persistence::PipelineRun.recent(@db, limit: @limit)
|
|
28
|
+
StreakCounter.count(pipeline_runs)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def cancel_run(run_id)
|
|
32
|
+
Persistence::PipelineRun.update_status(@db, run_id, "cancelled")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def enrich_with_stages(run)
|
|
38
|
+
rows = @db.execute(
|
|
39
|
+
"SELECT id, pipeline_run_id, stage, status, started_at, completed_at FROM stage_jobs WHERE pipeline_run_id = ? ORDER BY id",
|
|
40
|
+
[run[:id]]
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
stages = rows.map do |row|
|
|
44
|
+
job = { id: row[0], pipeline_run_id: row[1], stage: row[2], status: row[3], started_at: row[4], completed_at: row[5] }
|
|
45
|
+
duration = Persistence::StageJob.elapsed_duration(job)
|
|
46
|
+
{ stage: job[:stage], status: job[:status], duration: duration, started_at: job[:started_at] }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
run.merge(stages: stages)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "header_animation_manager"
|
|
4
|
+
require_relative "row_formatter"
|
|
5
|
+
require_relative "streak_counter"
|
|
6
|
+
|
|
7
|
+
module FunCi
|
|
8
|
+
module Tui
|
|
9
|
+
class BoardRenderer
|
|
10
|
+
HEADER_HEIGHT = HeaderAnimationManager::HEADER_HEIGHT
|
|
11
|
+
|
|
12
|
+
def initialize(screen:, spinner:, animation_renderer:, height_provider:)
|
|
13
|
+
@screen = screen
|
|
14
|
+
@spinner = spinner
|
|
15
|
+
@animation_renderer = animation_renderer
|
|
16
|
+
@height_provider = height_provider
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def render(runs:, streak:, cursor_index:, confirming:)
|
|
20
|
+
update_height
|
|
21
|
+
render_header_area(runs, streak)
|
|
22
|
+
|
|
23
|
+
if runs.empty?
|
|
24
|
+
@screen.render_empty_state
|
|
25
|
+
@screen.render_footer(empty: true)
|
|
26
|
+
else
|
|
27
|
+
rows = runs.map { |run| format_run(run) }
|
|
28
|
+
rows = truncate_rows_to_height(rows)
|
|
29
|
+
@screen.render_board(rows, cursor_index: cursor_index)
|
|
30
|
+
@screen.println unless rows.empty?
|
|
31
|
+
@screen.render_footer(empty: false, confirming: confirming)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@screen.clear_below
|
|
35
|
+
render_animations(runs)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def begin_frame
|
|
39
|
+
@screen.move_cursor_home
|
|
40
|
+
@spinner.advance!
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def clear
|
|
44
|
+
@screen.clear
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def resize(new_width)
|
|
48
|
+
@screen.width = new_width
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def animation_renderer?
|
|
52
|
+
!!@animation_renderer
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def render_header_area(runs, streak)
|
|
58
|
+
if @animation_renderer
|
|
59
|
+
@screen.write_at(HEADER_HEIGHT + 1, 1, "")
|
|
60
|
+
else
|
|
61
|
+
streak_text = StreakCounter.format_text(streak)
|
|
62
|
+
@screen.render_header(streak_text: streak_text)
|
|
63
|
+
(HEADER_HEIGHT - 1).times { @screen.println }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def format_run(run)
|
|
68
|
+
opts = {}
|
|
69
|
+
if run[:status] == "running"
|
|
70
|
+
active_stage = run[:stages].find { |s| s[:status] == "running" }
|
|
71
|
+
if active_stage && active_stage[:started_at]
|
|
72
|
+
opts[:elapsed_seconds] = Time.now - Time.parse(active_stage[:started_at])
|
|
73
|
+
end
|
|
74
|
+
opts[:spinner_frame] = @spinner.current_frame
|
|
75
|
+
end
|
|
76
|
+
RowFormatter.format(run, **opts)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def render_animations(runs)
|
|
80
|
+
return unless @animation_renderer
|
|
81
|
+
|
|
82
|
+
@animation_renderer.render(@screen, runs)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def update_height
|
|
86
|
+
return unless @height_provider
|
|
87
|
+
|
|
88
|
+
height = @height_provider.call
|
|
89
|
+
@screen.height = height if height
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def truncate_rows_to_height(rows)
|
|
93
|
+
height = @screen.height
|
|
94
|
+
return rows unless height
|
|
95
|
+
|
|
96
|
+
# Line budget: N rows consume 2N-1 lines (row + separator between rows).
|
|
97
|
+
# Remaining height after header, post-board separator, and footer:
|
|
98
|
+
# available = height - HEADER_HEIGHT - 2
|
|
99
|
+
# Solving 2N - 1 <= available gives N <= (available) / 2.
|
|
100
|
+
max_rows = [(height - HEADER_HEIGHT - 2) / 2, 0].max
|
|
101
|
+
rows.first(max_rows)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Tui
|
|
5
|
+
module DurationFormatter
|
|
6
|
+
def self.format(seconds)
|
|
7
|
+
if seconds >= 60
|
|
8
|
+
mins = (seconds / 60).to_i
|
|
9
|
+
secs = (seconds % 60).to_i
|
|
10
|
+
"#{mins}m#{secs.to_s.rjust(2, "0")}"
|
|
11
|
+
elsif seconds == seconds.to_i
|
|
12
|
+
"#{seconds.to_i}s"
|
|
13
|
+
else
|
|
14
|
+
"#{format_decimal(seconds)}s"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.format_decimal(value)
|
|
19
|
+
sprintf("%.1f", value)
|
|
20
|
+
end
|
|
21
|
+
private_class_method :format_decimal
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "header_animation_player"
|
|
4
|
+
require_relative "looping_animation_player"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Tui
|
|
8
|
+
class HeaderAnimationManager
|
|
9
|
+
HEADER_HEIGHT = 14
|
|
10
|
+
|
|
11
|
+
def initialize(animation_library:)
|
|
12
|
+
@idle_player = LoopingAnimationPlayer.new(animation_library.idle)
|
|
13
|
+
@running_player = nil
|
|
14
|
+
@header_player = nil
|
|
15
|
+
@animation_library = animation_library
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def trigger_failure
|
|
19
|
+
@header_player = HeaderAnimationPlayer.new(@animation_library.random_failure)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def trigger_success
|
|
23
|
+
@header_player = HeaderAnimationPlayer.new(@animation_library.random_success)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def start_running
|
|
27
|
+
@running_player ||= LoopingAnimationPlayer.new(@animation_library.running)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def stop_running
|
|
31
|
+
@running_player = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def current_lines(width)
|
|
35
|
+
lines = active_player.current_lines(width)
|
|
36
|
+
pad_to_height(lines, HEADER_HEIGHT)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def advance!
|
|
40
|
+
@idle_player.advance!
|
|
41
|
+
@running_player&.advance!
|
|
42
|
+
@header_player&.advance!
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def expire_if_finished!
|
|
46
|
+
@header_player = nil if @header_player&.finished?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def any_active_event?
|
|
50
|
+
@header_player && !@header_player.finished?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def active_player
|
|
56
|
+
return @header_player if any_active_event?
|
|
57
|
+
return @running_player if @running_player
|
|
58
|
+
|
|
59
|
+
@idle_player
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def pad_to_height(lines, height)
|
|
63
|
+
return lines if lines.length >= height
|
|
64
|
+
|
|
65
|
+
top_pad = (height - lines.length) / 2
|
|
66
|
+
blank = "\e[K"
|
|
67
|
+
Array.new(top_pad, blank) + lines + Array.new(height - lines.length - top_pad, blank)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|