kdep 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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/exe/kdep +3 -0
  4. data/lib/kdep/cli.rb +72 -0
  5. data/lib/kdep/cluster_health.rb +44 -0
  6. data/lib/kdep/commands/apply.rb +147 -0
  7. data/lib/kdep/commands/build.rb +103 -0
  8. data/lib/kdep/commands/bump.rb +190 -0
  9. data/lib/kdep/commands/diff.rb +133 -0
  10. data/lib/kdep/commands/eject.rb +97 -0
  11. data/lib/kdep/commands/init.rb +99 -0
  12. data/lib/kdep/commands/log.rb +145 -0
  13. data/lib/kdep/commands/push.rb +94 -0
  14. data/lib/kdep/commands/render.rb +130 -0
  15. data/lib/kdep/commands/restart.rb +109 -0
  16. data/lib/kdep/commands/scale.rb +136 -0
  17. data/lib/kdep/commands/secrets.rb +223 -0
  18. data/lib/kdep/commands/sh.rb +117 -0
  19. data/lib/kdep/commands/status.rb +187 -0
  20. data/lib/kdep/config.rb +69 -0
  21. data/lib/kdep/context_guard.rb +23 -0
  22. data/lib/kdep/dashboard/health_panel.rb +53 -0
  23. data/lib/kdep/dashboard/layout.rb +40 -0
  24. data/lib/kdep/dashboard/log_panel.rb +54 -0
  25. data/lib/kdep/dashboard/panel.rb +135 -0
  26. data/lib/kdep/dashboard/resources_panel.rb +91 -0
  27. data/lib/kdep/dashboard/rollout_panel.rb +75 -0
  28. data/lib/kdep/dashboard/screen.rb +29 -0
  29. data/lib/kdep/dashboard.rb +258 -0
  30. data/lib/kdep/defaults.rb +21 -0
  31. data/lib/kdep/discovery.rb +29 -0
  32. data/lib/kdep/docker.rb +29 -0
  33. data/lib/kdep/kubectl.rb +41 -0
  34. data/lib/kdep/preset.rb +33 -0
  35. data/lib/kdep/registry.rb +94 -0
  36. data/lib/kdep/renderer.rb +53 -0
  37. data/lib/kdep/template_context.rb +22 -0
  38. data/lib/kdep/ui.rb +48 -0
  39. data/lib/kdep/validator.rb +73 -0
  40. data/lib/kdep/version.rb +3 -0
  41. data/lib/kdep/version_tagger.rb +27 -0
  42. data/lib/kdep/writer.rb +26 -0
  43. data/lib/kdep.rb +49 -0
  44. data/templates/init/app.yml.erb +49 -0
  45. data/templates/init/gitignore +3 -0
  46. data/templates/init/secrets.yml +7 -0
  47. data/templates/presets/cronjob +4 -0
  48. data/templates/presets/job +4 -0
  49. data/templates/presets/web +7 -0
  50. data/templates/presets/worker +4 -0
  51. data/templates/resources/configmap.yml.erb +7 -0
  52. data/templates/resources/cronjob.yml.erb +42 -0
  53. data/templates/resources/deployment.yml.erb +71 -0
  54. data/templates/resources/ingress.yml.erb +47 -0
  55. data/templates/resources/job.yml.erb +35 -0
  56. data/templates/resources/secret.yml.erb +15 -0
  57. data/templates/resources/service.yml.erb +13 -0
  58. metadata +142 -0
