Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ PATH
dotenv (~> 3.1)
ed25519 (~> 1.4)
net-ssh (~> 7.3)
pastel (~> 0.8)
sshkit (>= 1.23.0, < 2.0)
thor (~> 1.3)
tty-spinner (~> 0.9)
zeitwerk (>= 2.6.18, < 3.0)

GEM
Expand Down Expand Up @@ -99,6 +101,8 @@ GEM
parser (3.3.10.0)
ast (~> 2.4.1)
racc
pastel (0.8.0)
tty-color (~> 0.5)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
Expand Down Expand Up @@ -181,6 +185,10 @@ GEM
stringio (3.2.0)
thor (1.4.0)
tsort (0.2.0)
tty-color (0.6.0)
tty-cursor (0.7.1)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
Expand Down
2 changes: 2 additions & 0 deletions kamal.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Gem::Specification.new do |spec|
spec.add_dependency "bcrypt_pbkdf", "~> 1.0"
spec.add_dependency "concurrent-ruby", "~> 1.2"
spec.add_dependency "base64", "~> 0.2"
spec.add_dependency "tty-spinner", "~> 0.9"
spec.add_dependency "pastel", "~> 0.8"

spec.add_development_dependency "debug"
spec.add_development_dependency "minitest", "< 6"
Expand Down
12 changes: 10 additions & 2 deletions lib/kamal/cli/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,17 @@ def modify(lock: false)
end
end

def say(message = "", *)
super unless options[:raw]
def say(message = "", color = nil, *)
# A console backend renders the markers itself, but only while it's
# active (inside a modify block, i.e. KAMAL.logging). Outside of that
# — e.g. read-only commands — fall back to printing normally.
super unless options[:raw] || (KAMAL.console_output? && KAMAL.logging)
# Expose the color to the console backend so it can tell phase markers
# (say ..., :magenta) apart from the rest of the logged line stream.
Thread.current[:kamal_say_color] = color
KAMAL.log(message.to_s)
ensure
Thread.current[:kamal_say_color] = nil
end
Comment thread
enichols marked this conversation as resolved.

# Raw output is written straight to stdout for piping, so silence SSHKit's
Expand Down
10 changes: 9 additions & 1 deletion lib/kamal/commander.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def reset
self.lock_wait = false
self.lock_wait_timeout = 900
self.lock_wait_interval = 15
@console_output = false
@modify_depth = 0
@specifics = @specific_roles = @specific_hosts = nil
@config = @config_kwargs = nil
Expand Down Expand Up @@ -170,6 +171,10 @@ def holding_lock?
self.holding_lock
end

def console_output?
@console_output
end

def connected?
self.connected
end
Expand Down Expand Up @@ -208,7 +213,10 @@ def configure_output_with(config)

config.output.loggers.each { |logger| output_logger.broadcast_to(logger) }

SSHKit.config.output = Kamal::Output::Formatter.new($stdout, output_logger)
# A console backend renders to the screen itself, so drop SSHKit's raw stream (still teed to other backends).
@console_output = config.output.loggers.any? { |logger| logger.is_a?(Kamal::Output::ConsoleLogger) }
formatter_output = @console_output ? File.open(File::NULL, "w") : $stdout
SSHKit.config.output = Kamal::Output::Formatter.new(formatter_output, output_logger)
Comment thread
enichols marked this conversation as resolved.
Outdated

at_exit { @output_logger&.close }
rescue => e
Expand Down
14 changes: 14 additions & 0 deletions lib/kamal/configuration/docs/output.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,17 @@ output:
# One log file is created per deploy, named with the timestamp and command.
file:
path: /var/log/kamal/

# Console
#
# Replace the raw command firehose on the terminal with a condensed view: a
# header panel, one section per deploy phase, a live status line per host,
# and a summary panel. The raw output is suppressed on success and replayed
# for any host that fails. Run with -v/--verbose to restore the full firehose.
#
# Both options are optional:
# spinner - show animated per-host spinners on a TTY (default: true)
# color - force color on/off (default: auto-detect from the terminal)
console:
spinner: true
color: true
3 changes: 2 additions & 1 deletion lib/kamal/configuration/output.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ class Kamal::Configuration::Output

LOGGER_TYPES = {
"otel" => "Kamal::Output::OtelLogger",
"file" => "Kamal::Output::FileLogger"
"file" => "Kamal::Output::FileLogger",
"console" => "Kamal::Output::ConsoleLogger"
}

attr_reader :output_config, :loggers
Expand Down
46 changes: 46 additions & 0 deletions lib/kamal/output/console/plain_renderer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Line-based renderer for non-TTY output (CI, piped, redirected). No cursor
# movement or spinners: phases and per-host results are printed in order as
# they resolve, so the log stays clean and deterministic.
class Kamal::Output::Console::PlainRenderer < Kamal::Output::Console::Renderer
def header(command:, service:, version:, destination:, hosts:, roles:)
target = [ service, version ].compact.join("@")
scope = "#{hosts} #{"host".pluralize(hosts)}, #{roles} #{"role".pluralize(roles)}"
scope += " · #{destination}" if destination
panel(command, [ "#{target} → #{scope}" ])
end

