├── .rspec ├── Gemfile ├── bin └── puma-status ├── .github └── workflows │ └── test.yml ├── puma-status.gemspec ├── Gemfile.lock ├── README.md ├── LICENSE ├── lib ├── puma-status.rb ├── helpers.rb ├── stats.rb └── core.rb └── spec ├── helpers_spec.rb ├── puma-status_spec.rb ├── spec_helper.rb ├── stats_spec.rb └── core_spec.rb /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /bin/puma-status: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'puma-status' 4 | 5 | run 6 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: on ruby ${{matrix.ruby}} 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | ruby: [2.6, 2.7, '3.0', 3.1, 3.2, 3.3, 3.4, head] 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v2 18 | 19 | - name: Set up Ruby 20 | uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: ${{matrix.ruby}} 23 | 24 | - name: Install dependencies 25 | run: bundle install --jobs 4 --retry 3 26 | 27 | - name: Specs 28 | run: bundle exec rspec 29 | -------------------------------------------------------------------------------- /puma-status.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = 'puma-status' 3 | s.version = "1.7" 4 | s.authors = ["Yoann Lecuyer"] 5 | s.date = '2019-07-14' 6 | s.summary = 'Command-line tool for puma to display information about running request/process' 7 | s.license = "MIT" 8 | s.homepage = 'https://github.com/ylecuyer/puma-status' 9 | s.required_ruby_version = '>=2.6.0' 10 | 11 | s.files = Dir.glob("{bin,lib}/**/*") + %w(LICENSE) 12 | s.require_paths = ["lib"] 13 | s.executables = ['puma-status'] 14 | 15 | s.add_runtime_dependency "net_http_unix", '~> 0.2' 16 | s.add_runtime_dependency "parallel", '~> 1' 17 | 18 | s.add_development_dependency "rspec", '~> 3.8' 19 | s.add_development_dependency "climate_control", '~> 0.2' 20 | s.add_development_dependency "timecop", '~> 0.9' 21 | end 22 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | puma-status (1.7) 5 | net_http_unix (~> 0.2) 6 | parallel (~> 1) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | climate_control (0.2.0) 12 | diff-lcs (1.5.0) 13 | net_http_unix (0.2.2) 14 | parallel (1.24.0) 15 | rspec (3.12.0) 16 | rspec-core (~> 3.12.0) 17 | rspec-expectations (~> 3.12.0) 18 | rspec-mocks (~> 3.12.0) 19 | rspec-core (3.12.2) 20 | rspec-support (~> 3.12.0) 21 | rspec-expectations (3.12.3) 22 | diff-lcs (>= 1.2.0, < 2.0) 23 | rspec-support (~> 3.12.0) 24 | rspec-mocks (3.12.6) 25 | diff-lcs (>= 1.2.0, < 2.0) 26 | rspec-support (~> 3.12.0) 27 | rspec-support (3.12.1) 28 | timecop (0.9.8) 29 | 30 | PLATFORMS 31 | ruby 32 | 33 | DEPENDENCIES 34 | climate_control (~> 0.2) 35 | puma-status! 36 | rspec (~> 3.8) 37 | timecop (~> 0.9) 38 | 39 | BUNDLED WITH 40 | 2.3.5 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # puma-status 2 | 3 | Command-line tool for puma to display information about running request/process. 4 | 5 | ## Install 6 | 7 | Install with: 8 | 9 | ``` 10 | gem install puma-status 11 | ``` 12 | 13 | ## Usage 14 | 15 | ``` 16 | $ puma-status path/to/puma.state 17 | ``` 18 | 19 | For continuous monitoring: 20 | 21 | ``` 22 | $ watch --interval 0.1 --color puma-status path/to/puma.state 23 | ``` 24 | 25 | ## Output examples 26 | 27 | Clustered mode: 28 | 29 | ``` 30 | 16723 (/tmp/puma.state) Version: 5.6.4/ruby2.5.3p105 | Uptime: 1m50s | Phase: 0 | Load: 2[██░░ ]10 | Req: 936 31 | └ 16827 CPU: 93.3% Mem: 140 MB Uptime: 1m50s | Load: 1[█░ ]5 | Req: 469 32 | └ 16833 CPU: 106.7% Mem: 145 MB Uptime: 1m50s | Load: 1[█░ ]5 | Req: 467 33 | ``` 34 | 35 | Single mode: 36 | 37 | ``` 38 | 18847 (/tmp/puma.state) Version: 5.6.4/ruby2.5.3p105 | Uptime: 0m 3s | Load: 1[█░░ ]5 | Req: 672 39 | └ 18847 CPU: 120.0% Mem: 143 MB Uptime: 0m 3s | Load: 1[█░░ ]5 | Req: 672 40 | ``` 41 | 42 | ## Known issues 43 | 44 | Uptime will shows `--m --s` for older versions of puma (< 4.1.0): https://github.com/puma/puma/pull/1844 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/puma-status.rb: -------------------------------------------------------------------------------- 1 | require_relative './helpers' 2 | require_relative './core.rb' 3 | require 'parallel' 4 | 5 | def run 6 | debug "puma-status" 7 | 8 | if ARGV.count < 1 9 | puts "Call with:" 10 | puts "\tpuma-status path/to/puma.state" 11 | exit -1 12 | end 13 | 14 | errors = [] 15 | 16 | outputs = Parallel.map(ARGV, in_threads: ARGV.count) do |state_file_path| 17 | begin 18 | debug "State file: #{state_file_path}" 19 | format_stats(get_stats(state_file_path)) 20 | rescue Errno::ENOENT => e 21 | if e.message =~ /#{state_file_path}/ 22 | errors << "#{yellow(state_file_path)} doesn't exist" 23 | elsif e.message =~ /connect\(2\) for [^\/]/ 24 | errors << "#{yellow("Relative Unix socket")}: the Unix socket of the control app has a relative path. Please, ensure you are running from the same folder as puma." 25 | else 26 | errors << "#{red(state_file_path)} an unhandled error occured: #{e.inspect}" 27 | end 28 | nil 29 | rescue Errno::EISDIR => e 30 | if e.message =~ /#{state_file_path}/ 31 | errors << "#{yellow(state_file_path)} isn't a state file" 32 | else 33 | errors << "#{red(state_file_path)} an unhandled error occured: #{e.inspect}" 34 | end 35 | nil 36 | rescue => e 37 | errors << "#{red(state_file_path)} an unhandled error occured: #{e.inspect}" 38 | nil 39 | end 40 | end 41 | 42 | outputs.compact.each { |output| puts output } 43 | 44 | if errors.any? 45 | puts "" 46 | errors.each { |error| puts error } 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/helpers_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require './lib/helpers' 4 | 5 | describe 'Helpers' do 6 | 7 | context 'seconds_to_human' do 8 | it 'works with 0 seconds' do 9 | expect(seconds_to_human(0)).to eq('--m--s') 10 | end 11 | 12 | it 'works with 1254 seconds' do 13 | expect(seconds_to_human(1254)).to eq('20m54s') 14 | end 15 | 16 | it 'works with 4501 seconds' do 17 | expect(seconds_to_human(4501)).to eq(' 1h15m') 18 | end 19 | 20 | it 'works with 90000 seconds' do 21 | expect(seconds_to_human(90000)).to eq(' 1d 1h') 22 | end 23 | 24 | it 'works with 2073600 seconds' do 25 | expect(seconds_to_human(2073600)).to eq(' 24d') 26 | end 27 | end 28 | 29 | context 'asciiThreadLoad' do 30 | it 'works when empty' do 31 | expect(asciiThreadLoad(0, 0, 0)).to eq('0[]0') 32 | end 33 | 34 | it 'works with data' do 35 | expect(asciiThreadLoad(4, 8, 8)).to eq('4[████░░░░]8') 36 | end 37 | 38 | it 'show spawned threads' do 39 | expect(asciiThreadLoad(4, 6, 8)).to eq('4[████░░ ]8') 40 | end 41 | 42 | it 'works when full' do 43 | expect(asciiThreadLoad(9, 9, 9)).to eq('9[█████████]9') 44 | end 45 | end 46 | 47 | context 'color' do 48 | it 'colors in red when critical' do 49 | expect(color(75, 50, 80, "critical")).to eq("\e[0;31;49mcritical\e[0m") 50 | end 51 | 52 | it 'colors in yellow when warning' do 53 | expect(color(75, 50, 60, "warn")).to eq("\e[0;33;49mwarn\e[0m") 54 | end 55 | 56 | it 'colors in green when ok' do 57 | expect(color(75, 50, 20, "ok")).to eq("\e[0;32;49mok\e[0m") 58 | end 59 | 60 | it 'works with non string' do 61 | expect(color(75, 50, 0.52, 0.52)).to eq("\e[0;32;49m0.52\e[0m") 62 | end 63 | end 64 | 65 | 66 | end 67 | -------------------------------------------------------------------------------- /lib/helpers.rb: -------------------------------------------------------------------------------- 1 | def debug(str) 2 | puts str if ENV.key?('DEBUG') 3 | end 4 | 5 | def yellow(str) 6 | colorize(str, :yellow) 7 | end 8 | 9 | def red(str) 10 | colorize(str, :red) 11 | end 12 | 13 | def colorize(str, color_name) 14 | return str if ENV.key?('NO_COLOR') 15 | case color_name 16 | when :red 17 | "\e[0;31;49m#{str}\e[0m" 18 | when :yellow 19 | "\e[0;33;49m#{str}\e[0m" 20 | when :green 21 | "\e[0;32;49m#{str}\e[0m" 22 | else 23 | str 24 | end 25 | end 26 | 27 | def color(critical, warn, value, str = nil) 28 | str = value unless str 29 | color_level = if value >= critical 30 | :red 31 | elsif value < critical && value >= warn 32 | :yellow 33 | else 34 | :green 35 | end 36 | colorize(str, color_level) 37 | end 38 | 39 | def asciiThreadLoad(running, spawned, total) 40 | full = "█" 41 | half= "░" 42 | empty = " " 43 | 44 | full_count = running 45 | half_count = [spawned - running, 0].max 46 | empty_count = total - half_count - full_count 47 | 48 | "#{running}[#{full*full_count}#{half*half_count}#{empty*empty_count}]#{total}" 49 | end 50 | 51 | def seconds_to_human(seconds) 52 | 53 | #=> 0m 0s 54 | #=> 59m59s 55 | #=> 1h 0m 56 | #=> 23h59m 57 | #=> 1d 0h 58 | #=> 24d 59 | 60 | if seconds <= 0 61 | "--m--s" 62 | elsif seconds < 60*60 63 | "#{(seconds/60).to_s.rjust(2, ' ')}m#{(seconds%60).to_s.rjust(2, ' ')}s" 64 | elsif seconds >= 60*60*1 && seconds < 60*60*24 65 | "#{(seconds/(60*60*1)).to_s.rjust(2, ' ')}h#{((seconds%(60*60*1))/60).to_s.rjust(2, ' ')}m" 66 | elsif seconds > 60*60*24 && seconds < 60*60*24*10 67 | "#{(seconds/(60*60*24)).to_s.rjust(2, ' ')}d#{((seconds%(60*60*24))/(60*60*1)).to_s.rjust(2, ' ')}h" 68 | else 69 | "#{seconds/(60*60*24)}d".rjust(6, ' ') 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/puma-status_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require './lib/puma-status' 4 | 5 | RSpec::Matchers.define_negated_matcher :not_raise_error, :raise_error 6 | 7 | describe 'Puma Status' do 8 | before(:each) do 9 | allow(Parallel).to receive(:map) do |state_files, options, &block| 10 | expect(options[:in_threads]).to eq(state_files.count) 11 | state_files.map(&block) 12 | end 13 | end 14 | 15 | it 'exits with an error if no state file' do 16 | ARGV.replace [] 17 | expect { 18 | run 19 | }.to output.to_stdout .and raise_error(SystemExit) do |error| 20 | expect(error.status).to eq(-1) 21 | end 22 | end 23 | 24 | it 'works with one state file' do 25 | ARGV.replace ['./tmp/puma.state'] 26 | allow_any_instance_of(Object).to receive(:get_stats).once { true } 27 | allow_any_instance_of(Object).to receive(:format_stats).once { true } 28 | expect { 29 | run 30 | }.to output.to_stdout 31 | end 32 | 33 | it 'works with multiple state file' do 34 | ARGV.replace ['./tmp/puma.state', './tmp/puma2.state'] 35 | allow_any_instance_of(Object).to receive(:get_stats).twice { true } 36 | allow_any_instance_of(Object).to receive(:format_stats).twice { true } 37 | expect { 38 | run 39 | }.to output.to_stdout 40 | end 41 | 42 | context 'error handling' do 43 | before(:each) do 44 | ARGV.replace ['./tmp/puma.state'] 45 | end 46 | 47 | around do |example| 48 | ClimateControl.modify NO_COLOR: '1' do 49 | example.run 50 | end 51 | end 52 | 53 | context 'errors related to state files' do 54 | it 'handles ENOENT errors' do 55 | allow_any_instance_of(Object).to receive(:get_stats).and_raise(Errno::ENOENT, './tmp/puma.state') 56 | 57 | expect { 58 | run 59 | }.to output(%Q{ 60 | ./tmp/puma.state doesn't exist 61 | }).to_stdout 62 | end 63 | 64 | it 'handles EISDIR errors' do 65 | allow_any_instance_of(Object).to receive(:get_stats).and_raise(Errno::EISDIR, 'fd:9 /home/ylecuyer/Projects/test-puma/tmp/puma.state') 66 | 67 | expect { 68 | run 69 | }.to output(%Q{ 70 | ./tmp/puma.state isn't a state file 71 | }).to_stdout 72 | end 73 | end 74 | 75 | context 'relative unix socket' do 76 | it 'handles ENOENT errors' do 77 | allow_any_instance_of(Object).to receive(:get_stats).and_raise(Errno::ENOENT, "connect(2) for tmp/puma.sock") 78 | 79 | expect { 80 | run 81 | }.to output(%Q{ 82 | Relative Unix socket: the Unix socket of the control app has a relative path. Please, ensure you are running from the same folder as puma. 83 | }).to_stdout 84 | end 85 | end 86 | 87 | context 'errors not related to state files' do 88 | it 'handles ENOENT errors' do 89 | allow_any_instance_of(Object).to receive(:get_stats).and_raise(Errno::ENOENT, "DUMMY ERROR") 90 | 91 | expect { 92 | run 93 | }.to output(%Q{ 94 | ./tmp/puma.state an unhandled error occured: # 95 | }).to_stdout 96 | end 97 | 98 | it 'handles EISDIR errors' do 99 | allow_any_instance_of(Object).to receive(:get_stats).and_raise(Errno::EISDIR, "DUMMY ERROR") 100 | 101 | expect { 102 | run 103 | }.to output(%Q{ 104 | ./tmp/puma.state an unhandled error occured: # 105 | }).to_stdout 106 | end 107 | end 108 | 109 | it 'handles all errors' do 110 | allow_any_instance_of(Object).to receive(:get_stats).and_raise(Errno::ECONNREFUSED) 111 | 112 | expect { 113 | run 114 | }.to output(%Q{ 115 | ./tmp/puma.state an unhandled error occured: # 116 | }).to_stdout 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/stats.rb: -------------------------------------------------------------------------------- 1 | class Stats 2 | 3 | class Worker 4 | def initialize(wstats) 5 | @wstats = wstats 6 | end 7 | 8 | def pid 9 | @wstats['pid'] 10 | end 11 | 12 | def killed=(killed) 13 | @wstats['killed'] = killed 14 | end 15 | 16 | def killed? 17 | !!@wstats['killed'] 18 | end 19 | 20 | def mem=(mem) 21 | @wstats['mem'] = mem 22 | end 23 | 24 | def mem 25 | @wstats['mem'] 26 | end 27 | 28 | def pcpu=(pcpu) 29 | @wstats['pcpu'] = pcpu 30 | end 31 | 32 | def pcpu 33 | @wstats['pcpu'] 34 | end 35 | 36 | def booting? 37 | @wstats.key?('last_status') && @wstats['last_status'].empty? 38 | end 39 | 40 | def running 41 | @wstats.dig('last_status', 'running') || @wstats['running'] || 0 42 | end 43 | alias :total_threads :running 44 | alias :spawned_threads :running 45 | 46 | def max_threads 47 | @wstats.dig('last_status', 'max_threads') || @wstats['max_threads'] || 0 48 | end 49 | alias :total_threads :max_threads 50 | 51 | def pool_capacity 52 | @wstats.dig('last_status', 'pool_capacity') || @wstats['pool_capacity'] || 0 53 | end 54 | 55 | def running_threads 56 | max_threads - pool_capacity 57 | end 58 | 59 | def phase 60 | @wstats['phase'] 61 | end 62 | 63 | def load 64 | running_threads/total_threads.to_f*100 65 | end 66 | 67 | def uptime 68 | return 0 unless @wstats.key?('started_at') 69 | (Time.now - Time.parse(@wstats['started_at'])).to_i 70 | end 71 | 72 | def requests_count 73 | @wstats.dig('last_status', 'requests_count') || @wstats['requests_count'] 74 | end 75 | 76 | def backlog 77 | @wstats.dig('last_status', 'backlog') || 0 78 | end 79 | 80 | def last_checkin 81 | (Time.now - Time.parse(@wstats['last_checkin'])).round 82 | rescue 83 | 0 84 | end 85 | end 86 | 87 | def initialize(stats) 88 | @stats = stats 89 | end 90 | 91 | def workers 92 | @workers ||= (@stats['worker_status'] || [@stats]).map { |wstats| Worker.new(wstats) } 93 | end 94 | 95 | def pid=(pid) 96 | @stats['pid'] = pid 97 | end 98 | 99 | def pid 100 | @stats['pid'] 101 | end 102 | 103 | def state_file_path=(state_file_path) 104 | @stats['state_file_path'] = state_file_path 105 | end 106 | 107 | def state_file_path 108 | @stats['state_file_path'] 109 | end 110 | 111 | def uptime 112 | return 0 unless @stats.key?('started_at') 113 | (Time.now - Time.parse(@stats['started_at'])).to_i 114 | end 115 | 116 | def booting? 117 | workers.all?(&:booting?) 118 | end 119 | 120 | def total_threads 121 | workers.reduce(0) { |total, wstats| total + wstats.max_threads } 122 | end 123 | 124 | def running_threads 125 | workers.reduce(0) { |total, wstats| total + wstats.running_threads } 126 | end 127 | 128 | def spawned_threads 129 | workers.reduce(0) { |total, wstats| total + wstats.spawned_threads } 130 | end 131 | 132 | def max_threads 133 | workers.reduce(0) { |total, wstats| total + wstats.max_threads } 134 | end 135 | 136 | def requests_count 137 | workers_with_requests_count = workers.select(&:requests_count) 138 | return if workers_with_requests_count.none? 139 | workers_with_requests_count.reduce(0) { |total, wstats| total + wstats.requests_count } 140 | end 141 | 142 | def running 143 | @stats['running'] || 0 144 | end 145 | 146 | def pool_capacity 147 | @stats['pool_capacity'] || 0 148 | end 149 | 150 | def phase 151 | @stats['phase'] 152 | end 153 | 154 | def load 155 | running_threads/total_threads.to_f*100 156 | end 157 | 158 | def version 159 | return nil unless @stats.key?('versions') 160 | 161 | "#{@stats['versions']['puma']}/#{@stats['versions']['ruby']['engine']}#{@stats['versions']['ruby']['version']}p#{@stats['versions']['ruby']['patchlevel']}" 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /lib/core.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | require 'json' 3 | require 'net_x/http_unix' 4 | require 'openssl' 5 | require 'time' 6 | require 'open3' 7 | require_relative 'stats' 8 | 9 | def get_stats(state_file_path) 10 | puma_state = YAML.load_file(state_file_path) 11 | 12 | uri = URI.parse(puma_state["control_url"]) 13 | 14 | address = if uri.scheme =~ /unix/i 15 | [uri.scheme, '://', uri.host, uri.path].join 16 | else 17 | [uri.host, uri.path].join 18 | end 19 | 20 | client = NetX::HTTPUnix.new(address, uri.port) 21 | 22 | if uri.scheme =~ /ssl/i 23 | client.use_ssl = true 24 | client.verify_mode = OpenSSL::SSL::VERIFY_NONE if ENV['SSL_NO_VERIFY'] == '1' 25 | end 26 | 27 | req = Net::HTTP::Get.new("/stats?token=#{puma_state["control_auth_token"]}") 28 | resp = client.request(req) 29 | raw_stats = JSON.parse(resp.body) 30 | debug raw_stats 31 | stats = Stats.new(raw_stats) 32 | 33 | hydrate_stats(stats, puma_state, state_file_path) 34 | end 35 | 36 | def get_memory_from_top(raw_memory) 37 | case raw_memory[-1].downcase 38 | when 'g' 39 | (raw_memory[0...-1].to_f*1024).to_i 40 | when 'm' 41 | raw_memory[0...-1].to_i 42 | else 43 | raw_memory.to_i/1024 44 | end 45 | end 46 | 47 | PID_COLUMN = 0 48 | MEM_COLUMN = 5 49 | CPU_COLUMN = 8 50 | OPEN3_STDOUT = 1 51 | 52 | def get_top_stats(pids) 53 | pids.each_slice(19).inject({}) do |res, pids19| 54 | top_result = Open3.popen3({ 'LC_ALL' => 'C' }, "top -b -n 1 -p #{pids19.map(&:to_i).join(',')}")[OPEN3_STDOUT].read 55 | top_result.split("\n").last(pids19.length).map { |row| r = row.split(' '); [r[PID_COLUMN].to_i, get_memory_from_top(r[MEM_COLUMN]), r[CPU_COLUMN].to_f] } 56 | .inject(res) { |hash, row| hash[row[0]] = { mem: row[1], pcpu: row[2] }; hash } 57 | res 58 | end 59 | end 60 | 61 | def hydrate_stats(stats, puma_state, state_file_path) 62 | stats.pid = puma_state['pid'] 63 | stats.state_file_path = state_file_path 64 | 65 | workers_pids = stats.workers.map(&:pid) 66 | 67 | top_stats = get_top_stats(workers_pids) 68 | 69 | stats.tap do |s| 70 | stats.workers.map do |wstats| 71 | wstats.mem = top_stats.dig(wstats.pid, :mem) || 0 72 | wstats.pcpu = top_stats.dig(wstats.pid, :pcpu) || 0 73 | wstats.killed = !top_stats.key?(wstats.pid) || (wstats.mem <=0 && wstats.pcpu <= 0) 74 | end 75 | end 76 | end 77 | 78 | def format_stats(stats) 79 | master_line = "#{stats.pid} (#{stats.state_file_path})" 80 | master_line += " Version: #{stats.version} |" if stats.version 81 | master_line += " Uptime: #{seconds_to_human(stats.uptime)}" 82 | master_line += " | Phase: #{stats.phase}" if stats.phase 83 | 84 | if stats.booting? 85 | master_line += " #{yellow("booting")}" 86 | else 87 | master_line += " | Load: #{color(75, 50, stats.load, asciiThreadLoad(stats.running_threads, stats.spawned_threads, stats.max_threads))}" 88 | master_line += " | Req: #{stats.requests_count}" if stats.requests_count 89 | end 90 | 91 | output = [master_line] + stats.workers.map do |wstats| 92 | worker_line = " └ #{wstats.pid.to_s.rjust(5, ' ')} CPU: #{color(75, 50, wstats.pcpu, wstats.pcpu.to_s.rjust(5, ' '))}% Mem: #{color(1000, 750, wstats.mem, wstats.mem.to_s.rjust(4, ' '))} MB Uptime: #{seconds_to_human(wstats.uptime)}" 93 | 94 | if wstats.booting? 95 | worker_line += " #{yellow("booting")}" 96 | elsif wstats.killed? 97 | worker_line += " #{red("killed")}" 98 | else 99 | worker_line += " | Load: #{color(75, 50, wstats.load, asciiThreadLoad(wstats.running_threads, wstats.spawned_threads, wstats.max_threads))}" 100 | worker_line += " | Phase: #{red(wstats.phase)}" if wstats.phase != stats.phase 101 | worker_line += " | Req: #{wstats.requests_count}" if wstats.requests_count 102 | worker_line += " Queue: #{red(wstats.backlog.to_s)}" if wstats.backlog > 0 103 | worker_line += " Last checkin: #{red(wstats.last_checkin)}" if wstats.last_checkin >= 10 104 | end 105 | 106 | worker_line 107 | end 108 | 109 | output.join("\n") 110 | end 111 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'climate_control' 2 | require 'timecop' 3 | 4 | # This file was generated by the `rspec --init` command. Conventionally, all 5 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 6 | # The generated `.rspec` file contains `--require spec_helper` which will cause 7 | # this file to always be loaded, without a need to explicitly require it in any 8 | # files. 9 | # 10 | # Given that it is always loaded, you are encouraged to keep this file as 11 | # light-weight as possible. Requiring heavyweight dependencies from this file 12 | # will add to the boot time of your test suite on EVERY test run, even for an 13 | # individual file that may not need all of that loaded. Instead, consider making 14 | # a separate helper file that requires the additional dependencies and performs 15 | # the additional setup, and require it from the spec files that actually need 16 | # it. 17 | # 18 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 19 | RSpec.configure do |config| 20 | # rspec-expectations config goes here. You can use an alternate 21 | # assertion/expectation library such as wrong or the stdlib/minitest 22 | # assertions if you prefer. 23 | config.expect_with :rspec do |expectations| 24 | # This option will default to `true` in RSpec 4. It makes the `description` 25 | # and `failure_message` of custom matchers include text for helper methods 26 | # defined using `chain`, e.g.: 27 | # be_bigger_than(2).and_smaller_than(4).description 28 | # # => "be bigger than 2 and smaller than 4" 29 | # ...rather than: 30 | # # => "be bigger than 2" 31 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 32 | end 33 | 34 | # rspec-mocks config goes here. You can use an alternate test double 35 | # library (such as bogus or mocha) by changing the `mock_with` option here. 36 | config.mock_with :rspec do |mocks| 37 | # Prevents you from mocking or stubbing a method that does not exist on 38 | # a real object. This is generally recommended, and will default to 39 | # `true` in RSpec 4. 40 | mocks.verify_partial_doubles = true 41 | end 42 | 43 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 44 | # have no way to turn it off -- the option exists only for backwards 45 | # compatibility in RSpec 3). It causes shared context metadata to be 46 | # inherited by the metadata hash of host groups and examples, rather than 47 | # triggering implicit auto-inclusion in groups with matching metadata. 48 | config.shared_context_metadata_behavior = :apply_to_host_groups 49 | 50 | # The settings below are suggested to provide a good initial experience 51 | # with RSpec, but feel free to customize to your heart's content. 52 | =begin 53 | # This allows you to limit a spec run to individual examples or groups 54 | # you care about by tagging them with `:focus` metadata. When nothing 55 | # is tagged with `:focus`, all examples get run. RSpec also provides 56 | # aliases for `it`, `describe`, and `context` that include `:focus` 57 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 58 | config.filter_run_when_matching :focus 59 | 60 | # Allows RSpec to persist some state between runs in order to support 61 | # the `--only-failures` and `--next-failure` CLI options. We recommend 62 | # you configure your source control system to ignore this file. 63 | config.example_status_persistence_file_path = "spec/examples.txt" 64 | 65 | # Limits the available syntax to the non-monkey patched syntax that is 66 | # recommended. For more details, see: 67 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 68 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 69 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 70 | config.disable_monkey_patching! 71 | 72 | # This setting enables warnings. It's recommended, but in some cases may 73 | # be too noisy due to issues in dependencies. 74 | config.warnings = true 75 | 76 | # Many RSpec users commonly either run the entire suite or an individual 77 | # file, and it's useful to allow more verbose output when running an 78 | # individual spec file. 79 | if config.files_to_run.one? 80 | # Use the documentation formatter for detailed output, 81 | # unless a formatter has already been configured 82 | # (e.g. via a command-line flag). 83 | config.default_formatter = "doc" 84 | end 85 | 86 | # Print the 10 slowest examples and example groups at the 87 | # end of the spec run, to help surface which specs are running 88 | # particularly slow. 89 | config.profile_examples = 10 90 | 91 | # Run specs in random order to surface order dependencies. If you find an 92 | # order dependency and want to debug it, you can fix the order by providing 93 | # the seed, which is printed after each run. 94 | # --seed 1234 95 | config.order = :random 96 | 97 | # Seed global randomization in this process using the `--seed` CLI option. 98 | # Setting this allows you to use `--seed` to deterministically reproduce 99 | # test failures related to randomization by passing the same `--seed` value 100 | # as the one that triggered the failure. 101 | Kernel.srand config.seed 102 | =end 103 | end 104 | -------------------------------------------------------------------------------- /spec/stats_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require './lib/stats' 4 | 5 | describe Stats do 6 | 7 | context 'clustered stats' do 8 | let(:stats) { Stats.new({"started_at"=>"2019-07-14T14:32:56Z", "workers"=>4, "phase"=>0, "booted_workers"=>4, "old_workers"=>0, "worker_status"=>[{"started_at"=>"2019-07-14T14:32:56Z", "pid"=>28909, "index"=>0, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T14:33:54Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>0, "max_threads"=>4}}, {"started_at"=>"2019-07-14T14:32:56Z", "pid"=>28911, "index"=>1, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T14:33:54Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>0, "max_threads"=>4}}, {"started_at"=>"2019-07-14T14:32:56Z", "pid"=>28917, "index"=>2, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T14:33:54Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>1, "max_threads"=>4}}, {"started_at"=>"2019-07-14T14:32:56Z", "pid"=>28921, "index"=>3, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T14:33:54Z", "last_status"=>{"backlog"=>0, "running"=>2, "pool_capacity"=>3, "max_threads"=>4}}]}) } 9 | 10 | it 'returns uptime 0 for older version of puma' do 11 | stats = Stats.new({"workers"=>4, "phase"=>0, "booted_workers"=>4, "old_workers"=>0, "worker_status"=>[{"pid"=>28909, "index"=>0, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T14:33:54Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>0, "max_threads"=>4}}, {"pid"=>28911, "index"=>1, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T14:33:54Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>0, "max_threads"=>4}}, {"pid"=>28917, "index"=>2, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T14:33:54Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>1, "max_threads"=>4}}, {"pid"=>28921, "index"=>3, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T14:33:54Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>2, "max_threads"=>4}}]}) 12 | expect(stats.uptime).to eq(0) 13 | expect(stats.workers.map { |wstats| wstats.uptime }).to eq([0, 0, 0, 0]) 14 | end 15 | 16 | it 'gives workers' do 17 | expect(stats.workers.count).to eq(4) 18 | end 19 | 20 | it 'gives running threads' do 21 | expect(stats.running_threads).to eq(12) 22 | end 23 | 24 | it 'gives total threads' do 25 | expect(stats.total_threads).to eq(16) 26 | end 27 | 28 | it 'master is not marked as booting' do 29 | expect(stats.booting?).to eq(false) 30 | end 31 | 32 | it 'master process is marked as booting' do 33 | stats = Stats.new({"workers"=>4, "phase"=>0, "booted_workers"=>4, "old_workers"=>0, "worker_status"=>[{"pid"=>28909, "index"=>0, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T14:33:54Z", "last_status"=>{}}, {"pid"=>28911, "index"=>1, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T14:33:54Z", "last_status"=>{}}]}) 34 | expect(stats.booting?).to eq(true) 35 | end 36 | 37 | it 'gives the number of requests' do 38 | stats = Stats.new({"workers"=>4, "phase"=>0, "booted_workers"=>4, "old_workers"=>0, "worker_status"=>[{"pid"=>28909, "index"=>0, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T14:33:54Z", "last_status"=>{"requests_count" => 150}}, {"pid"=>28911, "index"=>1, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T14:33:54Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>0, "max_threads"=>4, "requests_count" => 300}}]}) 39 | expect(stats.requests_count).to eq(450) 40 | end 41 | 42 | context 'workers' do 43 | it 'gives running threads first worker' do 44 | expect(stats.workers.first.running_threads).to eq(4) 45 | end 46 | 47 | it 'gives total threads first worker' do 48 | expect(stats.workers.first.total_threads).to eq(4) 49 | end 50 | 51 | it 'gives running threads last worker' do 52 | expect(stats.workers.last.running_threads).to eq(1) 53 | end 54 | 55 | it 'gives total threads last worker' do 56 | expect(stats.workers.last.total_threads).to eq(4) 57 | end 58 | 59 | it 'can mark worker as killed' do 60 | worker = stats.workers.first 61 | expect { 62 | worker.killed = true 63 | }.to change(worker, :killed?).from(false).to(true) 64 | end 65 | 66 | it 'worker is marked as booting' do 67 | stats = Stats.new({"workers"=>4, "phase"=>0, "booted_workers"=>4, "old_workers"=>0, "worker_status"=>[{"pid"=>28909, "index"=>0, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T14:33:54Z", "last_status"=>{}}, {"pid"=>28911, "index"=>1, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T14:33:54Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>0, "max_threads"=>4}}]}) 68 | worker = stats.workers.first 69 | expect(worker.booting?).to eq(true) 70 | end 71 | 72 | it 'gives the number of requests' do 73 | stats = Stats.new({"workers"=>4, "phase"=>0, "booted_workers"=>4, "old_workers"=>0, "worker_status"=>[{"pid"=>28909, "index"=>0, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T14:33:54Z", "last_status"=>{"requests_count" => 150}}, {"pid"=>28911, "index"=>1, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T14:33:54Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>0, "max_threads"=>4, "requests_count" => 300}}]}) 74 | worker = stats.workers.first 75 | expect(worker.requests_count).to eq(150) 76 | end 77 | end 78 | end 79 | 80 | context 'single stats' do 81 | let(:stats) { Stats.new({"started_at"=>"2019-07-14T15:07:15Z", "backlog"=>0, "running"=>4, "pool_capacity"=>2, "max_threads"=>4, "requests_count"=>150}) } 82 | 83 | it 'returns uptime 0 for older version of puma' do 84 | stats = Stats.new({"backlog"=>0, "running"=>4, "pool_capacity"=>2, "max_threads"=>4}) 85 | expect(stats.uptime).to eq(0) 86 | end 87 | 88 | it 'gives workers' do 89 | expect(stats.workers.count).to eq(1) 90 | end 91 | 92 | it 'gives running threads' do 93 | expect(stats.running_threads).to eq(2) 94 | end 95 | 96 | it 'gives total threads' do 97 | expect(stats.total_threads).to eq(4) 98 | end 99 | 100 | it 'gives the number of requests' do 101 | expect(stats.requests_count).to eq(150) 102 | end 103 | 104 | context 'workers' do 105 | it 'gives running threads' do 106 | expect(stats.workers.first.running_threads).to eq(2) 107 | end 108 | 109 | it 'gives total threads' do 110 | expect(stats.workers.first.total_threads).to eq(4) 111 | end 112 | 113 | it 'gives the number of requests' do 114 | expect(stats.workers.first.requests_count).to eq(150) 115 | end 116 | end 117 | end 118 | 119 | end 120 | -------------------------------------------------------------------------------- /spec/core_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require './lib/core' 4 | require './lib/helpers' 5 | 6 | describe 'Core' do 7 | 8 | def stub_top(output) 9 | allow(Open3).to receive(:popen3) do 10 | [nil, StringIO.new(output), nil, 0] 11 | end 12 | end 13 | 14 | context 'get_top_stats' do 15 | it 'prevents shell injections' do 16 | get_top_stats(['| echo "shell injection" > /tmp/out.log']) 17 | expect(File).not_to exist('/tmp/out.log') 18 | end 19 | 20 | it 'skips top header' do 21 | stub_top %Q{top - 16:24:47 up 2:39, 1 user, load average: 3,30, 3,04, 3,07 22 | Tasks: 1 total, 1 running, 0 sleeping, 0 stopped, 0 zombie 23 | %Cpu(s): 21,1 us, 2,6 sy, 1,8 ni, 72,2 id, 0,2 wa, 0,0 hi, 2,1 si, 0,0 st 24 | KiB Mem : 16259816 total, 2183812 free, 4538464 used, 9537540 buff/cache 25 | KiB Swap: 2097148 total, 2097148 free, 0 used. 10639744 avail Mem 26 | 27 | 12362 ylecuyer 20 0 1144000 65764 8916 S 0,0 1,8 0:05.18 bundle 28 | 12366 ylecuyer 20 0 1145032 65732 8936 S 0,0 1,8 0:05.17 bundle 29 | 12370 ylecuyer 20 0 1143996 65708 8936 S 0,0 1,8 0:05.17 bundle 30 | 12372 ylecuyer 20 0 1143992 65780 8936 S 0,0 1,8 0:05.16 bundle} 31 | 32 | expect(get_top_stats([12362, 12366, 12370, 12372])).to eq({ 33 | 12362 => { mem: 64, pcpu: 0.0 }, 34 | 12366 => { mem: 64, pcpu: 0.0 }, 35 | 12370 => { mem: 64, pcpu: 0.0 }, 36 | 12372 => { mem: 64, pcpu: 0.0 } 37 | }) 38 | end 39 | 40 | it 'returns mem and cpu' do 41 | stub_top %Q{12362 ylecuyer 20 0 1144000 65764 8916 S 0,0 1,8 0:05.18 bundle 42 | 12366 ylecuyer 20 0 1145032 65732 8936 S 0,0 1,8 0:05.17 bundle 43 | 12370 ylecuyer 20 0 1143996 65708 8936 S 0,0 1,8 0:05.17 bundle 44 | 12372 ylecuyer 20 0 1143992 65780 8936 S 0,0 1,8 0:05.16 bundle} 45 | 46 | expect(get_top_stats([12362, 12366, 12370, 12372])).to eq({ 47 | 12362 => { mem: 64, pcpu: 0.0 }, 48 | 12366 => { mem: 64, pcpu: 0.0 }, 49 | 12370 => { mem: 64, pcpu: 0.0 }, 50 | 12372 => { mem: 64, pcpu: 0.0 } 51 | }) 52 | end 53 | 54 | context 'with high memory' do 55 | it 'for MB' do 56 | stub_top %Q{12362 ylecuyer 20 0 1144000 988.6m 8916 S 0,0 1,8 0:05.18 bundle 57 | 12366 ylecuyer 20 0 1145032 65732 8936 S 0,0 1,8 0:05.17 bundle 58 | 12370 ylecuyer 20 0 1143996 65708 8936 S 0,0 1,8 0:05.17 bundle 59 | 12372 ylecuyer 20 0 1143992 65780 8936 S 0,0 1,8 0:05.16 bundle} 60 | 61 | expect(get_top_stats([12362, 12366, 12370, 12372])).to eq({ 62 | 12362 => { mem: 988, pcpu: 0.0 }, 63 | 12366 => { mem: 64, pcpu: 0.0 }, 64 | 12370 => { mem: 64, pcpu: 0.0 }, 65 | 12372 => { mem: 64, pcpu: 0.0 } 66 | }) 67 | end 68 | 69 | it 'for GB' do 70 | stub_top %Q{12362 ylecuyer 20 0 1144000 1.646g 8916 S 0,0 1,8 0:05.18 bundle 71 | 12366 ylecuyer 20 0 1145032 65732 8936 S 0,0 1,8 0:05.17 bundle 72 | 12370 ylecuyer 20 0 1143996 65708 8936 S 0,0 1,8 0:05.17 bundle 73 | 12372 ylecuyer 20 0 1143992 65780 8936 S 0,0 1,8 0:05.16 bundle} 74 | 75 | expect(get_top_stats([12362, 12366, 12370, 12372])).to eq({ 76 | 12362 => { mem: 1685, pcpu: 0.0 }, 77 | 12366 => { mem: 64, pcpu: 0.0 }, 78 | 12370 => { mem: 64, pcpu: 0.0 }, 79 | 12372 => { mem: 64, pcpu: 0.0 } 80 | }) 81 | end 82 | end 83 | end 84 | 85 | context 'hydrate_stats' do 86 | before(:each) do 87 | allow(self).to receive(:get_top_stats) { {} } 88 | end 89 | 90 | it 'adds the main pid and state_file_path' do 91 | stats = Stats.new({ 'worker_status' => [] }) 92 | hydrate_stats(stats, { 'pid' => '1234' }, 'test') 93 | 94 | expect(stats.pid).to eq('1234') 95 | expect(stats.state_file_path).to eq('test') 96 | end 97 | end 98 | 99 | context 'display_stats' do 100 | 101 | before do 102 | Timecop.freeze(Time.parse('2019-07-14T10:54:47Z')) 103 | end 104 | 105 | after do 106 | Timecop.return 107 | end 108 | 109 | it 'shows puma and ruby versions if available' do 110 | stats = {"started_at"=>"2022-05-30T21:49:20Z", "backlog"=>0, "running"=>2, "pool_capacity"=>5, "max_threads"=>5, "requests_count"=>2, "versions"=>{"puma"=>"5.6.4", "ruby"=>{"engine"=>"ruby", "version"=>"2.5.3", "patchlevel"=>105}}, "pid"=>21725, "state_file_path"=>"../testpuma/tmp/puma.state", "pcpu"=>10, "mem"=>64} 111 | 112 | ClimateControl.modify NO_COLOR: '1' do 113 | expect(format_stats(Stats.new(stats))).to eq( 114 | %Q{21725 (../testpuma/tmp/puma.state) Version: 5.6.4/ruby2.5.3p105 | Uptime: --m--s | Load: 0[░░ ]5 | Req: 2 115 | └ 21725 CPU: 10% Mem: 64 MB Uptime: --m--s | Load: 0[░░ ]5 | Req: 2}) 116 | end 117 | end 118 | 119 | it 'works in clusted mode' do 120 | stats = {"started_at"=>"2019-07-14T10:49:24Z", "workers"=>4, "phase"=>0, "booted_workers"=>4, "old_workers"=>0, "worker_status"=>[{"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12362, "index"=>0, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}, {"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12366, "index"=>1, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}, {"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12370, "index"=>2, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}, {"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12372, "index"=>3, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}], "pid"=>12328, "state_file_path"=>"../testpuma/tmp/puma.state"} 121 | 122 | ClimateControl.modify NO_COLOR: '1' do 123 | expect(format_stats(Stats.new(stats))).to eq( 124 | %Q{12328 (../testpuma/tmp/puma.state) Uptime: 5m23s | Phase: 0 | Load: 0[░░░░░░░░░░░░░░░░]16 125 | └ 12362 CPU: 0.0% Mem: 64 MB Uptime: 5m23s | Load: 0[░░░░]4 126 | └ 12366 CPU: 0.0% Mem: 64 MB Uptime: 5m23s | Load: 0[░░░░]4 127 | └ 12370 CPU: 0.0% Mem: 64 MB Uptime: 5m23s | Load: 0[░░░░]4 128 | └ 12372 CPU: 0.0% Mem: 64 MB Uptime: 5m23s | Load: 0[░░░░]4}) 129 | end 130 | end 131 | 132 | context 'with few running threads' do 133 | stats = {"started_at"=>"2019-07-14T10:49:24Z", "workers"=>4, "phase"=>0, "booted_workers"=>4, "old_workers"=>0, "worker_status"=>[{"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12362, "index"=>0, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>1, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}, {"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12366, "index"=>1, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>1, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}, {"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12370, "index"=>2, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>1, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}, {"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12372, "index"=>3, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>1, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}], "pid"=>12328, "state_file_path"=>"../testpuma/tmp/puma.state"} 134 | 135 | 136 | it 'displays the right amount of max threads' do 137 | ClimateControl.modify NO_COLOR: '1' do 138 | expect(format_stats(Stats.new(stats))).to eq( 139 | %Q{12328 (../testpuma/tmp/puma.state) Uptime: 5m23s | Phase: 0 | Load: 0[░░░░ ]16 140 | └ 12362 CPU: 0.0% Mem: 64 MB Uptime: 5m23s | Load: 0[░ ]4 141 | └ 12366 CPU: 0.0% Mem: 64 MB Uptime: 5m23s | Load: 0[░ ]4 142 | └ 12370 CPU: 0.0% Mem: 64 MB Uptime: 5m23s | Load: 0[░ ]4 143 | └ 12372 CPU: 0.0% Mem: 64 MB Uptime: 5m23s | Load: 0[░ ]4}) 144 | end 145 | end 146 | end 147 | 148 | it 'works in clusted mode during phased restart' do 149 | stats = {"started_at"=>"2019-07-14T10:49:24Z", "workers"=>4, "phase"=>1, "booted_workers"=>4, "old_workers"=>0, "worker_status"=>[{"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12362, "index"=>0, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}, {"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12366, "index"=>1, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}, {"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12370, "index"=>2, "phase"=>1, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}, {"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12372, "index"=>3, "phase"=>1, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}], "pid"=>12328, "state_file_path"=>"../testpuma/tmp/puma.state"} 150 | 151 | ClimateControl.modify NO_COLOR: '1' do 152 | expect(format_stats(Stats.new(stats))).to eq( 153 | %Q{12328 (../testpuma/tmp/puma.state) Uptime: 5m23s | Phase: 1 | Load: 0[░░░░░░░░░░░░░░░░]16 154 | └ 12362 CPU: 0.0% Mem: 64 MB Uptime: 5m23s | Load: 0[░░░░]4 | Phase: 0 155 | └ 12366 CPU: 0.0% Mem: 64 MB Uptime: 5m23s | Load: 0[░░░░]4 | Phase: 0 156 | └ 12370 CPU: 0.0% Mem: 64 MB Uptime: 5m23s | Load: 0[░░░░]4 157 | └ 12372 CPU: 0.0% Mem: 64 MB Uptime: 5m23s | Load: 0[░░░░]4}) 158 | end 159 | end 160 | 161 | it 'shows killed workers' do 162 | stats = {"started_at"=>"2019-07-14T10:49:24Z", "workers"=>4, "phase"=>1, "booted_workers"=>4, "old_workers"=>0, "worker_status"=>[{"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12362, "index"=>0, "phase"=>1, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}, {"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12366, "index"=>1, "phase"=>1, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}, {"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12370, "index"=>2, "phase"=>1, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}, {"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12372, "index"=>3, "phase"=>1, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}], "pid"=>12328, "state_file_path"=>"../testpuma/tmp/puma.state"} 163 | 164 | ClimateControl.modify NO_COLOR: '1' do 165 | stats = Stats.new(stats) 166 | allow(stats.workers.first).to receive(:booting?) { false } 167 | allow(stats.workers.first).to receive(:killed?) { true } 168 | 169 | expect(format_stats(stats)).to eq( 170 | %Q{12328 (../testpuma/tmp/puma.state) Uptime: 5m23s | Phase: 1 | Load: 0[░░░░░░░░░░░░░░░░]16 171 | └ 12362 CPU: 0.0% Mem: 64 MB Uptime: 5m23s killed 172 | └ 12366 CPU: 0.0% Mem: 64 MB Uptime: 5m23s | Load: 0[░░░░]4 173 | └ 12370 CPU: 0.0% Mem: 64 MB Uptime: 5m23s | Load: 0[░░░░]4 174 | └ 12372 CPU: 0.0% Mem: 64 MB Uptime: 5m23s | Load: 0[░░░░]4}) 175 | end 176 | end 177 | 178 | it 'shows booting workers' do 179 | stats = {"started_at"=>"2019-07-14T10:49:24Z", "workers"=>4, "phase"=>1, "booted_workers"=>4, "old_workers"=>0, "worker_status"=>[{"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12362, "index"=>0, "phase"=>1, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}, {"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12366, "index"=>1, "phase"=>1, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}, {"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12370, "index"=>2, "phase"=>1, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}, {"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12372, "index"=>3, "phase"=>1, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}], "pid"=>12328, "state_file_path"=>"../testpuma/tmp/puma.state"} 180 | 181 | ClimateControl.modify NO_COLOR: '1' do 182 | stats = Stats.new(stats) 183 | allow(stats.workers.first).to receive(:booting?) { true } 184 | 185 | expect(format_stats(stats)).to eq( 186 | %Q{12328 (../testpuma/tmp/puma.state) Uptime: 5m23s | Phase: 1 | Load: 0[░░░░░░░░░░░░░░░░]16 187 | └ 12362 CPU: 0.0% Mem: 64 MB Uptime: 5m23s booting 188 | └ 12366 CPU: 0.0% Mem: 64 MB Uptime: 5m23s | Load: 0[░░░░]4 189 | └ 12370 CPU: 0.0% Mem: 64 MB Uptime: 5m23s | Load: 0[░░░░]4 190 | └ 12372 CPU: 0.0% Mem: 64 MB Uptime: 5m23s | Load: 0[░░░░]4}) 191 | end 192 | end 193 | 194 | it 'shows the master process booting' do 195 | stats = {"started_at"=>"2019-07-14T10:49:24Z", "workers"=>4, "phase"=>1, "booted_workers"=>4, "old_workers"=>0, "worker_status"=>[{"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12362, "index"=>0, "phase"=>1, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}, {"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12366, "index"=>1, "phase"=>1, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}, {"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12370, "index"=>2, "phase"=>1, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}, {"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12372, "index"=>3, "phase"=>1, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4}, "mem"=>64, "pcpu"=>0.0}], "pid"=>12328, "state_file_path"=>"../testpuma/tmp/puma.state"} 196 | 197 | ClimateControl.modify NO_COLOR: '1' do 198 | stats = Stats.new(stats) 199 | allow_any_instance_of(Stats::Worker).to receive(:booting?) { true } 200 | 201 | expect(format_stats(stats)).to eq( 202 | %Q{12328 (../testpuma/tmp/puma.state) Uptime: 5m23s | Phase: 1 booting 203 | └ 12362 CPU: 0.0% Mem: 64 MB Uptime: 5m23s booting 204 | └ 12366 CPU: 0.0% Mem: 64 MB Uptime: 5m23s booting 205 | └ 12370 CPU: 0.0% Mem: 64 MB Uptime: 5m23s booting 206 | └ 12372 CPU: 0.0% Mem: 64 MB Uptime: 5m23s booting}) 207 | end 208 | end 209 | 210 | it 'show the number of request when present in clustered mode' do 211 | stats = {"started_at"=>"2019-07-14T10:49:24Z", "workers"=>4, "phase"=>0, "booted_workers"=>4, "old_workers"=>0, "worker_status"=>[{"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12362, "index"=>0, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4, "requests_count"=>150}, "mem"=>64, "pcpu"=>0.0}, {"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12366, "index"=>1, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4, "requests_count"=>223}, "mem"=>64, "pcpu"=>0.0}, {"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12370, "index"=>2, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4, "requests_count"=>450}, "mem"=>64, "pcpu"=>0.0}, {"started_at"=>"2019-07-14T10:49:24Z", "pid"=>12372, "index"=>3, "phase"=>0, "booted"=>true, "last_checkin"=>"2019-07-14T13:09:00Z", "last_status"=>{"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4, "requests_count"=>10}, "mem"=>64, "pcpu"=>0.0}], "pid"=>12328, "state_file_path"=>"../testpuma/tmp/puma.state"} 212 | 213 | ClimateControl.modify NO_COLOR: '1' do 214 | expect(format_stats(Stats.new(stats))).to eq( 215 | %Q{12328 (../testpuma/tmp/puma.state) Uptime: 5m23s | Phase: 0 | Load: 0[░░░░░░░░░░░░░░░░]16 | Req: 833 216 | └ 12362 CPU: 0.0% Mem: 64 MB Uptime: 5m23s | Load: 0[░░░░]4 | Req: 150 217 | └ 12366 CPU: 0.0% Mem: 64 MB Uptime: 5m23s | Load: 0[░░░░]4 | Req: 223 218 | └ 12370 CPU: 0.0% Mem: 64 MB Uptime: 5m23s | Load: 0[░░░░]4 | Req: 450 219 | └ 12372 CPU: 0.0% Mem: 64 MB Uptime: 5m23s | Load: 0[░░░░]4 | Req: 10}) 220 | end 221 | end 222 | 223 | it 'works in single mode' do 224 | stats = {"started_at"=>"2019-07-14T10:49:24Z", "backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4, "pid"=>21725, "state_file_path"=>"../testpuma/tmp/puma.state", "pcpu"=>10, "mem"=>64} 225 | 226 | ClimateControl.modify NO_COLOR: '1' do 227 | expect(format_stats(Stats.new(stats))).to eq( 228 | %Q{21725 (../testpuma/tmp/puma.state) Uptime: 5m23s | Load: 0[░░░░]4 229 | └ 21725 CPU: 10% Mem: 64 MB Uptime: 5m23s | Load: 0[░░░░]4}) 230 | end 231 | end 232 | 233 | it 'show the number of request when present in single mode' do 234 | stats = {"started_at"=>"2019-07-14T10:49:24Z", "backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4, "pid"=>21725, "state_file_path"=>"../testpuma/tmp/puma.state", "pcpu"=>10, "mem"=>64, "requests_count"=> 150} 235 | 236 | ClimateControl.modify NO_COLOR: '1' do 237 | expect(format_stats(Stats.new(stats))).to eq( 238 | %Q{21725 (../testpuma/tmp/puma.state) Uptime: 5m23s | Load: 0[░░░░]4 | Req: 150 239 | └ 21725 CPU: 10% Mem: 64 MB Uptime: 5m23s | Load: 0[░░░░]4 | Req: 150}) 240 | end 241 | end 242 | 243 | it 'displays --m--s for uptime for older versions of puma with no time instrumentation' do 244 | stats = {"backlog"=>0, "running"=>4, "pool_capacity"=>4, "max_threads"=>4, "pid"=>21725, "state_file_path"=>"../testpuma/tmp/puma.state", "pcpu"=>10, "mem"=>64} 245 | 246 | ClimateControl.modify NO_COLOR: '1' do 247 | expect(format_stats(Stats.new(stats))).to eq( 248 | %Q{21725 (../testpuma/tmp/puma.state) Uptime: --m--s | Load: 0[░░░░]4 249 | └ 21725 CPU: 10% Mem: 64 MB Uptime: --m--s | Load: 0[░░░░]4}) 250 | end 251 | end 252 | end 253 | end 254 | --------------------------------------------------------------------------------