@@ -0,0 +1,187 @@
1
+ require "optparse"
2
+ require "time"
3
+
4
+ module Kdep
5
+ module Commands
6
+ class Status
7
+ def self.option_parser
8
+ OptionParser.new do |opts|
9
+ opts.banner = "Usage: kdep status [deploy]"
10
+ opts.separator ""
11
+ opts.separator "Shows pod status, image versions, and resource usage."
12
+ end
13
+ end
14
+
15
+ def initialize(global_options:, command_options:, args:)
16
+ @global_options = global_options
17
+ @command_options = command_options
18
+ @args = args
19
+ @ui = Kdep::UI.new(color: false)
20
+ end
21
+
22
+ def execute
23
+ deploy_name = @args[0]
24
+
25
+ # Discover kdep/ directory
26
+ discovery = Kdep::Discovery.new
27
+ kdep_dir = discovery.find_kdep_dir
28
+ unless kdep_dir
29
+ @ui.error("No kdep/ directory found")
30
+ exit 1
31
+ end
32
+
33
+ # Resolve deploy directory
34
+ deploy_dir = resolve_deploy_dir(kdep_dir, deploy_name, discovery)
35
+ unless deploy_dir
36
+ exit 1
37
+ end
38
+
39
+ # Load config
40
+ config = Kdep::Config.new(deploy_dir, nil).load
41
+
42
+ # Validate context
43
+ Kdep::ContextGuard.new(config["context"]).validate!
44
+
45
+ # Get pods
46
+ namespace = config["namespace"]
47
+ app_name = config["name"]
48
+ data = Kdep::Kubectl.run_json("get", "pods", "-n", namespace, "-l", "app=#{app_name}")
49
+ pods = data["items"]
50
+
51
+ if pods.nil? || pods.empty?
52
+ @ui.info("No pods found for #{app_name} in #{namespace}")
53
+ return
54
+ end
55
+
56
+ # Try to get pod metrics
57
+ metrics = fetch_pod_metrics(namespace, app_name)
58
+
59
+ # Build and display table
60
+ display_pod_table(pods, metrics)
61
+
62
+ # Show replica summary
63
+ running = pods.count { |p| p["status"]["phase"] == "Running" }
64
+ total = pods.length
65
+ @ui.info("Replicas: #{running}/#{total} ready")
66
+
67
+ # Cluster health check
68
+ begin
69
+ health = Kdep::ClusterHealth.new
70
+ health.report(@ui)
71
+ rescue NameError
72
+ # ClusterHealth not yet implemented, skip
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def fetch_pod_metrics(namespace, app_name)
79
+ begin
80
+ output = Kdep::Kubectl.run("top", "pods", "-n", namespace, "-l", "app=#{app_name}", "--no-headers")
81
+ metrics = {}
82
+ output.each_line do |line|
83
+ parts = line.strip.split(/\s+/)
84
+ next if parts.length < 3
85
+ # name, cpu, memory
86
+ metrics[parts[0]] = { "cpu" => parts[1], "memory" => parts[2] }
87
+ end
88
+ metrics
89
+ rescue Kdep::Kubectl::Error
90
+ nil
91
+ end
92
+ end
93
+
94
+ def display_pod_table(pods, metrics)
95
+ rows = pods.map do |pod|
96
+ name = pod["metadata"]["name"]
97
+ phase = pod["status"]["phase"]
98
+ containers = pod["status"]["containerStatuses"] || []
99
+ ready_count = containers.count { |c| c["ready"] }
100
+ total_count = containers.length
101
+ ready_str = "#{ready_count}/#{total_count}"
102
+ restarts = containers.inject(0) { |sum, c| sum + (c["restartCount"] || 0) }
103
+ image = (pod["spec"]["containers"][0] || {})["image"] || ""
104
+ # Strip registry prefix for readability (keep last two segments)
105
+ image_short = image.include?("/") ? image.split("/").last : image
106
+ age = format_age(pod["metadata"]["creationTimestamp"])
107
+
108
+ row = { name: name, status: phase, ready: ready_str, restarts: restarts.to_s, image: image_short, age: age }
109
+ if metrics && metrics[name]
110
+ row[:cpu] = metrics[name]["cpu"]
111
+ row[:memory] = metrics[name]["memory"]
112
+ end
113
+ row
114
+ end
115
+
116
+ has_metrics = metrics && rows.any? { |r| r[:cpu] }
117
+
118
+ # Calculate column widths
119
+ headers = [:name, :status, :ready, :restarts, :image, :age]
120
+ labels = { name: "POD", status: "STATUS", ready: "READY", restarts: "RESTARTS", image: "IMAGE", age: "AGE" }
121
+
122
+ if has_metrics
123
+ headers += [:cpu, :memory]
124
+ labels[:cpu] = "CPU"
125
+ labels[:memory] = "MEM"
126
+ end
127
+
128
+ widths = {}
129
+ headers.each do |h|
130
+ col_values = rows.map { |r| (r[h] || "").to_s }
131
+ widths[h] = ([labels[h].length] + col_values.map(&:length)).max
132
+ end
133
+
134
+ # Print header
135
+ header_line = headers.map { |h| labels[h].ljust(widths[h]) }.join(" ")
136
+ @ui.info(header_line)
137
+
138
+ # Print rows
139
+ rows.each do |row|
140
+ line = headers.map { |h| (row[h] || "").to_s.ljust(widths[h]) }.join(" ")
141
+ @ui.info(line)
142
+ end
143
+ end
144
+
145
+ def format_age(timestamp)
146
+ return "?" unless timestamp
147
+ created = Time.parse(timestamp)
148
+ diff = Time.now - created
149
+ if diff >= 86400
150
+ "#{(diff / 86400).to_i}d"
151
+ elsif diff >= 3600
152
+ "#{(diff / 3600).to_i}h"
153
+ elsif diff >= 60
154
+ "#{(diff / 60).to_i}m"
155
+ else
156
+ "#{diff.to_i}s"
157
+ end
158
+ end
159
+
160
+ def resolve_deploy_dir(kdep_dir, deploy_name, discovery)
161
+ if deploy_name
162
+ deploy_dir = File.join(kdep_dir, deploy_name)
163
+ unless File.directory?(deploy_dir)
164
+ @ui.error("Deploy target not found: #{deploy_name}")
165
+ deploys = discovery.find_deploys
166
+ if deploys.any?
167
+ @ui.info("Available deploys: #{deploys.join(', ')}")
168
+ end
169
+ return nil
170
+ end
171
+ deploy_dir
172
+ else
173
+ deploys = discovery.find_deploys
174
+ if deploys.length == 1
175
+ File.join(kdep_dir, deploys[0])
176
+ elsif deploys.length > 1
177
+ @ui.error("Multiple deploys found, specify one: #{deploys.join(', ')}")
178
+ nil
179
+ else
180
+ @ui.error("No deploy targets found in kdep/")
181
+ nil
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,69 @@
1
+ require "yaml"
2
+
3
+ module Kdep
4
+ class Config
5
+ attr_reader :deploy_dir, :env
6
+
7
+ def initialize(deploy_dir, env = nil)
8
+ @deploy_dir = deploy_dir
9
+ @env = env
10
+ end
11
+
12
+ def load
13
+ base = load_yaml(File.join(@deploy_dir, "app.yml"))
14
+
15
+ if @env
16
+ overlay_path = File.join(@deploy_dir, "app_#{@env}.yml")
17
+ if File.exist?(overlay_path)
18
+ overlay = load_yaml(overlay_path)
19
+ base = deep_merge(base, overlay)
20
+ end
21
+ end
22
+
23
+ apply_defaults(base)
24
+ end
25
+
26
+ def deep_merge(base, overlay)
27
+ base.merge(overlay) do |_key, old_val, new_val|
28
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
29
+ deep_merge(old_val, new_val)
30
+ else
31
+ new_val
32
+ end
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def load_yaml(path)
39
+ content = File.read(path)
40
+ if RUBY_VERSION >= "3.1"
41
+ YAML.safe_load(content, permitted_classes: [], permitted_symbols: [], aliases: false, filename: path) || {}
42
+ else
43
+ YAML.safe_load(content, [], [], false, path) || {}
44
+ end
45
+ end
46
+
47
+ def apply_defaults(config)
48
+ preset_name = config.fetch("preset", "custom")
49
+ defaults = Kdep::Defaults.for_preset(preset_name)
50
+ result = deep_merge(defaults, config)
51
+
52
+ # Name default: derive from project folder + deploy folder name
53
+ # deploy_dir is [project]/kdep/[deploy], so go up 2 levels for project name
54
+ unless result.key?("name")
55
+ deploy = File.basename(@deploy_dir)
56
+ kdep_dir = File.dirname(@deploy_dir)
57
+ project = File.basename(File.dirname(kdep_dir))
58
+ result["name"] = "#{project}-#{deploy}"
59
+ end
60
+
61
+ # Image default: use name value
62
+ unless result.key?("image")
63
+ result["image"] = result["name"]
64
+ end
65
+
66
+ result
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,23 @@
1
+ module Kdep
2
+ class ContextGuard
3
+ def initialize(expected_context)
4
+ @expected_context = expected_context
5
+ @ui = Kdep::UI.new(color: false)
6
+ end
7
+
8
+ def validate!
9
+ if @expected_context.nil? || @expected_context.to_s.strip.empty?
10
+ @ui.warn("No context specified in app.yml -- skipping context validation. Add 'context: your-cluster' for safety.")
11
+ return
12
+ end
13
+
14
+ actual = Kdep::Kubectl.current_context
15
+ return if actual == @expected_context
16
+
17
+ raise Kdep::Kubectl::Error,
18
+ "Context mismatch: kubectl is set to '#{actual}' but app.yml expects '#{@expected_context}'. " \
19
+ "Aborting to prevent wrong-cluster deployment. " \
20
+ "Run: kubectl config use-context #{@expected_context}"
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,53 @@
1
+ module Kdep
2
+ class Dashboard
3
+ class HealthPanel < Panel
4
+ CPU_WARN = 80
5
+ MEMORY_WARN = 85
6
+
7
+ GREEN = "\e[32m".freeze
8
+ YELLOW = "\e[33m".freeze
9
+ RESET = "\e[0m".freeze
10
+
11
+ def update(node_data)
12
+ @node_data = node_data
13
+ build_lines
14
+ end
15
+
16
+ private
17
+
18
+ def build_lines
19
+ content = []
20
+ has_warning = false
21
+
22
+ (@node_data || []).each do |node|
23
+ name = node[:name] || node["name"]
24
+ cpu = node[:cpu_percent] || node["cpu_percent"]
25
+ mem = node[:mem_percent] || node["mem_percent"]
26
+
27
+ cpu_warn = cpu.to_i > CPU_WARN
28
+ mem_warn = mem.to_i > MEMORY_WARN
29
+ has_warning = true if cpu_warn || mem_warn
30
+
31
+ cpu_str = cpu_warn ? "#{YELLOW}#{cpu}%#{RESET}" : "#{cpu}%"
32
+ mem_str = mem_warn ? "#{YELLOW}#{mem}%#{RESET}" : "#{mem}%"
33
+
34
+ content << "#{name} CPU: #{cpu_str} MEM: #{mem_str}"
35
+ end
36
+
37
+ unless has_warning
38
+ content << "#{GREEN}Cluster healthy#{RESET}"
39
+ end
40
+
41
+ @lines = content
42
+ clamp_scroll_offset
43
+ end
44
+
45
+ def clamp_scroll_offset
46
+ content_height = @rect.height - 2
47
+ max = [@lines.length - content_height, 0].max
48
+ @scroll_offset = [@scroll_offset, max].min
49
+ @scroll_offset = [@scroll_offset, 0].max
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,40 @@
1
+ module Kdep
2
+ class Dashboard
3
+ Rect = Struct.new(:top, :left, :height, :width)
4
+
5
+ class Layout
6
+ MIN_ROWS = 16
7
+ MIN_COLS = 60
8
+
9
+ def initialize(rows:, cols:)
10
+ @rows = rows
11
+ @cols = cols
12
+ end
13
+
14
+ def panels
15
+ return nil if @rows < MIN_ROWS || @cols < MIN_COLS
16
+
17
+ # Reserve row 0 for header, last row for status bar
18
+ usable_top = 1
19
+ usable_height = @rows - 2 # minus header and status bar
20
+ usable_left = 0
21
+ usable_width = @cols
22
+
23
+ top_height = usable_height / 2
24
+ bottom_height = usable_height - top_height
25
+
26
+ left_width = (usable_width * 0.6).to_i
27
+ right_width = usable_width - left_width
28
+
29
+ bottom_top = usable_top + top_height
30
+
31
+ {
32
+ rollout: Rect.new(usable_top, usable_left, top_height, left_width),
33
+ resources: Rect.new(usable_top, usable_left + left_width, top_height, right_width),
34
+ logs: Rect.new(bottom_top, usable_left, bottom_height, left_width),
35
+ health: Rect.new(bottom_top, usable_left + left_width, bottom_height, right_width),
36
+ }
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,54 @@
1
+ module Kdep
2
+ class Dashboard
3
+ class LogPanel < Panel
4
+ MAX_LINES = 500
5
+
6
+ ERROR_PATTERN = /\b(ERROR|FATAL|PANIC)\b/
7
+ WARN_PATTERN = /\b(WARN|WARNING)\b/
8
+
9
+ RED = "\e[31m".freeze
10
+ YELLOW = "\e[33m".freeze
11
+ RESET = "\e[0m".freeze
12
+
13
+ def add_line(line)
14
+ was_at_bottom = at_bottom?
15
+
16
+ highlighted = highlight(line)
17
+ @lines << highlighted
18
+
19
+ # Cap buffer at MAX_LINES
20
+ while @lines.length > MAX_LINES
21
+ @lines.shift
22
+ end
23
+
24
+ # Auto-scroll to bottom if we were at bottom
25
+ if was_at_bottom
26
+ scroll_to_bottom
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def highlight(line)
33
+ if line.match?(ERROR_PATTERN)
34
+ "#{RED}#{line}#{RESET}"
35
+ elsif line.match?(WARN_PATTERN)
36
+ "#{YELLOW}#{line}#{RESET}"
37
+ else
38
+ line
39
+ end
40
+ end
41
+
42
+ def at_bottom?
43
+ content_height = @rect.height - 2
44
+ max_offset = [@lines.length - content_height, 0].max
45
+ @scroll_offset >= max_offset
46
+ end
47
+
48
+ def scroll_to_bottom
49
+ content_height = @rect.height - 2
50
+ @scroll_offset = [@lines.length - content_height, 0].max
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,135 @@
1
+ module Kdep
2
+ class Dashboard
3
+ class Panel
4
+ attr_reader :title, :rect, :lines, :scroll_offset
5
+
6
+ def initialize(title:, rect:)
7
+ @title = title
8
+ @rect = rect
9
+ @lines = []
10
+ @scroll_offset = 0
11
+ end
12
+
13
+ def update(lines)
14
+ @lines = lines.dup
15
+ clamp_scroll
16
+ end
17
+
18
+ def add_line(line)
19
+ @lines << line
20
+ end
21
+
22
+ def scroll_up
23
+ @scroll_offset = [@scroll_offset - 1, 0].max
24
+ end
25
+
26
+ def scroll_down
27
+ max = [visible_line_count, 0].max
28
+ @scroll_offset = [@scroll_offset + 1, max].min
29
+ end
30
+
31
+ def render
32
+ output = []
33
+ w = @rect.width
34
+
35
+ # Title bar
36
+ title_text = " #{@title} "
37
+ bar_width = [w - 2, 0].max
38
+ if title_text.length > bar_width
39
+ title_text = title_text[0, bar_width]
40
+ end
41
+ dashes = bar_width - title_text.length
42
+ left_dashes = dashes / 2
43
+ right_dashes = dashes - left_dashes
44
+ output << "+" + ("-" * left_dashes) + title_text + ("-" * right_dashes) + "+"
45
+
46
+ # Content area
47
+ content_height = @rect.height - 2 # minus title and bottom border
48
+ content_width = [w - 4, 0].max # minus "| " and " |"
49
+
50
+ visible = content_lines
51
+ content_height.times do |i|
52
+ line = visible[i] || ""
53
+ # Strip ANSI for length calculation, but keep ANSI in output
54
+ display_len = strip_ansi(line).length
55
+ if display_len > content_width
56
+ line = truncate_with_ansi(line, content_width)
57
+ elsif display_len < content_width
58
+ line = line + (" " * (content_width - display_len))
59
+ end
60
+ output << "| " + line + " |"
61
+ end
62
+
63
+ # Bottom border
64
+ output << "+" + ("-" * bar_width) + "+"
65
+
66
+ output
67
+ end
68
+
69
+ protected
70
+
71
+ def content_lines
72
+ content_height = @rect.height - 2
73
+ max_offset = [@lines.length - content_height, 0].max
74
+ offset = [@scroll_offset, max_offset].min
75
+ @lines[offset, content_height] || []
76
+ end
77
+
78
+ def visible_line_count
79
+ content_height = @rect.height - 2
80
+ [@lines.length - content_height, 0].max
81
+ end
82
+
83
+ def strip_ansi(str)
84
+ str.gsub(/\e\[[0-9;]*m/, "")
85
+ end
86
+
87
+ def truncate_with_ansi(str, max_width)
88
+ visible = 0
89
+ i = 0
90
+ result = ""
91
+ while i < str.length && visible < max_width
92
+ if str[i] == "\e"
93
+ # Consume ANSI sequence
94
+ seq_end = str.index("m", i)
95
+ if seq_end
96
+ result << str[i..seq_end]
97
+ i = seq_end + 1
98
+ else
99
+ result << str[i]
100
+ i += 1
101
+ visible += 1
102
+ end
103
+ else
104
+ result << str[i]
105
+ i += 1
106
+ visible += 1
107
+ end
108
+ end
109
+ # Append any remaining ANSI sequences (like reset)
110
+ while i < str.length
111
+ if str[i] == "\e"
112
+ seq_end = str.index("m", i)
113
+ if seq_end
114
+ result << str[i..seq_end]
115
+ i = seq_end + 1
116
+ else
117
+ break
118
+ end
119
+ else
120
+ break
121
+ end
122
+ end
123
+ result
124
+ end
125
+
126
+ private
127
+
128
+ def clamp_scroll
129
+ max = visible_line_count
130
+ @scroll_offset = [@scroll_offset, max].min
131
+ @scroll_offset = [@scroll_offset, 0].max
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,91 @@
1
+ module Kdep
2
+ class Dashboard
3
+ class ResourcesPanel < Panel
4
+ WARN_THRESHOLD = 80
5
+ CRIT_THRESHOLD = 95
6
+
7
+ RED = "\e[31m".freeze
8
+ YELLOW = "\e[33m".freeze
9
+ RESET = "\e[0m".freeze
10
+
11
+ def update(metrics)
12
+ @pod_metrics = metrics
13
+ build_lines
14
+ end
15
+
16
+ private
17
+
18
+ def build_lines
19
+ content = []
20
+ content << "POD CPU MEM"
21
+ content << "-" * 42
22
+
23
+ (@pod_metrics || []).each do |pod|
24
+ name = pod[:name] || pod["name"]
25
+ cpu = pod[:cpu] || pod["cpu"]
26
+ cpu_limit = pod[:cpu_limit] || pod["cpu_limit"]
27
+ memory = pod[:memory] || pod["memory"]
28
+ mem_limit = pod[:mem_limit] || pod["mem_limit"]
29
+
30
+ cpu_pct = calc_percent_milli(cpu, cpu_limit)
31
+ mem_pct = calc_percent_mem(memory, mem_limit)
32
+
33
+ cpu_str = colorize_value("#{cpu}/#{cpu_limit}", cpu_pct)
34
+ mem_str = colorize_value("#{memory}/#{mem_limit}", mem_pct)
35
+
36
+ content << "%-22s %s %s" % [name, cpu_str, mem_str]
37
+ end
38
+
39
+ # Use the parent's update to set @lines
40
+ @lines = content
41
+ clamp_scroll_offset
42
+ end
43
+
44
+ def calc_percent_milli(value, limit)
45
+ return 0 unless value && limit
46
+ v = value.to_s.sub(/m$/, "").to_f
47
+ l = limit.to_s.sub(/m$/, "").to_f
48
+ return 0 if l <= 0
49
+ (v / l * 100).to_i
50
+ end
51
+
52
+ def calc_percent_mem(value, limit)
53
+ return 0 unless value && limit
54
+ v = parse_mem(value.to_s)
55
+ l = parse_mem(limit.to_s)
56
+ return 0 if l <= 0
57
+ (v.to_f / l * 100).to_i
58
+ end
59
+
60
+ def parse_mem(str)
61
+ case str
62
+ when /^(\d+(?:\.\d+)?)Gi$/
63
+ $1.to_f * 1024
64
+ when /^(\d+(?:\.\d+)?)Mi$/
65
+ $1.to_f
66
+ when /^(\d+(?:\.\d+)?)Ki$/
67
+ $1.to_f / 1024
68
+ else
69
+ str.to_f
70
+ end
71
+ end
72
+
73
+ def colorize_value(str, percent)
74
+ if percent >= CRIT_THRESHOLD
75
+ "#{RED}#{str}#{RESET}"
76
+ elsif percent >= WARN_THRESHOLD
77
+ "#{YELLOW}#{str}#{RESET}"
78
+ else
79
+ str
80
+ end
81
+ end
82
+
83
+ def clamp_scroll_offset
84
+ content_height = @rect.height - 2
85
+ max = [@lines.length - content_height, 0].max
86
+ @scroll_offset = [@scroll_offset, max].min
87
+ @scroll_offset = [@scroll_offset, 0].max
88
+ end
89
+ end
90
+ end
91
+ end