Ctrl K
ComunidadGuías…

app/services/host_stats.rb

Rama develop · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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