nanokvm 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/CHANGELOG.md +24 -0
- data/README.md +314 -0
- data/bin/nanokvm +9 -0
- data/lib/nanokvm/cli.rb +793 -0
- data/lib/nanokvm/client.rb +373 -0
- data/lib/nanokvm/errors.rb +8 -0
- data/lib/nanokvm/version.rb +5 -0
- data/lib/nanokvm.rb +36 -0
- metadata +154 -0
data/lib/nanokvm/cli.rb
ADDED
@@ -0,0 +1,793 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "thor"
|
4
|
+
require "terminal-table"
|
5
|
+
require "yaml"
|
6
|
+
require "fileutils"
|
7
|
+
require "net/http"
|
8
|
+
require "json"
|
9
|
+
|
10
|
+
module NanoKVM
|
11
|
+
class CLI < Thor
|
12
|
+
CONFIG_DIR = File.expand_path("~/.config/nanokvm")
|
13
|
+
CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
|
14
|
+
|
15
|
+
def self.exit_on_failure?
|
16
|
+
true
|
17
|
+
end
|
18
|
+
|
19
|
+
# Global options
|
20
|
+
class_option :debug, type: :boolean, desc: "Enable debug output"
|
21
|
+
class_option :secret_key, type: :string, desc: "Secret key for password encryption (default: nanokvm-sipeed-2024)"
|
22
|
+
class_option :host, type: :string, desc: "NanoKVM host (IP or hostname)"
|
23
|
+
class_option :username, type: :string, desc: "Username (default: admin)"
|
24
|
+
class_option :password, type: :string, desc: "Password (default: admin)"
|
25
|
+
|
26
|
+
desc "configure", "Configure default settings"
|
27
|
+
option :token, type: :string, desc: "Default token (optional)"
|
28
|
+
def configure
|
29
|
+
unless Dir.exist?(CONFIG_DIR)
|
30
|
+
FileUtils.mkdir_p(CONFIG_DIR)
|
31
|
+
end
|
32
|
+
|
33
|
+
# Load existing config
|
34
|
+
config = if File.exist?(CONFIG_FILE)
|
35
|
+
File.open(CONFIG_FILE) { |f| YAML.safe_load(f, symbolize_names: true) } rescue {}
|
36
|
+
else
|
37
|
+
{}
|
38
|
+
end
|
39
|
+
|
40
|
+
# Update with provided options
|
41
|
+
config[:host] = options[:host] if options[:host]
|
42
|
+
config[:username] = options[:username] if options[:username]
|
43
|
+
config[:password] = options[:password] if options[:password]
|
44
|
+
config[:token] = options[:token] if options[:token]
|
45
|
+
config[:secret_key] = options[:secret_key] if options[:secret_key]
|
46
|
+
|
47
|
+
# Save config
|
48
|
+
File.open(CONFIG_FILE, "w") { |f| f.write(config.to_yaml) }
|
49
|
+
|
50
|
+
# Update current configuration
|
51
|
+
NanoKVM.configure do |c|
|
52
|
+
c.default_host = config[:host]
|
53
|
+
c.default_username = config[:username]
|
54
|
+
c.default_password = config[:password]
|
55
|
+
end
|
56
|
+
|
57
|
+
puts "Configuration saved"
|
58
|
+
end
|
59
|
+
|
60
|
+
desc "info", "Get device information"
|
61
|
+
def info
|
62
|
+
display_as_table("NanoKVM Information", :get_device_info)
|
63
|
+
end
|
64
|
+
|
65
|
+
desc "power", "Press power button"
|
66
|
+
option :duration, type: :numeric, default: 800, desc: "Button press duration in ms (800=short, 8000=long)"
|
67
|
+
def power
|
68
|
+
client = authenticated_client
|
69
|
+
client.power_button(options[:duration])
|
70
|
+
puts "Power button pressed for #{options[:duration]}ms"
|
71
|
+
end
|
72
|
+
|
73
|
+
desc "reset", "Press reset button"
|
74
|
+
def reset
|
75
|
+
client = authenticated_client
|
76
|
+
client.reset_button
|
77
|
+
puts "Reset button pressed"
|
78
|
+
end
|
79
|
+
|
80
|
+
desc "leds", "Get LED status"
|
81
|
+
def leds
|
82
|
+
display_as_table("NanoKVM LEDs", :get_gpio_state)
|
83
|
+
end
|
84
|
+
|
85
|
+
desc "images", "List available images"
|
86
|
+
def images
|
87
|
+
client = create_client
|
88
|
+
ensure_authenticated(client)
|
89
|
+
|
90
|
+
begin
|
91
|
+
# Get available images
|
92
|
+
available = client.get_available_images["data"]["files"] rescue []
|
93
|
+
|
94
|
+
# Get mounted image for comparison
|
95
|
+
mounted = begin
|
96
|
+
client.get_mounted_image["data"]["file"]
|
97
|
+
rescue
|
98
|
+
nil
|
99
|
+
end
|
100
|
+
|
101
|
+
rows = []
|
102
|
+
if available.nil?
|
103
|
+
puts "No images available"
|
104
|
+
return
|
105
|
+
end
|
106
|
+
|
107
|
+
available.each do |image|
|
108
|
+
rows << [image, image == mounted ? "MOUNTED" : ""]
|
109
|
+
end
|
110
|
+
|
111
|
+
if rows.empty?
|
112
|
+
puts "No images available"
|
113
|
+
return
|
114
|
+
end
|
115
|
+
|
116
|
+
table = ::Terminal::Table.new(
|
117
|
+
title: "Available Images",
|
118
|
+
headings: ["Image Path", "Status"],
|
119
|
+
rows: rows
|
120
|
+
)
|
121
|
+
|
122
|
+
puts table
|
123
|
+
rescue NanoKVM::ApiError => e
|
124
|
+
puts "Error listing images: #{e.message}"
|
125
|
+
puts "This feature may not be available on your NanoKVM device/firmware."
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
desc "mount IMAGE_PATH", "Mount an image"
|
130
|
+
def mount(image_path)
|
131
|
+
client = authenticated_client
|
132
|
+
client.mount_image(image_path)
|
133
|
+
puts "Image #{image_path} mounted successfully"
|
134
|
+
end
|
135
|
+
|
136
|
+
desc "type TEXT", "Send text to the target"
|
137
|
+
def type(text)
|
138
|
+
client = authenticated_client
|
139
|
+
client.send_text(text)
|
140
|
+
puts "Text sent successfully"
|
141
|
+
end
|
142
|
+
|
143
|
+
desc "virtual", "Show virtual device status"
|
144
|
+
def virtual
|
145
|
+
display_as_table("Virtual Devices", :get_virtual_device_status)
|
146
|
+
end
|
147
|
+
|
148
|
+
desc "toggle DEVICE", "Toggle virtual device (network or disk)"
|
149
|
+
def toggle(device)
|
150
|
+
unless ["network", "disk"].include?(device)
|
151
|
+
puts "Error: Device must be 'network' or 'disk'"
|
152
|
+
return
|
153
|
+
end
|
154
|
+
|
155
|
+
client = authenticated_client
|
156
|
+
result = client.toggle_virtual_device(device)
|
157
|
+
state = result["data"]["on"]
|
158
|
+
|
159
|
+
puts "#{device.capitalize} is now #{state ? 'ENABLED' : 'DISABLED'}"
|
160
|
+
end
|
161
|
+
|
162
|
+
desc "account", "Get account information"
|
163
|
+
def account
|
164
|
+
display_as_table("NanoKVM Account Information", :get_account_info)
|
165
|
+
end
|
166
|
+
|
167
|
+
desc "network", "Get network information"
|
168
|
+
def network
|
169
|
+
display_as_table("NanoKVM Network Information", :get_network_info)
|
170
|
+
end
|
171
|
+
|
172
|
+
desc "version", "Get application version"
|
173
|
+
def version
|
174
|
+
display_as_table("NanoKVM Application Version", :get_app_version)
|
175
|
+
end
|
176
|
+
|
177
|
+
desc "reboot", "Reboot the NanoKVM system"
|
178
|
+
def reboot
|
179
|
+
client = authenticated_client
|
180
|
+
puts "Rebooting NanoKVM system..."
|
181
|
+
client.system_reboot
|
182
|
+
puts "Reboot command sent successfully."
|
183
|
+
puts "Note: The device will be unavailable during the reboot process."
|
184
|
+
end
|
185
|
+
|
186
|
+
desc "reset-hdmi", "Reset HDMI subsystem"
|
187
|
+
def reset_hdmi
|
188
|
+
client = authenticated_client
|
189
|
+
puts "Resetting HDMI subsystem..."
|
190
|
+
client.reset_hdmi
|
191
|
+
puts "HDMI reset command sent successfully."
|
192
|
+
end
|
193
|
+
|
194
|
+
desc "reset-hid", "Reset HID (keyboard/mouse) subsystem"
|
195
|
+
def reset_hid
|
196
|
+
client = authenticated_client
|
197
|
+
puts "Resetting HID subsystem..."
|
198
|
+
client.reset_hid
|
199
|
+
puts "HID reset command sent successfully."
|
200
|
+
end
|
201
|
+
|
202
|
+
desc "is-password-updated", "Check if password has been updated from default"
|
203
|
+
def is_password_updated
|
204
|
+
client = authenticated_client
|
205
|
+
begin
|
206
|
+
response = client.is_password_updated["data"]
|
207
|
+
updated = response["updated"] == true
|
208
|
+
puts "Password status: #{updated ? 'Updated from default' : 'Still using default password'}"
|
209
|
+
rescue NanoKVM::ApiError => e
|
210
|
+
puts "Error checking password status: #{e.message}"
|
211
|
+
puts "This feature may not be available on your NanoKVM device/firmware."
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
desc "tailscale", "Check Tailscale extension status"
|
216
|
+
def tailscale
|
217
|
+
display_as_table("NanoKVM Tailscale Status", :get_tailscale_status)
|
218
|
+
end
|
219
|
+
|
220
|
+
desc "download-enabled", "Check if downloading images is enabled"
|
221
|
+
def download_enabled
|
222
|
+
client = authenticated_client
|
223
|
+
begin
|
224
|
+
response = client.is_download_image_enabled["data"]
|
225
|
+
enabled = response["enabled"] == true
|
226
|
+
puts "Image download: #{enabled ? 'ENABLED' : 'DISABLED'}"
|
227
|
+
rescue NanoKVM::ApiError => e
|
228
|
+
puts "Error checking image download status: #{e.message}"
|
229
|
+
puts "This feature may not be available on your NanoKVM device/firmware."
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
desc "download-status", "Check status of image download"
|
234
|
+
option :monitor, type: :boolean, desc: "Monitor until download completes"
|
235
|
+
option :interval, type: :numeric, default: 3, desc: "Refresh interval in seconds when monitoring"
|
236
|
+
option :timeout, type: :numeric, desc: "Stop monitoring after this many seconds"
|
237
|
+
def download_status
|
238
|
+
client = authenticated_client
|
239
|
+
begin
|
240
|
+
if options[:monitor]
|
241
|
+
show_download_progress(client, options[:interval], options[:timeout])
|
242
|
+
else
|
243
|
+
display_download_status(client)
|
244
|
+
end
|
245
|
+
rescue NanoKVM::ApiError => e
|
246
|
+
puts "Error checking image download status: #{e.message}"
|
247
|
+
puts "This feature may not be available on your NanoKVM device/firmware."
|
248
|
+
rescue Interrupt
|
249
|
+
puts "\nMonitoring stopped"
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
desc "download-image URL", "Download an image from URL"
|
254
|
+
def download_image(url)
|
255
|
+
client = authenticated_client
|
256
|
+
begin
|
257
|
+
puts "Starting download of #{url}..."
|
258
|
+
client.download_image(url)
|
259
|
+
puts "Download initiated successfully."
|
260
|
+
puts "Use 'nanokvm download-status' to check progress."
|
261
|
+
rescue NanoKVM::ApiError => e
|
262
|
+
puts "Error starting image download: #{e.message}"
|
263
|
+
puts "This feature may not be available on your NanoKVM device/firmware."
|
264
|
+
end
|
265
|
+
end
|
266
|
+
|
267
|
+
desc "hardware", "Get hardware information"
|
268
|
+
def hardware
|
269
|
+
display_as_table("NanoKVM Hardware Information", :get_hardware_info)
|
270
|
+
end
|
271
|
+
|
272
|
+
desc "memory-limit", "Get or set memory limit"
|
273
|
+
option :enabled, type: :boolean, desc: "Enable or disable memory limit"
|
274
|
+
option :limit, type: :numeric, desc: "Memory limit in MB"
|
275
|
+
def memory_limit
|
276
|
+
client = authenticated_client
|
277
|
+
|
278
|
+
if options[:enabled].nil? && options[:limit].nil?
|
279
|
+
# Get current setting
|
280
|
+
display_as_table("Memory Limit Settings", :get_memory_limit)
|
281
|
+
else
|
282
|
+
# Set new values
|
283
|
+
unless options[:enabled] && options[:limit]
|
284
|
+
puts "Error: Both --enabled and --limit are required to set memory limit"
|
285
|
+
return
|
286
|
+
end
|
287
|
+
|
288
|
+
begin
|
289
|
+
client.set_memory_limit(options[:enabled], options[:limit])
|
290
|
+
puts "Memory limit updated successfully"
|
291
|
+
rescue NanoKVM::ApiError => e
|
292
|
+
puts "Error setting memory limit: #{e.message}"
|
293
|
+
puts "This feature may not be available on your NanoKVM device/firmware."
|
294
|
+
end
|
295
|
+
end
|
296
|
+
end
|
297
|
+
|
298
|
+
desc "oled", "Get or set OLED settings"
|
299
|
+
option :sleep, type: :numeric, desc: "Sleep timeout in seconds"
|
300
|
+
def oled
|
301
|
+
client = authenticated_client
|
302
|
+
|
303
|
+
if options[:sleep].nil?
|
304
|
+
# Get current setting
|
305
|
+
display_as_table("OLED Settings", :get_oled_info)
|
306
|
+
else
|
307
|
+
# Set new sleep timeout
|
308
|
+
begin
|
309
|
+
client.set_oled_sleep(options[:sleep])
|
310
|
+
puts "OLED sleep timeout updated to #{options[:sleep]} seconds"
|
311
|
+
rescue NanoKVM::ApiError => e
|
312
|
+
puts "Error setting OLED sleep timeout: #{e.message}"
|
313
|
+
puts "This feature may not be available on your NanoKVM device/firmware."
|
314
|
+
end
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
desc "ssh-status", "Get SSH server status"
|
319
|
+
def ssh_status
|
320
|
+
display_as_table("SSH Server Status", :get_ssh_status)
|
321
|
+
end
|
322
|
+
|
323
|
+
desc "cdrom", "Get CD-ROM information"
|
324
|
+
def cdrom
|
325
|
+
display_as_table("CD-ROM Information", :get_cdrom_info)
|
326
|
+
end
|
327
|
+
|
328
|
+
desc "hid-mode", "Get or set HID mode"
|
329
|
+
option :mode, type: :string, desc: "HID mode (usb or ps2)"
|
330
|
+
def hid_mode
|
331
|
+
client = authenticated_client
|
332
|
+
|
333
|
+
if options[:mode].nil?
|
334
|
+
# Get current setting
|
335
|
+
display_as_table("HID Mode", :get_hid_mode)
|
336
|
+
else
|
337
|
+
# Set new mode
|
338
|
+
unless ["usb", "ps2"].include?(options[:mode].downcase)
|
339
|
+
puts "Error: Mode must be 'usb' or 'ps2'"
|
340
|
+
return
|
341
|
+
end
|
342
|
+
|
343
|
+
begin
|
344
|
+
client.set_hid_mode(options[:mode].downcase)
|
345
|
+
puts "HID mode updated to #{options[:mode]}"
|
346
|
+
puts "Note: This change requires a reboot to take effect"
|
347
|
+
rescue NanoKVM::ApiError => e
|
348
|
+
puts "Error setting HID mode: #{e.message}"
|
349
|
+
puts "This feature may not be available on your NanoKVM device/firmware."
|
350
|
+
end
|
351
|
+
end
|
352
|
+
end
|
353
|
+
|
354
|
+
desc "script", "Get script information"
|
355
|
+
def script
|
356
|
+
display_as_table("Script Information", :get_script_info)
|
357
|
+
end
|
358
|
+
|
359
|
+
desc "mdns", "Get mDNS status"
|
360
|
+
def mdns
|
361
|
+
display_as_table("mDNS Status", :get_mdns_status)
|
362
|
+
end
|
363
|
+
|
364
|
+
desc "wol-mac", "Get Wake-on-LAN MAC address"
|
365
|
+
def wol_mac
|
366
|
+
display_as_table("Wake-on-LAN MAC Address", :get_wol_mac)
|
367
|
+
end
|
368
|
+
|
369
|
+
desc "preview", "Get or set preview state"
|
370
|
+
option :enable, type: :boolean, desc: "Enable or disable preview"
|
371
|
+
def preview
|
372
|
+
client = authenticated_client
|
373
|
+
|
374
|
+
if options[:enable].nil?
|
375
|
+
# Get current setting
|
376
|
+
display_as_table("Preview State", :get_preview_state)
|
377
|
+
else
|
378
|
+
# Set new state
|
379
|
+
begin
|
380
|
+
client.set_preview_state(options[:enable])
|
381
|
+
puts "Preview #{options[:enable] ? 'enabled' : 'disabled'}"
|
382
|
+
rescue NanoKVM::ApiError => e
|
383
|
+
puts "Error setting preview state: #{e.message}"
|
384
|
+
puts "This feature may not be available on your NanoKVM device/firmware."
|
385
|
+
end
|
386
|
+
end
|
387
|
+
end
|
388
|
+
|
389
|
+
desc "test", "Test connection to NanoKVM"
|
390
|
+
def test
|
391
|
+
puts "Testing connection to NanoKVM..."
|
392
|
+
|
393
|
+
client = create_client
|
394
|
+
client.debug = true
|
395
|
+
|
396
|
+
begin
|
397
|
+
puts "Connecting to #{client.host}..."
|
398
|
+
# Try a simple HTTP GET to see if the server is up
|
399
|
+
uri = URI.parse("http://#{client.host}")
|
400
|
+
response = Net::HTTP.get_response(uri)
|
401
|
+
puts "Server responded with status: #{response.code}"
|
402
|
+
|
403
|
+
if response.code == "200"
|
404
|
+
puts "Connection successful, attempting authentication..."
|
405
|
+
ensure_authenticated(client)
|
406
|
+
puts "Authentication successful!"
|
407
|
+
else
|
408
|
+
puts "Server responded, but with an unexpected status code"
|
409
|
+
end
|
410
|
+
rescue => e
|
411
|
+
puts "Connection failed: #{e.message}"
|
412
|
+
end
|
413
|
+
end
|
414
|
+
|
415
|
+
desc "interfaces", "Get status of HID, ETH, and HDMI interfaces"
|
416
|
+
def interfaces
|
417
|
+
display_as_table("Interface Status", :get_interface_status)
|
418
|
+
end
|
419
|
+
|
420
|
+
private
|
421
|
+
|
422
|
+
def load_config
|
423
|
+
return {} unless File.exist?(CONFIG_FILE)
|
424
|
+
|
425
|
+
YAML.safe_load(File.read(CONFIG_FILE), symbolize_names: true) rescue {}
|
426
|
+
end
|
427
|
+
|
428
|
+
def create_client
|
429
|
+
# Load saved config
|
430
|
+
config = load_config
|
431
|
+
|
432
|
+
# Command-line options override saved config
|
433
|
+
host = options[:host] || config[:host] || NanoKVM.configuration.default_host
|
434
|
+
username = options[:username] || config[:username] || NanoKVM.configuration.default_username
|
435
|
+
password = options[:password] || config[:password] || NanoKVM.configuration.default_password
|
436
|
+
token = config[:token]
|
437
|
+
debug = options[:debug]
|
438
|
+
secret_key = options[:secret_key] || config[:secret_key]
|
439
|
+
|
440
|
+
unless host
|
441
|
+
puts "Error: Host is required. Use --host or configure a default host with 'nanokvm configure'"
|
442
|
+
exit 1
|
443
|
+
end
|
444
|
+
|
445
|
+
NanoKVM::Client.new(
|
446
|
+
host: host,
|
447
|
+
username: username,
|
448
|
+
password: password,
|
449
|
+
token: token,
|
450
|
+
debug: debug,
|
451
|
+
secret_key: secret_key
|
452
|
+
)
|
453
|
+
end
|
454
|
+
|
455
|
+
def ensure_authenticated(client)
|
456
|
+
# Try using existing token
|
457
|
+
return if client.token
|
458
|
+
|
459
|
+
begin
|
460
|
+
client.login
|
461
|
+
rescue => e
|
462
|
+
puts "Authentication failed: #{e.message}"
|
463
|
+
puts "\nTroubleshooting tips:"
|
464
|
+
puts "1. Check your username and password"
|
465
|
+
puts "2. If you recently changed your password, run 'nanokvm configure'"
|
466
|
+
puts "3. Try different encryption key with --secret-key option"
|
467
|
+
puts "4. Check if your device has connectivity issues"
|
468
|
+
exit 1
|
469
|
+
end
|
470
|
+
end
|
471
|
+
|
472
|
+
# Helper method to get an authenticated client
|
473
|
+
def authenticated_client
|
474
|
+
client = create_client
|
475
|
+
ensure_authenticated(client)
|
476
|
+
client
|
477
|
+
end
|
478
|
+
|
479
|
+
# Helper method to display API data as a table
|
480
|
+
def display_as_table(title, data_method, *args)
|
481
|
+
client = authenticated_client
|
482
|
+
|
483
|
+
begin
|
484
|
+
data = client.send(data_method, *args)["data"]
|
485
|
+
|
486
|
+
rows = []
|
487
|
+
data.each do |key, value|
|
488
|
+
value_str = case value
|
489
|
+
when Hash, Array
|
490
|
+
value.to_json
|
491
|
+
else
|
492
|
+
value.to_s
|
493
|
+
end
|
494
|
+
rows << [key, value_str]
|
495
|
+
end
|
496
|
+
|
497
|
+
table = ::Terminal::Table.new(
|
498
|
+
title: title,
|
499
|
+
rows: rows
|
500
|
+
)
|
501
|
+
|
502
|
+
puts table
|
503
|
+
rescue NanoKVM::ApiError => e
|
504
|
+
puts "Error getting #{title.downcase}: #{e.message}"
|
505
|
+
puts "This feature may not be available on your NanoKVM device/firmware."
|
506
|
+
end
|
507
|
+
end
|
508
|
+
|
509
|
+
# Store previous download info for estimation
|
510
|
+
@previous_download_info = nil
|
511
|
+
|
512
|
+
# Get file size from URL using a HEAD request
|
513
|
+
def get_file_size_from_url(url)
|
514
|
+
begin
|
515
|
+
uri = URI.parse(url)
|
516
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
|
517
|
+
http.request_head(uri.path)
|
518
|
+
end
|
519
|
+
|
520
|
+
if response['content-length']
|
521
|
+
return response['content-length'].to_i
|
522
|
+
end
|
523
|
+
rescue => e
|
524
|
+
puts "Warning: Failed to get file size from URL: #{e.message}" if @debug
|
525
|
+
end
|
526
|
+
nil
|
527
|
+
end
|
528
|
+
|
529
|
+
def display_download_status(client)
|
530
|
+
status = client.get_download_image_status["data"]
|
531
|
+
|
532
|
+
rows = []
|
533
|
+
status.each do |key, value|
|
534
|
+
value_str = if value.is_a?(Hash)
|
535
|
+
value.to_json
|
536
|
+
else
|
537
|
+
value.to_s
|
538
|
+
end
|
539
|
+
rows << [key, value_str]
|
540
|
+
end
|
541
|
+
|
542
|
+
# Add time estimate if download in progress
|
543
|
+
if status["status"] == "in_progress"
|
544
|
+
# Extract progress from percentage string if needed
|
545
|
+
progress = if status["percentage"].to_s.include?("%")
|
546
|
+
status["percentage"].to_s.gsub("%", "").strip.to_f
|
547
|
+
else
|
548
|
+
status["progress"].to_f
|
549
|
+
end
|
550
|
+
|
551
|
+
# Try to get total file size if available or fetch from URL
|
552
|
+
total_size = status["size"].to_i
|
553
|
+
if total_size == 0 && status["file"].to_s.start_with?("http")
|
554
|
+
total_size = get_file_size_from_url(status["file"])
|
555
|
+
end
|
556
|
+
|
557
|
+
# Check if we have size information
|
558
|
+
if total_size.to_i > 0
|
559
|
+
downloaded = (total_size * (progress / 100.0)).to_i
|
560
|
+
|
561
|
+
# Add download size info
|
562
|
+
rows << ["downloaded", "#{downloaded} bytes (#{(downloaded / 1024.0 / 1024.0).round(2)} MB)"]
|
563
|
+
rows << ["total_size", "#{total_size} bytes (#{(total_size / 1024.0 / 1024.0).round(2)} MB)"]
|
564
|
+
|
565
|
+
# If we have enough information to calculate
|
566
|
+
if downloaded > 0 && progress > 0
|
567
|
+
start_time = status["start_time"].to_i rescue 0
|
568
|
+
|
569
|
+
if start_time > 0
|
570
|
+
elapsed_time = Time.now.to_i - start_time
|
571
|
+
else
|
572
|
+
# Use a default value if start_time is not available
|
573
|
+
elapsed_time = 60 # Assume 1 minute has passed for a basic estimate
|
574
|
+
end
|
575
|
+
|
576
|
+
if elapsed_time > 0
|
577
|
+
download_rate = downloaded / elapsed_time # bytes per second
|
578
|
+
remaining_bytes = total_size - downloaded
|
579
|
+
remaining_seconds = (download_rate > 0) ? (remaining_bytes / download_rate) : 0
|
580
|
+
|
581
|
+
# Format time remaining
|
582
|
+
if remaining_seconds < 60
|
583
|
+
time_estimate = "#{remaining_seconds.to_i}s"
|
584
|
+
elsif remaining_seconds < 3600
|
585
|
+
time_estimate = "#{(remaining_seconds/60).to_i}m #{(remaining_seconds%60).to_i}s"
|
586
|
+
else
|
587
|
+
time_estimate = "#{(remaining_seconds/3600).to_i}h #{(remaining_seconds%3600/60).to_i}m"
|
588
|
+
end
|
589
|
+
|
590
|
+
rows << ["estimated_time_remaining", time_estimate]
|
591
|
+
rows << ["download_speed", "#{download_rate.round(2)} bytes/s (#{(download_rate / 1024).round(2)} KB/s)"]
|
592
|
+
end
|
593
|
+
end
|
594
|
+
elsif @previous_download_info && progress > @previous_download_info[:progress]
|
595
|
+
# Calculate speed based solely on percentage change
|
596
|
+
current_time = Time.now.to_i
|
597
|
+
prev_time = @previous_download_info[:time]
|
598
|
+
prev_progress = @previous_download_info[:progress]
|
599
|
+
time_diff = current_time - prev_time
|
600
|
+
progress_diff = progress - prev_progress
|
601
|
+
|
602
|
+
if time_diff > 0 && progress_diff > 0
|
603
|
+
# Estimate remaining time based on progress rate
|
604
|
+
progress_per_second = progress_diff / time_diff.to_f
|
605
|
+
remaining_progress = 100.0 - progress
|
606
|
+
remaining_seconds = (progress_per_second > 0) ? (remaining_progress / progress_per_second).to_i : 0
|
607
|
+
|
608
|
+
# Format time remaining
|
609
|
+
if remaining_seconds < 60
|
610
|
+
time_estimate = "#{remaining_seconds}s"
|
611
|
+
elsif remaining_seconds < 3600
|
612
|
+
time_estimate = "#{(remaining_seconds/60).to_i}m #{(remaining_seconds%60).to_i}s"
|
613
|
+
else
|
614
|
+
time_estimate = "#{(remaining_seconds/3600).to_i}h #{(remaining_seconds%3600/60).to_i}m"
|
615
|
+
end
|
616
|
+
|
617
|
+
rows << ["estimated_time_remaining", time_estimate]
|
618
|
+
rows << ["download_rate", "#{progress_diff.round(2)}% per #{time_diff}s"]
|
619
|
+
end
|
620
|
+
end
|
621
|
+
|
622
|
+
# Store current info for next calculation
|
623
|
+
@previous_download_info = {
|
624
|
+
time: Time.now.to_i,
|
625
|
+
progress: progress
|
626
|
+
}
|
627
|
+
|
628
|
+
# Only store downloaded bytes if we have size information to calculate it correctly
|
629
|
+
if total_size.to_i > 0
|
630
|
+
@previous_download_info[:downloaded] = (total_size * (progress / 100.0)).to_i
|
631
|
+
end
|
632
|
+
else
|
633
|
+
# Reset tracking if not in progress
|
634
|
+
@previous_download_info = nil
|
635
|
+
end
|
636
|
+
|
637
|
+
table = ::Terminal::Table.new(
|
638
|
+
title: "Image Download Status",
|
639
|
+
rows: rows
|
640
|
+
)
|
641
|
+
|
642
|
+
puts table
|
643
|
+
end
|
644
|
+
|
645
|
+
def show_download_progress(client, interval = 3, timeout = nil)
|
646
|
+
monitor_start_time = Time.now.to_i
|
647
|
+
last_progress = nil
|
648
|
+
download_info = {}
|
649
|
+
line_count = 0
|
650
|
+
|
651
|
+
loop do
|
652
|
+
# Check timeout if specified
|
653
|
+
current_time = Time.now.to_i
|
654
|
+
if timeout && (current_time - monitor_start_time >= timeout)
|
655
|
+
puts "\nMonitoring stopped after #{timeout} seconds timeout"
|
656
|
+
break
|
657
|
+
end
|
658
|
+
|
659
|
+
# Clear previous output
|
660
|
+
if line_count > 0
|
661
|
+
print "\033[#{line_count}A\033[J" # Move cursor up and clear to end of screen
|
662
|
+
end
|
663
|
+
|
664
|
+
status = client.get_download_image_status["data"]
|
665
|
+
current_time = Time.now.to_i
|
666
|
+
|
667
|
+
# Extract progress from percentage string if needed
|
668
|
+
progress = if status["percentage"].to_s.include?("%")
|
669
|
+
status["percentage"].to_s.gsub("%", "").strip.to_f
|
670
|
+
else
|
671
|
+
status["progress"].to_f
|
672
|
+
end
|
673
|
+
|
674
|
+
# Try to get total file size
|
675
|
+
total_size = status["size"].to_i
|
676
|
+
if total_size == 0 && status["file"].to_s.start_with?("http")
|
677
|
+
total_size = get_file_size_from_url(status["file"])
|
678
|
+
end
|
679
|
+
|
680
|
+
# Update progress and info
|
681
|
+
if status["status"] == "in_progress"
|
682
|
+
# Setup the download info
|
683
|
+
download_info[:file] = status["file"]
|
684
|
+
download_info[:status] = status["status"]
|
685
|
+
download_info[:progress] = progress
|
686
|
+
|
687
|
+
# Calculate additional download information
|
688
|
+
if total_size.to_i > 0
|
689
|
+
downloaded = (total_size * (progress / 100.0)).to_i
|
690
|
+
download_info[:downloaded] = "#{downloaded} bytes (#{(downloaded / 1024.0 / 1024.0).round(2)} MB)"
|
691
|
+
download_info[:total_size] = "#{total_size} bytes (#{(total_size / 1024.0 / 1024.0).round(2)} MB)"
|
692
|
+
|
693
|
+
if @previous_download_info && @previous_download_info[:downloaded]
|
694
|
+
prev_time = @previous_download_info[:time]
|
695
|
+
prev_downloaded = @previous_download_info[:downloaded] || 0
|
696
|
+
time_diff = current_time - prev_time
|
697
|
+
bytes_diff = downloaded - prev_downloaded
|
698
|
+
|
699
|
+
if time_diff > 0 && bytes_diff > 0
|
700
|
+
download_rate = bytes_diff / time_diff.to_f # bytes per second
|
701
|
+
download_info[:speed] = "#{(download_rate / 1024).round(2)} KB/s"
|
702
|
+
|
703
|
+
remaining_bytes = total_size - downloaded
|
704
|
+
remaining_seconds = (download_rate > 0) ? (remaining_bytes / download_rate).to_i : 0
|
705
|
+
download_info[:time_remaining] = format_time_remaining(remaining_seconds)
|
706
|
+
end
|
707
|
+
end
|
708
|
+
|
709
|
+
# Store current info for next calculation
|
710
|
+
@previous_download_info = {
|
711
|
+
time: current_time,
|
712
|
+
downloaded: downloaded,
|
713
|
+
progress: progress
|
714
|
+
}
|
715
|
+
elsif @previous_download_info && progress > @previous_download_info[:progress]
|
716
|
+
# Calculate based on percentage change
|
717
|
+
prev_time = @previous_download_info[:time]
|
718
|
+
prev_progress = @previous_download_info[:progress]
|
719
|
+
time_diff = current_time - prev_time
|
720
|
+
progress_diff = progress - prev_progress
|
721
|
+
|
722
|
+
if time_diff > 0 && progress_diff > 0
|
723
|
+
progress_per_second = progress_diff / time_diff.to_f
|
724
|
+
download_info[:speed] = "#{progress_diff.round(2)}% per second"
|
725
|
+
remaining_progress = 100.0 - progress
|
726
|
+
remaining_seconds = (progress_per_second > 0) ? (remaining_progress / progress_per_second).to_i : 0
|
727
|
+
download_info[:time_remaining] = format_time_remaining(remaining_seconds)
|
728
|
+
end
|
729
|
+
|
730
|
+
@previous_download_info = {
|
731
|
+
time: current_time,
|
732
|
+
progress: progress
|
733
|
+
}
|
734
|
+
else
|
735
|
+
@previous_download_info = {
|
736
|
+
time: current_time,
|
737
|
+
progress: progress
|
738
|
+
}
|
739
|
+
end
|
740
|
+
|
741
|
+
# Display a nice progress bar
|
742
|
+
progress_width = 50
|
743
|
+
completed = (progress_width * progress / 100).to_i
|
744
|
+
bar = "[" + "#" * completed + " " * (progress_width - completed) + "]"
|
745
|
+
|
746
|
+
# Prepare output lines
|
747
|
+
output = []
|
748
|
+
output << "File: #{download_info[:file]}"
|
749
|
+
output << "Progress: #{bar} #{progress.round(2)}%"
|
750
|
+
output << "Status: #{download_info[:status]}"
|
751
|
+
output << "Downloaded: #{download_info[:downloaded]}" if download_info[:downloaded]
|
752
|
+
output << "Total size: #{download_info[:total_size]}" if download_info[:total_size]
|
753
|
+
output << "Speed: #{download_info[:speed]}" if download_info[:speed]
|
754
|
+
output << "ETA: #{download_info[:time_remaining]}" if download_info[:time_remaining]
|
755
|
+
|
756
|
+
if timeout
|
757
|
+
elapsed = current_time - monitor_start_time
|
758
|
+
remaining = timeout - elapsed
|
759
|
+
output << "Timeout in: #{remaining}s"
|
760
|
+
end
|
761
|
+
|
762
|
+
output << "\nPress Ctrl+C to stop monitoring"
|
763
|
+
|
764
|
+
# Print output and count lines for next update
|
765
|
+
puts output
|
766
|
+
line_count = output.size
|
767
|
+
|
768
|
+
# If download is complete, break the loop
|
769
|
+
if status["status"] != "in_progress"
|
770
|
+
break
|
771
|
+
end
|
772
|
+
else
|
773
|
+
puts "Download #{status['status']}"
|
774
|
+
break
|
775
|
+
end
|
776
|
+
|
777
|
+
sleep interval
|
778
|
+
end
|
779
|
+
|
780
|
+
puts "\nDownload #{client.get_download_image_status["data"]["status"]}"
|
781
|
+
end
|
782
|
+
|
783
|
+
def format_time_remaining(seconds)
|
784
|
+
if seconds < 60
|
785
|
+
"#{seconds}s"
|
786
|
+
elsif seconds < 3600
|
787
|
+
"#{(seconds/60).to_i}m #{(seconds%60).to_i}s"
|
788
|
+
else
|
789
|
+
"#{(seconds/3600).to_i}h #{(seconds%3600/60).to_i}m"
|
790
|
+
end
|
791
|
+
end
|
792
|
+
end
|
793
|
+
end
|