supermicro 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 +224 -0
- data/lib/supermicro/boot.rb +206 -0
- data/lib/supermicro/client.rb +420 -0
- data/lib/supermicro/error.rb +10 -0
- data/lib/supermicro/jobs.rb +179 -0
- data/lib/supermicro/license.rb +132 -0
- data/lib/supermicro/power.rb +169 -0
- data/lib/supermicro/session.rb +121 -0
- data/lib/supermicro/spinner.rb +179 -0
- data/lib/supermicro/storage.rb +180 -0
- data/lib/supermicro/system.rb +275 -0
- data/lib/supermicro/system_config.rb +201 -0
- data/lib/supermicro/tasks.rb +139 -0
- data/lib/supermicro/utility.rb +235 -0
- data/lib/supermicro/version.rb +5 -0
- data/lib/supermicro/virtual_media.rb +372 -0
- data/lib/supermicro.rb +55 -0
- data/supermicro.gemspec +41 -0
- metadata +193 -0
@@ -0,0 +1,132 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module Supermicro
|
6
|
+
module License
|
7
|
+
# Check if the BMC has the required licenses for virtual media
|
8
|
+
def check_virtual_media_license
|
9
|
+
response = authenticated_request(:get, "/redfish/v1/Managers/1/LicenseManager/QueryLicense")
|
10
|
+
|
11
|
+
if response.status != 200
|
12
|
+
debug "Unable to query license status: #{response.status}", 1, :yellow
|
13
|
+
return {
|
14
|
+
available: :unknown,
|
15
|
+
message: "Unable to query license status",
|
16
|
+
licenses: []
|
17
|
+
}
|
18
|
+
end
|
19
|
+
|
20
|
+
begin
|
21
|
+
data = JSON.parse(response.body)
|
22
|
+
licenses = data["Licenses"] || []
|
23
|
+
|
24
|
+
found_licenses = []
|
25
|
+
has_oob = false
|
26
|
+
has_dcms = false
|
27
|
+
|
28
|
+
licenses.each do |license_json|
|
29
|
+
# Parse the nested JSON structure
|
30
|
+
license_data = JSON.parse(license_json) rescue nil
|
31
|
+
next unless license_data
|
32
|
+
|
33
|
+
license_name = license_data.dig("ProductKey", "Node", "LicenseName")
|
34
|
+
next unless license_name
|
35
|
+
|
36
|
+
found_licenses << license_name
|
37
|
+
|
38
|
+
# Check for required licenses
|
39
|
+
has_oob = true if license_name == "SFT-OOB-LIC"
|
40
|
+
has_dcms = true if license_name == "SFT-DCMS-SINGLE"
|
41
|
+
end
|
42
|
+
|
43
|
+
# Virtual media requires either SFT-OOB-LIC or SFT-DCMS-SINGLE
|
44
|
+
has_required_license = has_oob || has_dcms
|
45
|
+
|
46
|
+
{
|
47
|
+
available: has_required_license,
|
48
|
+
licenses: found_licenses,
|
49
|
+
message: if has_required_license
|
50
|
+
"Virtual media license present: #{found_licenses.join(', ')}"
|
51
|
+
elsif found_licenses.empty?
|
52
|
+
"No licenses found. Virtual media requires SFT-OOB-LIC or SFT-DCMS-SINGLE"
|
53
|
+
else
|
54
|
+
"Virtual media requires SFT-OOB-LIC or SFT-DCMS-SINGLE. Found: #{found_licenses.join(', ')}"
|
55
|
+
end
|
56
|
+
}
|
57
|
+
rescue JSON::ParserError => e
|
58
|
+
debug "Failed to parse license response: #{e.message}", 1, :red
|
59
|
+
{
|
60
|
+
available: :unknown,
|
61
|
+
message: "Failed to parse license response",
|
62
|
+
licenses: []
|
63
|
+
}
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# Get all licenses
|
68
|
+
def licenses
|
69
|
+
response = authenticated_request(:get, "/redfish/v1/Managers/1/LicenseManager/QueryLicense")
|
70
|
+
|
71
|
+
return [] if response.status != 200
|
72
|
+
|
73
|
+
begin
|
74
|
+
data = JSON.parse(response.body)
|
75
|
+
licenses = data["Licenses"] || []
|
76
|
+
|
77
|
+
parsed_licenses = []
|
78
|
+
licenses.each do |license_json|
|
79
|
+
license_data = JSON.parse(license_json) rescue nil
|
80
|
+
next unless license_data
|
81
|
+
|
82
|
+
node = license_data.dig("ProductKey", "Node") || {}
|
83
|
+
parsed_licenses << {
|
84
|
+
id: node["LicenseID"],
|
85
|
+
name: node["LicenseName"],
|
86
|
+
created: node["CreateDate"]
|
87
|
+
}
|
88
|
+
end
|
89
|
+
|
90
|
+
parsed_licenses
|
91
|
+
rescue JSON::ParserError
|
92
|
+
[]
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# Activate a license
|
97
|
+
def activate_license(license_key)
|
98
|
+
response = authenticated_request(
|
99
|
+
:post,
|
100
|
+
"/redfish/v1/Managers/1/LicenseManager/Actions/LicenseManager.ActivateLicense",
|
101
|
+
body: { "LicenseKey" => license_key }.to_json,
|
102
|
+
headers: { 'Content-Type': 'application/json' }
|
103
|
+
)
|
104
|
+
|
105
|
+
if response.status.between?(200, 299)
|
106
|
+
debug "License activated successfully", 1, :green
|
107
|
+
true
|
108
|
+
else
|
109
|
+
debug "Failed to activate license: #{response.status}", 1, :red
|
110
|
+
false
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
# Clear/remove a license
|
115
|
+
def clear_license(license_id)
|
116
|
+
response = authenticated_request(
|
117
|
+
:post,
|
118
|
+
"/redfish/v1/Managers/1/LicenseManager/Actions/LicenseManager.ClearLicense",
|
119
|
+
body: { "LicenseID" => license_id }.to_json,
|
120
|
+
headers: { 'Content-Type': 'application/json' }
|
121
|
+
)
|
122
|
+
|
123
|
+
if response.status.between?(200, 299)
|
124
|
+
debug "License cleared successfully", 1, :green
|
125
|
+
true
|
126
|
+
else
|
127
|
+
debug "Failed to clear license: #{response.status}", 1, :red
|
128
|
+
false
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
@@ -0,0 +1,169 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'colorize'
|
5
|
+
|
6
|
+
module Supermicro
|
7
|
+
module Power
|
8
|
+
def power_status
|
9
|
+
response = authenticated_request(:get, "/redfish/v1/Systems/1?$select=PowerState")
|
10
|
+
|
11
|
+
if response.status == 200
|
12
|
+
begin
|
13
|
+
data = JSON.parse(response.body)
|
14
|
+
power_state = data["PowerState"]
|
15
|
+
return power_state
|
16
|
+
rescue JSON::ParserError
|
17
|
+
raise Error, "Failed to parse power status response: #{response.body}"
|
18
|
+
end
|
19
|
+
else
|
20
|
+
raise Error, "Failed to get power status. Status code: #{response.status}"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def power_on
|
25
|
+
current_state = power_status
|
26
|
+
|
27
|
+
if current_state == "On"
|
28
|
+
puts "System is already powered on.".yellow
|
29
|
+
return true
|
30
|
+
end
|
31
|
+
|
32
|
+
puts "Powering on system...".yellow
|
33
|
+
|
34
|
+
response = authenticated_request(
|
35
|
+
:post,
|
36
|
+
"/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
|
37
|
+
body: { "ResetType": "On" }.to_json,
|
38
|
+
headers: { 'Content-Type': 'application/json' }
|
39
|
+
)
|
40
|
+
|
41
|
+
if response.status.between?(200, 299)
|
42
|
+
puts "Power on command sent successfully.".green
|
43
|
+
return true
|
44
|
+
else
|
45
|
+
raise Error, "Failed to power on system: #{response.status} - #{response.body}"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def power_off(force: false)
|
50
|
+
current_state = power_status
|
51
|
+
|
52
|
+
if current_state == "Off"
|
53
|
+
puts "System is already powered off.".yellow
|
54
|
+
return true
|
55
|
+
end
|
56
|
+
|
57
|
+
reset_type = force ? "ForceOff" : "GracefulShutdown"
|
58
|
+
puts "#{force ? 'Force powering' : 'Gracefully shutting'} off system...".yellow
|
59
|
+
|
60
|
+
response = authenticated_request(
|
61
|
+
:post,
|
62
|
+
"/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
|
63
|
+
body: { "ResetType": reset_type }.to_json,
|
64
|
+
headers: { 'Content-Type': 'application/json' }
|
65
|
+
)
|
66
|
+
|
67
|
+
if response.status.between?(200, 299)
|
68
|
+
puts "Power off command sent successfully.".green
|
69
|
+
return true
|
70
|
+
elsif !force
|
71
|
+
puts "Graceful shutdown failed, trying force off...".yellow
|
72
|
+
|
73
|
+
response = authenticated_request(
|
74
|
+
:post,
|
75
|
+
"/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
|
76
|
+
body: { "ResetType": "ForceOff" }.to_json,
|
77
|
+
headers: { 'Content-Type': 'application/json' }
|
78
|
+
)
|
79
|
+
|
80
|
+
if response.status.between?(200, 299)
|
81
|
+
puts "Force power off command sent successfully.".green
|
82
|
+
return true
|
83
|
+
else
|
84
|
+
raise Error, "Failed to power off system: #{response.status} - #{response.body}"
|
85
|
+
end
|
86
|
+
else
|
87
|
+
raise Error, "Failed to force power off system: #{response.status} - #{response.body}"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def power_restart(force: false)
|
92
|
+
reset_type = force ? "ForceRestart" : "GracefulRestart"
|
93
|
+
puts "#{force ? 'Force restarting' : 'Gracefully restarting'} system...".yellow
|
94
|
+
|
95
|
+
response = authenticated_request(
|
96
|
+
:post,
|
97
|
+
"/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
|
98
|
+
body: { "ResetType": reset_type }.to_json,
|
99
|
+
headers: { 'Content-Type': 'application/json' }
|
100
|
+
)
|
101
|
+
|
102
|
+
if response.status.between?(200, 299)
|
103
|
+
puts "Restart command sent successfully.".green
|
104
|
+
return true
|
105
|
+
elsif !force
|
106
|
+
puts "Graceful restart failed, trying force restart...".yellow
|
107
|
+
|
108
|
+
response = authenticated_request(
|
109
|
+
:post,
|
110
|
+
"/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
|
111
|
+
body: { "ResetType": "ForceRestart" }.to_json,
|
112
|
+
headers: { 'Content-Type': 'application/json' }
|
113
|
+
)
|
114
|
+
|
115
|
+
if response.status.between?(200, 299)
|
116
|
+
puts "Force restart command sent successfully.".green
|
117
|
+
return true
|
118
|
+
else
|
119
|
+
raise Error, "Failed to restart system: #{response.status} - #{response.body}"
|
120
|
+
end
|
121
|
+
else
|
122
|
+
raise Error, "Failed to force restart system: #{response.status} - #{response.body}"
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def power_cycle
|
127
|
+
puts "Power cycling system...".yellow
|
128
|
+
|
129
|
+
response = authenticated_request(
|
130
|
+
:post,
|
131
|
+
"/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
|
132
|
+
body: { "ResetType": "PowerCycle" }.to_json,
|
133
|
+
headers: { 'Content-Type': 'application/json' }
|
134
|
+
)
|
135
|
+
|
136
|
+
if response.status.between?(200, 299)
|
137
|
+
puts "Power cycle command sent successfully.".green
|
138
|
+
return true
|
139
|
+
else
|
140
|
+
raise Error, "Failed to power cycle system: #{response.status} - #{response.body}"
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def reset_type_allowed
|
145
|
+
response = authenticated_request(:get, "/redfish/v1/Systems/1")
|
146
|
+
|
147
|
+
if response.status == 200
|
148
|
+
begin
|
149
|
+
data = JSON.parse(response.body)
|
150
|
+
|
151
|
+
allowed_values = data.dig("Actions", "#ComputerSystem.Reset", "ResetType@Redfish.AllowableValues")
|
152
|
+
|
153
|
+
if allowed_values
|
154
|
+
puts "Allowed reset types:".green
|
155
|
+
allowed_values.each { |type| puts " - #{type}" }
|
156
|
+
return allowed_values
|
157
|
+
else
|
158
|
+
puts "Could not determine allowed reset types".yellow
|
159
|
+
return []
|
160
|
+
end
|
161
|
+
rescue JSON::ParserError
|
162
|
+
raise Error, "Failed to parse system response: #{response.body}"
|
163
|
+
end
|
164
|
+
else
|
165
|
+
raise Error, "Failed to get system info. Status code: #{response.status}"
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'faraday'
|
5
|
+
require 'base64'
|
6
|
+
|
7
|
+
module Supermicro
|
8
|
+
class Session
|
9
|
+
attr_reader :client, :x_auth_token, :session_id
|
10
|
+
|
11
|
+
include Debuggable
|
12
|
+
|
13
|
+
def initialize(client)
|
14
|
+
@client = client
|
15
|
+
@x_auth_token = nil
|
16
|
+
@session_id = nil
|
17
|
+
end
|
18
|
+
|
19
|
+
def connection
|
20
|
+
@connection ||= Faraday.new(url: client.base_url, ssl: { verify: client.verify_ssl }) do |faraday|
|
21
|
+
faraday.request :url_encoded
|
22
|
+
faraday.adapter Faraday.default_adapter
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def create
|
27
|
+
debug "Creating Redfish session for #{client.host}", 1
|
28
|
+
|
29
|
+
payload = {
|
30
|
+
UserName: client.username,
|
31
|
+
Password: client.password
|
32
|
+
}.to_json
|
33
|
+
|
34
|
+
headers = {
|
35
|
+
'Content-Type' => 'application/json',
|
36
|
+
'Accept' => 'application/json'
|
37
|
+
}
|
38
|
+
headers['Host'] = client.host_header if client.host_header
|
39
|
+
|
40
|
+
begin
|
41
|
+
response = connection.post('/redfish/v1/SessionService/Sessions', payload, headers)
|
42
|
+
|
43
|
+
if response.status == 201
|
44
|
+
@x_auth_token = response.headers['x-auth-token']
|
45
|
+
|
46
|
+
if response.headers['location']
|
47
|
+
@session_id = response.headers['location'].split('/').last
|
48
|
+
end
|
49
|
+
|
50
|
+
begin
|
51
|
+
body = JSON.parse(response.body)
|
52
|
+
@session_id ||= body["Id"] if body.is_a?(Hash)
|
53
|
+
rescue JSON::ParserError
|
54
|
+
end
|
55
|
+
|
56
|
+
debug "Session created successfully. Token: #{@x_auth_token ? @x_auth_token[0..10] + '...' : 'nil'}", 1, :green
|
57
|
+
return true
|
58
|
+
else
|
59
|
+
debug "Failed to create session. Status: #{response.status}", 1, :red
|
60
|
+
debug "Response: #{response.body}", 2
|
61
|
+
return false
|
62
|
+
end
|
63
|
+
rescue Faraday::Error => e
|
64
|
+
debug "Connection error creating session: #{e.message}", 1, :red
|
65
|
+
return false
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def delete
|
70
|
+
return unless @x_auth_token && @session_id
|
71
|
+
|
72
|
+
debug "Deleting session #{@session_id}", 1
|
73
|
+
|
74
|
+
headers = {
|
75
|
+
'X-Auth-Token' => @x_auth_token,
|
76
|
+
'Accept' => 'application/json'
|
77
|
+
}
|
78
|
+
headers['Host'] = client.host_header if client.host_header
|
79
|
+
|
80
|
+
begin
|
81
|
+
response = connection.delete("/redfish/v1/SessionService/Sessions/#{@session_id}", nil, headers)
|
82
|
+
|
83
|
+
if response.status == 204 || response.status == 200
|
84
|
+
debug "Session deleted successfully", 1, :green
|
85
|
+
@x_auth_token = nil
|
86
|
+
@session_id = nil
|
87
|
+
return true
|
88
|
+
else
|
89
|
+
debug "Failed to delete session. Status: #{response.status}", 1, :yellow
|
90
|
+
return false
|
91
|
+
end
|
92
|
+
rescue Faraday::Error => e
|
93
|
+
debug "Error deleting session: #{e.message}", 1, :yellow
|
94
|
+
return false
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
def valid?
|
99
|
+
return false unless @x_auth_token
|
100
|
+
|
101
|
+
headers = {
|
102
|
+
'X-Auth-Token' => @x_auth_token,
|
103
|
+
'Accept' => 'application/json'
|
104
|
+
}
|
105
|
+
headers['Host'] = client.host_header if client.host_header
|
106
|
+
|
107
|
+
begin
|
108
|
+
response = connection.get("/redfish/v1/SessionService/Sessions/#{@session_id}", nil, headers)
|
109
|
+
response.status == 200
|
110
|
+
rescue
|
111
|
+
false
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
private
|
116
|
+
|
117
|
+
def verbosity
|
118
|
+
client.verbosity
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
@@ -0,0 +1,179 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Supermicro
|
4
|
+
class Spinner
|
5
|
+
SPINNERS = {
|
6
|
+
dots: {
|
7
|
+
frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
|
8
|
+
interval: 0.08
|
9
|
+
},
|
10
|
+
line: {
|
11
|
+
frames: ['-', '\\', '|', '/'],
|
12
|
+
interval: 0.1
|
13
|
+
},
|
14
|
+
arrow: {
|
15
|
+
frames: ['←', '↖', '↑', '↗', '→', '↘', '↓', '↙'],
|
16
|
+
interval: 0.1
|
17
|
+
},
|
18
|
+
bounce: {
|
19
|
+
frames: ['⠁', '⠂', '⠄', '⡀', '⡈', '⡐', '⡠', '⣀', '⣁', '⣂', '⣄', '⣌', '⣔', '⣤', '⣥', '⣦', '⣮', '⣶', '⣷', '⣿', '⡿', '⠿', '⢟', '⠟', '⡛', '⠛', '⠫', '⢋', '⠋', '⠍', '⡉', '⠉', '⠑', '⠡', '⢁'],
|
20
|
+
interval: 0.08
|
21
|
+
},
|
22
|
+
bar: {
|
23
|
+
frames: ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█', '▇', '▆', '▅', '▄', '▃', '▂'],
|
24
|
+
interval: 0.08
|
25
|
+
}
|
26
|
+
}
|
27
|
+
|
28
|
+
def initialize(message = "Working", type: :dots, color: :cyan)
|
29
|
+
@message = message
|
30
|
+
@type = type
|
31
|
+
@color = color
|
32
|
+
@running = false
|
33
|
+
@thread = nil
|
34
|
+
@current_frame = 0
|
35
|
+
@spinner_config = SPINNERS[@type] || SPINNERS[:dots]
|
36
|
+
@start_time = nil
|
37
|
+
@last_update = nil
|
38
|
+
@max_width = 0 # Track the maximum width we've printed
|
39
|
+
end
|
40
|
+
|
41
|
+
def start
|
42
|
+
return if @running
|
43
|
+
@running = true
|
44
|
+
@start_time = Time.now
|
45
|
+
@current_frame = 0
|
46
|
+
|
47
|
+
@thread = Thread.new do
|
48
|
+
while @running
|
49
|
+
render
|
50
|
+
sleep @spinner_config[:interval]
|
51
|
+
@current_frame = (@current_frame + 1) % @spinner_config[:frames].length
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def update(message)
|
57
|
+
@message = message
|
58
|
+
@last_update = Time.now
|
59
|
+
# Immediately render to show the update
|
60
|
+
render if @running
|
61
|
+
end
|
62
|
+
|
63
|
+
def stop(final_message = nil, success: true)
|
64
|
+
return unless @running
|
65
|
+
@running = false
|
66
|
+
@thread&.join
|
67
|
+
|
68
|
+
# Clear the spinner line completely
|
69
|
+
print "\r\033[2K"
|
70
|
+
|
71
|
+
if final_message
|
72
|
+
icon = success ? "✓".green : "✗".red
|
73
|
+
elapsed = Time.now - @start_time
|
74
|
+
time_str = elapsed > 1 ? " (#{elapsed.round(1)}s)" : ""
|
75
|
+
puts "#{icon} #{final_message}#{time_str}"
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def with_spinner
|
80
|
+
start
|
81
|
+
begin
|
82
|
+
yield self
|
83
|
+
ensure
|
84
|
+
stop
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
private
|
89
|
+
|
90
|
+
def render
|
91
|
+
frame = @spinner_config[:frames][@current_frame]
|
92
|
+
elapsed = Time.now - @start_time
|
93
|
+
time_str = elapsed > 2 ? " (#{elapsed.round}s)" : ""
|
94
|
+
|
95
|
+
# Build the output string without color codes to calculate real width
|
96
|
+
text_content = "#{frame} #{@message}#{time_str}"
|
97
|
+
current_width = text_content.length
|
98
|
+
|
99
|
+
# Track maximum width seen
|
100
|
+
@max_width = current_width if current_width > @max_width
|
101
|
+
|
102
|
+
# Pad with spaces to clear any leftover text from longer previous messages
|
103
|
+
padding = @max_width > current_width ? " " * (@max_width - current_width) : ""
|
104
|
+
|
105
|
+
# Build the colored output
|
106
|
+
output = "\r#{frame.send(@color)} #{@message}#{time_str}#{padding}"
|
107
|
+
|
108
|
+
# Print without newline and flush immediately
|
109
|
+
print output
|
110
|
+
$stdout.flush
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
module SpinnerHelper
|
115
|
+
def with_spinner(message, type: :dots, color: :cyan, &block)
|
116
|
+
return yield if respond_to?(:verbosity) && verbosity > 0
|
117
|
+
|
118
|
+
spinner = Spinner.new(message, type: type, color: color)
|
119
|
+
spinner.start
|
120
|
+
|
121
|
+
begin
|
122
|
+
result = yield(spinner)
|
123
|
+
spinner.stop("#{message} - Complete", success: true)
|
124
|
+
result
|
125
|
+
rescue => e
|
126
|
+
spinner.stop("#{message} - Failed", success: false)
|
127
|
+
raise e
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def show_progress(message, duration: nil, &block)
|
132
|
+
if duration
|
133
|
+
# Show progress bar if duration is known
|
134
|
+
with_progress_bar(message, duration, &block)
|
135
|
+
else
|
136
|
+
# Show spinner if duration is unknown
|
137
|
+
with_spinner(message, &block)
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
private
|
142
|
+
|
143
|
+
def with_progress_bar(message, duration, &block)
|
144
|
+
return yield if respond_to?(:verbosity) && verbosity > 0
|
145
|
+
|
146
|
+
start_time = Time.now
|
147
|
+
width = 30
|
148
|
+
|
149
|
+
thread = Thread.new do
|
150
|
+
while true
|
151
|
+
elapsed = Time.now - start_time
|
152
|
+
progress = [elapsed / duration, 1.0].min
|
153
|
+
filled = (progress * width).round
|
154
|
+
bar = "█" * filled + "░" * (width - filled)
|
155
|
+
percent = (progress * 100).round
|
156
|
+
|
157
|
+
print "\r#{message}: [#{bar}] #{percent}%"
|
158
|
+
$stdout.flush
|
159
|
+
|
160
|
+
break if progress >= 1.0
|
161
|
+
sleep 0.1
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
begin
|
166
|
+
result = yield
|
167
|
+
thread.join
|
168
|
+
print "\r\e[K"
|
169
|
+
puts "✓ #{message} - Complete".green
|
170
|
+
result
|
171
|
+
rescue => e
|
172
|
+
thread.kill
|
173
|
+
print "\r\e[K"
|
174
|
+
puts "✗ #{message} - Failed".red
|
175
|
+
raise e
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|