server_stats 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.
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ServerStats
4
+ module Formatter
5
+ UNITS = %w[B KB MB GB TB PB].freeze
6
+
7
+ module_function
8
+
9
+ # Format bytes to human readable string (e.g. 1073741824 -> "1.00 GB")
10
+ def bytes_to_human(bytes, precision = 2)
11
+ return "0 B" if bytes.nil? || bytes <= 0
12
+
13
+ value = bytes.to_f
14
+ unit_index = 0
15
+
16
+ while value >= 1024.0 && unit_index < UNITS.size - 1
17
+ value /= 1024.0
18
+ unit_index += 1
19
+ end
20
+
21
+ if unit_index.zero?
22
+ "#{bytes} B"
23
+ else
24
+ "#{format("%.#{precision}f", value)} #{UNITS[unit_index]}"
25
+ end
26
+ end
27
+
28
+ # Format seconds to human readable string (e.g. 90061 -> "1d 1h 1m 1s")
29
+ def seconds_to_human(total_seconds)
30
+ return "0s" if total_seconds.nil? || total_seconds <= 0
31
+
32
+ secs = total_seconds.to_i
33
+ days = secs / 86_400
34
+ hours = (secs % 86_400) / 3600
35
+ minutes = (secs % 3600) / 60
36
+ seconds = secs % 60
37
+
38
+ parts = []
39
+ parts << "#{days}d" if days.positive?
40
+ parts << "#{hours}h" if hours.positive?
41
+ parts << "#{minutes}m" if minutes.positive?
42
+ parts << "#{seconds}s" if seconds.positive? || parts.empty?
43
+
44
+ parts.join(" ")
45
+ end
46
+
47
+ # Format percentage with standard precision
48
+ def format_percent(percent, precision = 2)
49
+ return "0.0%" if percent.nil?
50
+
51
+ "#{format("%.#{precision}f", percent)}%"
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "formatter"
6
+
7
+ module ServerStats
8
+ class CpuStats
9
+ attr_reader :cores, :physical_cores, :usage_percent, :load_average, :model
10
+
11
+ def initialize(cores:, usage_percent:, load_average: {}, physical_cores: nil, model: nil)
12
+ @cores = cores
13
+ @usage_percent = usage_percent ? usage_percent.round(2) : 0.0
14
+ @load_average = load_average || {}
15
+ @physical_cores = physical_cores || cores
16
+ @model = model
17
+ end
18
+
19
+ def to_h
20
+ {
21
+ cores: @cores,
22
+ physical_cores: @physical_cores,
23
+ usage_percent: @usage_percent,
24
+ load_average: @load_average,
25
+ model: @model
26
+ }
27
+ end
28
+
29
+ def to_json(*args)
30
+ to_h.to_json(*args)
31
+ end
32
+ end
33
+
34
+ class MemoryStats
35
+ attr_reader :total_bytes, :free_bytes, :available_bytes, :used_bytes,
36
+ :usage_percent, :details
37
+
38
+ def initialize(total_bytes:, free_bytes:, available_bytes: nil, used_bytes: nil, details: {})
39
+ @total_bytes = total_bytes.to_i
40
+ @free_bytes = free_bytes.to_i
41
+ @available_bytes = (available_bytes || free_bytes).to_i
42
+ @used_bytes = (used_bytes || [@total_bytes - @available_bytes, 0].max).to_i
43
+ @usage_percent = if @total_bytes.positive?
44
+ ((@used_bytes.to_f / @total_bytes) * 100.0).round(2)
45
+ else
46
+ 0.0
47
+ end
48
+ @details = details || {}
49
+ end
50
+
51
+ def total_human
52
+ Formatter.bytes_to_human(@total_bytes)
53
+ end
54
+
55
+ def free_human
56
+ Formatter.bytes_to_human(@free_bytes)
57
+ end
58
+
59
+ def available_human
60
+ Formatter.bytes_to_human(@available_bytes)
61
+ end
62
+
63
+ def used_human
64
+ Formatter.bytes_to_human(@used_bytes)
65
+ end
66
+
67
+ def to_h
68
+ {
69
+ total_bytes: @total_bytes,
70
+ free_bytes: @free_bytes,
71
+ available_bytes: @available_bytes,
72
+ used_bytes: @used_bytes,
73
+ usage_percent: @usage_percent,
74
+ total_human: total_human,
75
+ free_human: free_human,
76
+ available_human: available_human,
77
+ used_human: used_human,
78
+ details: @details
79
+ }
80
+ end
81
+
82
+ def to_json(*args)
83
+ to_h.to_json(*args)
84
+ end
85
+ end
86
+
87
+ class DiskStats
88
+ attr_reader :path, :filesystem, :total_bytes, :used_bytes, :free_bytes,
89
+ :available_bytes, :usage_percent
90
+
91
+ def initialize(path:, filesystem:, total_bytes:, used_bytes:, available_bytes:, free_bytes: nil)
92
+ @path = path
93
+ @filesystem = filesystem
94
+ @total_bytes = total_bytes.to_i
95
+ @used_bytes = used_bytes.to_i
96
+ @available_bytes = available_bytes.to_i
97
+ @free_bytes = (free_bytes || available_bytes).to_i
98
+ @usage_percent = if @total_bytes.positive?
99
+ ((@used_bytes.to_f / @total_bytes) * 100.0).round(2)
100
+ else
101
+ 0.0
102
+ end
103
+ end
104
+
105
+ def total_human
106
+ Formatter.bytes_to_human(@total_bytes)
107
+ end
108
+
109
+ def free_human
110
+ Formatter.bytes_to_human(@free_bytes)
111
+ end
112
+
113
+ def available_human
114
+ Formatter.bytes_to_human(@available_bytes)
115
+ end
116
+
117
+ def used_human
118
+ Formatter.bytes_to_human(@used_bytes)
119
+ end
120
+
121
+ def to_h
122
+ {
123
+ path: @path,
124
+ filesystem: @filesystem,
125
+ total_bytes: @total_bytes,
126
+ used_bytes: @used_bytes,
127
+ free_bytes: @free_bytes,
128
+ available_bytes: @available_bytes,
129
+ usage_percent: @usage_percent,
130
+ total_human: total_human,
131
+ used_human: used_human,
132
+ free_human: free_human,
133
+ available_human: available_human
134
+ }
135
+ end
136
+
137
+ def to_json(*args)
138
+ to_h.to_json(*args)
139
+ end
140
+ end
141
+
142
+ class SystemStats
143
+ attr_reader :os, :os_family, :os_version, :hostname, :architecture,
144
+ :kernel, :uptime_seconds
145
+
146
+ def initialize(os:, os_family:, os_version:, hostname:, architecture:, kernel:, uptime_seconds:)
147
+ @os = os
148
+ @os_family = os_family
149
+ @os_version = os_version
150
+ @hostname = hostname
151
+ @architecture = architecture
152
+ @kernel = kernel
153
+ @uptime_seconds = uptime_seconds.to_i
154
+ end
155
+
156
+ def uptime_human
157
+ Formatter.seconds_to_human(@uptime_seconds)
158
+ end
159
+
160
+ def to_h
161
+ {
162
+ os: @os,
163
+ os_family: @os_family,
164
+ os_version: @os_version,
165
+ hostname: @hostname,
166
+ architecture: @architecture,
167
+ kernel: @kernel,
168
+ uptime_seconds: @uptime_seconds,
169
+ uptime_human: uptime_human
170
+ }
171
+ end
172
+
173
+ def to_json(*args)
174
+ to_h.to_json(*args)
175
+ end
176
+ end
177
+
178
+ class Snapshot
179
+ attr_reader :cpu, :memory, :disk, :system, :timestamp
180
+
181
+ def initialize(cpu:, memory:, disk:, system:, timestamp: nil)
182
+ @cpu = cpu
183
+ @memory = memory
184
+ @disk = disk
185
+ @system = system
186
+ @timestamp = timestamp || Time.now.utc.iso8601
187
+ end
188
+
189
+ alias ram memory
190
+
191
+ def to_h
192
+ {
193
+ timestamp: @timestamp,
194
+ system: @system&.to_h,
195
+ cpu: @cpu&.to_h,
196
+ memory: @memory&.to_h,
197
+ disk: @disk&.to_h
198
+ }
199
+ end
200
+
201
+ def to_json(*args)
202
+ to_h.to_json(*args)
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module ServerStats
6
+ module OS
7
+ module_function
8
+
9
+ def host_os
10
+ RbConfig::CONFIG["host_os"]
11
+ end
12
+
13
+ def macos?
14
+ /darwin|mac os/i.match?(host_os)
15
+ end
16
+
17
+ def linux?
18
+ /linux/i.match?(host_os)
19
+ end
20
+
21
+ def type
22
+ if macos?
23
+ :macos
24
+ elsif linux?
25
+ :linux
26
+ else
27
+ :unknown
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "socket"
5
+ require "etc"
6
+ require "shellwords"
7
+ require_relative "../models"
8
+
9
+ module ServerStats
10
+ module Providers
11
+ class Base
12
+ # Execute command safely without raising exception
13
+ def execute(command)
14
+ stdout, _stderr, status = Open3.capture3(command)
15
+ return stdout.strip if status.success?
16
+
17
+ ""
18
+ rescue StandardError
19
+ ""
20
+ end
21
+
22
+ # Cross-platform disk stats using POSIX `df -P -k <path>`
23
+ def disk_stats(path = "/")
24
+ clean_path = path.to_s.strip
25
+ clean_path = "/" if clean_path.empty?
26
+
27
+ output = execute("df -P -k #{clean_path.shellescape}")
28
+ lines = output.lines.map(&:strip).reject(&:empty?)
29
+
30
+ if lines.size >= 2
31
+ # Skip header line, parse first data line
32
+ parts = lines[1].split(/\s+/)
33
+ filesystem = parts[0]
34
+ total_kb = parts[1].to_i
35
+ used_kb = parts[2].to_i
36
+ available_kb = parts[3].to_i
37
+
38
+ total_bytes = total_kb * 1024
39
+ used_bytes = used_kb * 1024
40
+ available_bytes = available_kb * 1024
41
+
42
+ DiskStats.new(
43
+ path: clean_path,
44
+ filesystem: filesystem,
45
+ total_bytes: total_bytes,
46
+ used_bytes: used_bytes,
47
+ available_bytes: available_bytes,
48
+ free_bytes: available_bytes
49
+ )
50
+ else
51
+ DiskStats.new(
52
+ path: clean_path,
53
+ filesystem: "unknown",
54
+ total_bytes: 0,
55
+ used_bytes: 0,
56
+ available_bytes: 0,
57
+ free_bytes: 0
58
+ )
59
+ end
60
+ end
61
+
62
+ # Logical CPU core count via Ruby's standard Etc
63
+ def cpu_cores
64
+ Etc.nprocessors
65
+ rescue StandardError
66
+ 1
67
+ end
68
+
69
+ # Hostname helper
70
+ def hostname
71
+ Socket.gethostname
72
+ rescue StandardError
73
+ "localhost"
74
+ end
75
+
76
+ # Abstract methods to be implemented by OS providers
77
+ def cpu(_interval = 0.5)
78
+ raise NotImplementedError
79
+ end
80
+
81
+ def memory
82
+ raise NotImplementedError
83
+ end
84
+
85
+ def disk(path = "/")
86
+ disk_stats(path)
87
+ end
88
+
89
+ def system
90
+ raise NotImplementedError
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module ServerStats
6
+ module Providers
7
+ class Linux < Base
8
+ attr_accessor :proc_meminfo_path, :proc_stat_path, :proc_loadavg_path,
9
+ :proc_uptime_path, :proc_cpuinfo_path, :os_release_path
10
+
11
+ def initialize(options = {})
12
+ super()
13
+ @proc_meminfo_path = options[:proc_meminfo_path] || "/proc/meminfo"
14
+ @proc_stat_path = options[:proc_stat_path] || "/proc/stat"
15
+ @proc_loadavg_path = options[:proc_loadavg_path] || "/proc/loadavg"
16
+ @proc_uptime_path = options[:proc_uptime_path] || "/proc/uptime"
17
+ @proc_cpuinfo_path = options[:proc_cpuinfo_path] || "/proc/cpuinfo"
18
+ @os_release_path = options[:os_release_path] || "/etc/os-release"
19
+ end
20
+
21
+ # Collect CPU statistics on Linux
22
+ def cpu(interval = 0.5)
23
+ cores = cpu_cores
24
+ model, physical_cores = parse_cpuinfo
25
+ load_avg = parse_load_average
26
+ usage_pct = calculate_cpu_usage(interval.to_f)
27
+
28
+ CpuStats.new(
29
+ cores: cores,
30
+ physical_cores: physical_cores || cores,
31
+ usage_percent: usage_pct,
32
+ load_average: load_avg,
33
+ model: model
34
+ )
35
+ end
36
+
37
+ # Collect Memory statistics on Linux
38
+ def memory
39
+ meminfo = parse_meminfo
40
+
41
+ total_bytes = (meminfo["MemTotal"] || 0) * 1024
42
+ free_bytes = (meminfo["MemFree"] || 0) * 1024
43
+ buffers_bytes = (meminfo["Buffers"] || 0) * 1024
44
+ cached_bytes = (meminfo["Cached"] || 0) * 1024
45
+ available_kb = meminfo["MemAvailable"]
46
+
47
+ available_bytes = if available_kb
48
+ available_kb * 1024
49
+ else
50
+ free_bytes + buffers_bytes + cached_bytes
51
+ end
52
+
53
+ used_bytes = [total_bytes - available_bytes, 0].max
54
+
55
+ MemoryStats.new(
56
+ total_bytes: total_bytes,
57
+ free_bytes: free_bytes,
58
+ available_bytes: available_bytes,
59
+ used_bytes: used_bytes,
60
+ details: {
61
+ buffers_bytes: buffers_bytes,
62
+ cached_bytes: cached_bytes,
63
+ swap_total_bytes: (meminfo["SwapTotal"] || 0) * 1024,
64
+ swap_free_bytes: (meminfo["SwapFree"] || 0) * 1024
65
+ }
66
+ )
67
+ end
68
+
69
+ # Collect System statistics on Linux
70
+ def system
71
+ os_name, os_ver = parse_os_release
72
+ arch = execute("uname -m")
73
+ arch = "unknown" if arch.empty?
74
+
75
+ kernel = execute("uname -r")
76
+ kernel = "unknown" if kernel.empty?
77
+
78
+ uptime_secs = parse_uptime
79
+
80
+ SystemStats.new(
81
+ os: os_name,
82
+ os_family: :linux,
83
+ os_version: os_ver,
84
+ hostname: hostname,
85
+ architecture: arch,
86
+ kernel: kernel,
87
+ uptime_seconds: uptime_secs
88
+ )
89
+ end
90
+
91
+ # Internal helper: parse /proc/meminfo
92
+ def parse_meminfo
93
+ return {} unless File.exist?(@proc_meminfo_path)
94
+
95
+ info = {}
96
+ File.foreach(@proc_meminfo_path) do |line|
97
+ if (m = line.match(/^(\w+):\s+(\d+)\s+kB/))
98
+ info[m[1]] = m[2].to_i
99
+ end
100
+ end
101
+ info
102
+ rescue StandardError
103
+ {}
104
+ end
105
+
106
+ # Internal helper: parse /proc/loadavg
107
+ def parse_load_average
108
+ return { one_minute: 0.0, five_minutes: 0.0, fifteen_minutes: 0.0 } unless File.exist?(@proc_loadavg_path)
109
+
110
+ parts = File.read(@proc_loadavg_path).strip.split(/\s+/)
111
+ {
112
+ one_minute: parts[0].to_f,
113
+ five_minutes: parts[1].to_f,
114
+ fifteen_minutes: parts[2].to_f
115
+ }
116
+ rescue StandardError
117
+ { one_minute: 0.0, five_minutes: 0.0, fifteen_minutes: 0.0 }
118
+ end
119
+
120
+ # Internal helper: parse /proc/uptime
121
+ def parse_uptime
122
+ return 0 unless File.exist?(@proc_uptime_path)
123
+
124
+ parts = File.read(@proc_uptime_path).strip.split(/\s+/)
125
+ parts[0].to_f.to_i
126
+ rescue StandardError
127
+ 0
128
+ end
129
+
130
+ # Internal helper: parse /proc/cpuinfo
131
+ def parse_cpuinfo
132
+ return [nil, nil] unless File.exist?(@proc_cpuinfo_path)
133
+
134
+ model = nil
135
+ physical_core_pairs = {}
136
+ current_phys_id = nil
137
+
138
+ File.foreach(@proc_cpuinfo_path) do |line|
139
+ line = line.strip
140
+ if line.start_with?("model name")
141
+ model ||= line.split(":", 2)[1]&.strip
142
+ elsif line.start_with?("physical id")
143
+ current_phys_id = line.split(":", 2)[1]&.strip
144
+ elsif line.start_with?("core id") && current_phys_id
145
+ core_id = line.split(":", 2)[1]&.strip
146
+ physical_core_pairs["#{current_phys_id}-#{core_id}"] = true
147
+ end
148
+ end
149
+
150
+ phys_count = physical_core_pairs.empty? ? nil : physical_core_pairs.size
151
+ [model, phys_count]
152
+ rescue StandardError
153
+ [nil, nil]
154
+ end
155
+
156
+ # Internal helper: read first CPU line from /proc/stat
157
+ def read_cpu_ticks
158
+ return nil unless File.exist?(@proc_stat_path)
159
+
160
+ File.foreach(@proc_stat_path) do |line|
161
+ if line.start_with?("cpu ")
162
+ # cpu user nice system idle iowait irq softirq steal guest guest_nice
163
+ fields = line.strip.split(/\s+/)[1..-1].map(&:to_i)
164
+ idle = (fields[3] || 0) + (fields[4] || 0) # idle + iowait
165
+ total = fields.inject(0, :+)
166
+ return { idle: idle, total: total }
167
+ end
168
+ end
169
+ nil
170
+ rescue StandardError
171
+ nil
172
+ end
173
+
174
+ # Internal helper: calculate CPU % using sample interval
175
+ def calculate_cpu_usage(interval)
176
+ sample1 = read_cpu_ticks
177
+ return 0.0 unless sample1
178
+
179
+ if interval > 0.0
180
+ sleep(interval)
181
+ sample2 = read_cpu_ticks
182
+ return 0.0 unless sample2
183
+
184
+ delta_total = sample2[:total] - sample1[:total]
185
+ delta_idle = sample2[:idle] - sample1[:idle]
186
+
187
+ if delta_total.positive?
188
+ usage = ((delta_total - delta_idle).to_f / delta_total) * 100.0
189
+ return [[usage, 0.0].max, 100.0].min
190
+ end
191
+ end
192
+
193
+ 0.0
194
+ end
195
+
196
+ # Internal helper: parse /etc/os-release
197
+ def parse_os_release
198
+ return ["Linux", "unknown"] unless File.exist?(@os_release_path)
199
+
200
+ env = {}
201
+ File.foreach(@os_release_path) do |line|
202
+ line = line.strip
203
+ next if line.empty? || line.start_with?("#")
204
+
205
+ if (m = line.match(/^([A-Za-z_]+)=(?:"([^"]*)"|'([^']*)'|(.*))$/))
206
+ key = m[1]
207
+ val = m[2] || m[3] || m[4]
208
+ env[key] = val
209
+ end
210
+ end
211
+
212
+ name = env["PRETTY_NAME"] || env["NAME"] || "Linux"
213
+ version = env["VERSION_ID"] || env["VERSION"] || "unknown"
214
+ [name, version]
215
+ rescue StandardError
216
+ ["Linux", "unknown"]
217
+ end
218
+ end
219
+ end
220
+ end