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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/exe/kdep +3 -0
- data/lib/kdep/cli.rb +72 -0
- data/lib/kdep/cluster_health.rb +44 -0
- data/lib/kdep/commands/apply.rb +147 -0
- data/lib/kdep/commands/build.rb +103 -0
- data/lib/kdep/commands/bump.rb +190 -0
- data/lib/kdep/commands/diff.rb +133 -0
- data/lib/kdep/commands/eject.rb +97 -0
- data/lib/kdep/commands/init.rb +99 -0
- data/lib/kdep/commands/log.rb +145 -0
- data/lib/kdep/commands/push.rb +94 -0
- data/lib/kdep/commands/render.rb +130 -0
- data/lib/kdep/commands/restart.rb +109 -0
- data/lib/kdep/commands/scale.rb +136 -0
- data/lib/kdep/commands/secrets.rb +223 -0
- data/lib/kdep/commands/sh.rb +117 -0
- data/lib/kdep/commands/status.rb +187 -0
- data/lib/kdep/config.rb +69 -0
- data/lib/kdep/context_guard.rb +23 -0
- data/lib/kdep/dashboard/health_panel.rb +53 -0
- data/lib/kdep/dashboard/layout.rb +40 -0
- data/lib/kdep/dashboard/log_panel.rb +54 -0
- data/lib/kdep/dashboard/panel.rb +135 -0
- data/lib/kdep/dashboard/resources_panel.rb +91 -0
- data/lib/kdep/dashboard/rollout_panel.rb +75 -0
- data/lib/kdep/dashboard/screen.rb +29 -0
- data/lib/kdep/dashboard.rb +258 -0
- data/lib/kdep/defaults.rb +21 -0
- data/lib/kdep/discovery.rb +29 -0
- data/lib/kdep/docker.rb +29 -0
- data/lib/kdep/kubectl.rb +41 -0
- data/lib/kdep/preset.rb +33 -0
- data/lib/kdep/registry.rb +94 -0
- data/lib/kdep/renderer.rb +53 -0
- data/lib/kdep/template_context.rb +22 -0
- data/lib/kdep/ui.rb +48 -0
- data/lib/kdep/validator.rb +73 -0
- data/lib/kdep/version.rb +3 -0
- data/lib/kdep/version_tagger.rb +27 -0
- data/lib/kdep/writer.rb +26 -0
- data/lib/kdep.rb +49 -0
- data/templates/init/app.yml.erb +49 -0
- data/templates/init/gitignore +3 -0
- data/templates/init/secrets.yml +7 -0
- data/templates/presets/cronjob +4 -0
- data/templates/presets/job +4 -0
- data/templates/presets/web +7 -0
- data/templates/presets/worker +4 -0
- data/templates/resources/configmap.yml.erb +7 -0
- data/templates/resources/cronjob.yml.erb +42 -0
- data/templates/resources/deployment.yml.erb +71 -0
- data/templates/resources/ingress.yml.erb +47 -0
- data/templates/resources/job.yml.erb +35 -0
- data/templates/resources/secret.yml.erb +15 -0
- data/templates/resources/service.yml.erb +13 -0
- 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
|
data/lib/kdep/config.rb
ADDED
|
@@ -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
|