def phase(name)
puts
puts pastel.decorate("#{ARROW} #{name}", :bright_magenta, :bold)
end

def end_phase(statuses)
statuses.each do |host, result|
if result[:status] == :failed
puts " #{pastel.red(FAIL)} #{host} #{pastel.red("failed")}"
else
puts " #{pastel.green(OK)} #{host} #{pastel.dim(format_duration(result[:duration]))}"
end
end
end

def summary(ok:, failed:, needs_attention:, runtime:, exception:)
counts = [ pastel.green("#{OK} #{ok} ok") ]
counts << pastel.red("#{FAIL} #{failed} failed") if failed > 0
lines = [ "#{counts.join(" ")} #{pastel.dim(format_duration(runtime))}" ]
lines << pastel.red("needs attention: #{needs_attention.join(", ")}") if needs_attention.any?
panel("Summary", lines, color: failed > 0 ? :red : :green)
end

def replay(host, lines)
puts
puts pastel.dim("── retained output · #{host} ─────")
lines.each { |line| puts pastel.dim("#{BAR} ") + line }
end

private
def format_duration(seconds)
return "" unless seconds
"#{sprintf("%.1f", seconds)}s"
end
end
53 changes: 53 additions & 0 deletions lib/kamal/output/console/renderer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
require "pastel"

# Shared formatting for the console renderers: colors, icons, and the rounded
# panels used for the header and summary. Subclasses implement the event
# methods (+header+, +phase+, +host_active+, +end_phase+, +summary+, +replay+)
# that the ConsoleLogger drives.
class Kamal::Output::Console::Renderer
OK = "✔"
FAIL = "✖"
ARROW = "❯"
BAR = "┃"

def initialize(output:, settings: {})
@output = output
@settings = settings
@pastel = Pastel.new(enabled: color_enabled?)
end

def header(command:, service:, version:, destination:, hosts:, roles:); end
def phase(name); end
def host_active(host); end
def end_phase(statuses); end
def summary(ok:, failed:, needs_attention:, runtime:, exception:); end
def replay(host, lines); end
def host_error(host); end

private
attr_reader :output, :settings, :pastel

def color_enabled?
return settings["color"] if settings.key?("color")
output.respond_to?(:tty?) && output.tty?
end

def puts(line = "")
output.puts(line)
end

# A rounded panel with a highlighted title, sized to its widest line.
def panel(title, lines, color: :magenta)
width = ([ visible_width(title) + 4 ] + lines.map { |line| visible_width(line) }).max
puts
puts pastel.decorate("╭─ ", color) + pastel.decorate(title, color, :bold) + pastel.decorate(" #{"─" * (width - visible_width(title) - 1)}╮", color)
lines.each do |line|
puts pastel.decorate("│ ", color) + line + " " * (width - visible_width(line)) + pastel.decorate(" │", color)
end
puts pastel.decorate("╰#{"─" * (width + 2)}╯", color)
end

def visible_width(string)
pastel.strip(string.to_s).length
end
end
56 changes: 56 additions & 0 deletions lib/kamal/output/console/tty_renderer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
require "tty-spinner"

# Interactive renderer: each phase is a TTY::Spinner::Multi whose children are
# the participating hosts, so their spinners animate concurrently and then
# settle into ✔/✖ status lines when the phase resolves. The header, summary,
# and replay panels are inherited from the plain renderer unchanged.
class Kamal::Output::Console::TtyRenderer < Kamal::Output::Console::PlainRenderer
SPINNER = :dots

def phase(name)
finish_multi
puts
@multi = TTY::Spinner::Multi.new(
pastel.decorate("#{ARROW} #{name}", :bright_magenta, :bold),
output: output, hide_cursor: true, format: SPINNER
)
@spinners = {}
end

def host_active(host)
return unless @multi
spinner = @multi.register(
"[:spinner] #{host}",
format: SPINNER,
success_mark: pastel.green(OK),
error_mark: pastel.red(FAIL)
)
@spinners[host] = spinner
spinner.auto_spin
end

def end_phase(statuses)
return super unless @multi

statuses.each do |host, result|
spinner = @spinners[host]
next unless spinner

if result[:status] == :failed
spinner.error(pastel.red("failed"))
else
spinner.success(pastel.dim(format_duration(result[:duration])))
end
end

finish_multi
end

private
def finish_multi
return unless @multi
@spinners.each_value { |spinner| spinner.stop if spinner.spinning? }
@multi = nil
@spinners = {}
end
end
Loading