app/services/host_stats.rb
Rama develop · réplica de lectura del repositorio original.
module HostStats # rubocop:disable Metrics/ModuleLength
Snapshot = Data.define(:cpu_percent, :cpu_cores, :ram_percent, :ram_free, :ram_total)
# Placeholder used by the navbar's HostStats::Component on initial render
# so a page render never pays for sampling. Real values are filled in by
# the immediate poll the Stimulus controller fires on first connect
# (when its polledAt value is still 0) and then every 5 s.
EMPTY_SNAPSHOT = Snapshot.new(nil, nil, nil, nil, nil).freeze
# Cache briefly so concurrent renders and the 5 s poller don't each pay the
# cost (cheap on Linux, expensive on macOS dev — sysctl + vm_stat shell-outs).
CACHE_TTL_SECONDS = 1.0
# MemTotal/NCPU of the Docker host are effectively static; refetch at most
# once a minute so the 5 s poll doesn't hit the Docker daemon every tick.
HOST_LIMITS_TTL_SECONDS = 60.0
# Linux process/memory pseudo-filesystem.
PROC_ROOT = '/proc'.freeze
# CPU sampler for macOS dev, where /proc and the host cgroup are absent.
IOSTAT_COMMAND = %w[iostat -c 2 -w 1].freeze
@cache_mutex = Mutex.new
def self.snapshot
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@cache_mutex.synchronize do
if @cached_at.nil? || (now - @cached_at) > CACHE_TTL_SECONDS
if @host_limits.nil? || (now - @host_limits_at) > HOST_LIMITS_TTL_SECONDS
@host_limits = fetch_host_limits
@host_limits_at = now
end
cpu = read_cpu_metrics(prev: @prev_cpu_sample, now: now, limits: @host_limits)
@prev_cpu_sample = cpu[:sample] || @prev_cpu_sample
ram = read_ram_metrics(limits: @host_limits)
@cached_snapshot = Snapshot.new(cpu[:percent], cpu[:cores], ram[:percent], ram[:free], ram[:total])
@cached_at = now
end
@cached_snapshot
end
end
def self.reset_cache!
@cache_mutex.synchronize do
@cached_at = nil
@cached_snapshot = nil
@prev_cpu_sample = nil
@host_limits = nil
@host_limits_at = nil
end
end
# Seam: specs override this to point /proc reads at a temp fixture directory.
def self.proc_root
PROC_ROOT
end
class << self
private
# `prev` is the previous return value's :sample, fed back so we can
# compute the delta. nil on the first call → :percent is nil.
def read_cpu_metrics(prev:, now:, limits:)
cpu_from_host_cgroup(prev, now, limits) || cpu_from_proc_stat(prev) ||
cpu_from_iostat || { percent: nil, cores: nil, sample: nil }
end
# CPU usage of the Docker host from its cgroup's cpu.stat. `usage_usec` is
# cumulative; two reads give the busy time, divided by elapsed wall time
# and the host's core count. Preferred over /proc/stat, which is not
# namespaced for a container nested in an LXC and leaks the physical node.
def cpu_from_host_cgroup(prev, now, limits)
usage = HostCgroup.cpu_usage_usec
return nil unless usage
cores = limits[:ncpu]
sample = { usage_usec: usage, at: now }
return { percent: nil, cores: cores, sample: sample } unless prev&.key?(:usage_usec)
elapsed = now - prev[:at]
percent =
if elapsed.positive? && cores&.positive?
busy = (usage - prev[:usage_usec]) / 1_000_000.0
((busy / (elapsed * cores)) * 100).round.clamp(0, 100)
end
{ percent: percent, cores: cores, sample: sample }
end
# Two /proc/stat reads give cumulative CPU jiffies; the delta is real CPU
# usage (what /proc/loadavg only approximates). Numerator and denominator
# come from the *same* file, so they stay consistent regardless of which
# lxcfs/cgroup overlays are in play.
def cpu_from_proc_stat(prev)
sample = read_proc_stat
return nil unless sample
return { percent: nil, cores: sample[:cores], sample: sample } unless prev&.key?(:total)
total_delta = sample[:total] - prev[:total]
percent =
if total_delta.positive?
idle_delta = sample[:idle] - prev[:idle]
(((total_delta - idle_delta).to_f / total_delta) * 100).round.clamp(0, 100)
end
{ percent: percent, cores: sample[:cores], sample: sample }
end
# The `cpu` aggregate line is always first in /proc/stat — break after
# parsing it instead of scanning the per-core lines that follow.
def read_proc_stat
File.foreach(File.join(proc_root, 'stat')) do |line|
name, *rest = line.split
return parse_cpu_totals(rest).merge(cores: Etc.nprocessors) if name == 'cpu'
end
nil
rescue SystemCallError
nil
end
# 'cpu' line columns: user nice system idle iowait irq softirq steal …
# "Idle" = idle + iowait (top/htop convention).
def parse_cpu_totals(nums)
parsed = nums.map(&:to_i)
{ total: parsed.sum, idle: parsed[3].to_i + parsed[4].to_i }
end
# macOS dev fallback (no /proc/stat). Two samples a second apart; the
# second one is a user/system/idle split, already normalised across all
# cores — matching the Linux path's semantics. `top -l 2` gives the same
# split, but walks the whole process table twice and burns close to a
# second of kernel CPU doing it, while iostat only sleeps. Dev only.
def cpu_from_iostat
raw, status = Open3.capture2e(*IOSTAT_COMMAND)
return nil unless status.success?
idle = iostat_idle_percent(raw)
cores = Etc.nprocessors
return nil if idle.nil? || cores.zero?
{ percent: (100 - idle).round.clamp(0, 100), cores: cores, sample: nil }
rescue SystemCallError
nil
end
# The disk columns vary with the number of disks, so count from the right:
# every data line ends with `us sy id` and the three load averages.
def iostat_idle_percent(raw)
fields = raw.lines.reverse.map(&:split).find { |f| f.size >= 6 && f.all? { |v| v.match?(/\A[\d.]+\z/) } }
fields&.fetch(-4)&.to_f
end
def read_ram_metrics(limits:)
ram_from_host_cgroup(limits) || ram_from_proc || { percent: nil, free: nil, total: nil }
end
# RAM usage of the Docker host: bytes in use (HostCgroup.memory_used) over
# the host's MemTotal. nil (→ /proc fallback) when the host cgroup is not
# mounted, e.g. on bare-metal/VM hosts — where /proc/meminfo is accurate.
def ram_from_host_cgroup(limits)
used = HostCgroup.memory_used
limit = limits[:mem_total]
return nil unless used && limit&.positive?
{ percent: (used.to_f / limit * 100).round.clamp(0, 100), free: limit - used, total: limit }
end
def ram_from_proc
total, available = mem_totals
return nil if total.nil? || total.zero? || available.nil?
{ percent: ((total - available).to_f / total * 100).round, free: available, total: total }
end
def mem_totals
mem_from_proc || mem_from_sysctl
end
def mem_from_proc
total = available = nil
File.foreach(File.join(proc_root, 'meminfo')) do |line|
case line
when /\AMemTotal:\s+(\d+)\s*kB/ then total = Regexp.last_match(1).to_i * 1024
when /\AMemAvailable:\s+(\d+)\s*kB/ then available = Regexp.last_match(1).to_i * 1024
end
break if total && available
end
total ? [total, available] : nil
rescue SystemCallError
nil
end
# macOS dev fallback. "Available" = free + inactive + speculative pages —
# closest equivalent to Linux's MemAvailable (reclaimable without swapping).
def mem_from_sysctl
total = capture_int('sysctl', '-n', 'hw.memsize')
return nil if total.nil? || total.zero?
available = sysctl_available_pages * Etc.sysconf(Etc::SC_PAGE_SIZE)
available.zero? ? nil : [total, available]
rescue SystemCallError
nil
end
def sysctl_available_pages
vm, status = Open3.capture2e('vm_stat')
return 0 unless status.success?
%w[free inactive speculative].sum do |kind|
line = vm.lines.find { |l| l.match?(/\APages #{kind}:/i) }
line.to_s[/\d+/].to_i
end
end
def capture_int(*command)
raw, status = Open3.capture2e(*command)
status.success? && raw.strip.match?(/\A\d+\z/) ? raw.strip.to_i : nil
rescue SystemCallError
nil
end
# MemTotal and NCPU as the Docker daemon reports them — i.e. the Docker
# host (Proxmox LXC, VM or bare metal), not the HELIOS container. These are
# the denominators for the host cgroup's usage figures.
def fetch_host_limits
Orchestration::Connection.configure!
info = Docker.info
{ mem_total: positive_int(info['MemTotal']), ncpu: positive_int(info['NCPU']) }
rescue StandardError
{ mem_total: nil, ncpu: nil }
end
def positive_int(value)
value.to_i if value.is_a?(Numeric) && value.positive?
end
end
end