puny-monitor 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ef7da3ad71ee116621cfd11bc930e6ab5624aad481afaa024e6e09790916cde4
4
+ data.tar.gz: d590745c16a3ca947e1a1af8b91931cbf9daec21e9f1703a308b8be7e92e5bf5
5
+ SHA512:
6
+ metadata.gz: 345ed87c689aa7c153009f8a3f21fa33778ed97cf1db0a3e60948717ee0906886c4d9eb037a6b4602567354a34c89378f9fa57fb7b1f4a3596024f1580f2b687
7
+ data.tar.gz: af40c578d0c669f4216e0eaaa456154835819934c79f6befb40ea9200e888eba3854bef9594278644718ab242bc0c9804c862f8bdbca02d1df323ebeae6892e3
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.3.4
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Hans Schnedlitz
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.
data/README.md ADDED
@@ -0,0 +1,96 @@
1
+ <div align="center">
2
+
3
+ # Puny Monitor
4
+
5
+ <img alt="logo" src="/public/icon-512.png" width="256" height="auto">
6
+
7
+ ### A batteries-included monitoring tool for single hosts.
8
+
9
+ </div>
10
+
11
+ <p align="center">
12
+ <img alt="Screenshot of Puny Monitor" src="screenshot.png" width="90%">
13
+ </p>
14
+
15
+ ## Features
16
+
17
+ - Just enough data to be useful 🔍
18
+ - Install in 30 seconds 🏎️
19
+ - Perfect for [Kamal](https://kamal-deploy.org/) and other containerized setups 🐋
20
+
21
+
22
+ ## Getting Started
23
+
24
+ Puny Monitor works best with Docker. Run this command to check it out quickly:
25
+
26
+ ```
27
+ docker run --rm \
28
+ -v=/:/host:ro,rslave -v=puny-data:/puny-monitor/db \
29
+ -e HOST_PATH=/host \
30
+ -p 4567:4567 \
31
+ hschne/puny-monitor:latest
32
+ ```
33
+
34
+ Visit [localhost:4567](http://localhost:4567) to check your system data. To see how to deploy Puny Monitor in a production environment see [Deployment].
35
+
36
+ ## Deployment
37
+
38
+ Puny Monitor was made with [Kamal](https://kamal-deploy.org/) and [Ruby on Rails](https://rubyonrails.org/) in mind. It is recommended that you deploy it as an accessory to your application. Add the following lines to `config/deploy.yml`:
39
+
40
+ ```
41
+ accessories:
42
+ puny-monitor:
43
+ image: hschne/puny-monitor:latest
44
+ host: <host>
45
+ port: "127.0.0.1:4567:4567"
46
+ volumes:
47
+ - /:/host:ro,rslave
48
+ - puny-monitor-data:/puny-monitor/db
49
+
50
+ aliases:
51
+ add-puny-monitor-to-proxy: |
52
+ server exec docker exec kamal-proxy kamal-proxy deploy puny-monitor
53
+ --target "<your-service-name>-puny-monitor:4567"
54
+ --host "puny-monitor.<your-domain>"
55
+ --tls
56
+ ```
57
+
58
+ Then run `kamal-proxy` to point to Puny Monitor:
59
+
60
+ ```
61
+ kamal add-puny-monitor-to-proxy
62
+ ```
63
+
64
+ ### Other Deployment Options
65
+
66
+ You may install the Puny Monitor gem and run the application from the command line.
67
+
68
+ ```bash
69
+ gem install puny-monitor
70
+ # Run puny monitor on port 4567
71
+ puny-monitor
72
+ ```
73
+
74
+ ## Why Puny Monitor?
75
+
76
+ Puny Monitor aims to be a dead-simple, no-frills monitoring solution for single hosts. It provides enough information to be useful (and not a bit more) and avoids the complications and overhead that come with existing solutions.
77
+
78
+ To put it simply, Puny Monitor replicates [Digital Ocean's Monitoring](https://www.digitalocean.com/products/monitoring) but runs on any ol' VPS or metal server you might have lying around. It is the perfect solution for IndieHackers who use Rails & Kamal, but works beautifully for anyone that wants some useful monitoring quickly.
79
+
80
+ ## Development
81
+
82
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
83
+
84
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
85
+
86
+ ## Contributing
87
+
88
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/puny-monitor. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/puny-monitor/blob/main/CODE_OF_CONDUCT.md).
89
+
90
+ ## License
91
+
92
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
93
+
94
+ ## Code of Conduct
95
+
96
+ Everyone interacting in the Puny::Monitor project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/puny-monitor/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+
5
+ require_relative "config/environment"
6
+
7
+ require "sinatra/activerecord/rake"
8
+
9
+ if PunyMonitor::App.development?
10
+
11
+ require "minitest/test_task"
12
+ Minitest::TestTask.create
13
+
14
+ require "rubocop/rake_task"
15
+ RuboCop::RakeTask.new
16
+
17
+ namespace :docker do
18
+ desc "Build Puny Monitor Docker image"
19
+ task :build do
20
+ sh "docker build -t hschne/puny-monitor:latest ."
21
+ end
22
+
23
+ desc "Push Puny Monitor Docker image"
24
+ task :push do
25
+ sh "docker push -a hschne/puny-monitor"
26
+ end
27
+
28
+ desc "Run Docker container"
29
+ task :run do
30
+ `docker run --rm \
31
+ -v=/:/host:ro,rslave -v=puny-data:/puny-monitor/db \
32
+ -e ROOT_PATH=/host \
33
+ -p 80:4567 \
34
+ hschne/puny-monitor:latest`
35
+ end
36
+
37
+ desc "Run Docker interactive shell"
38
+ task :shell do
39
+ `docker run --rm \
40
+ -v=/:/host:ro,rslave -v=puny-data:/puny-monitor/db \
41
+ -e ROOT_PATH=/host \
42
+ -p 80:4567 \
43
+ -it \
44
+ hschne/puny-monitor:latest \
45
+ /bin/bash`
46
+ end
47
+ end
48
+
49
+ task default: %i[test rubocop]
50
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Bandwidth < ActiveRecord::Base
4
+ class << self
5
+ def average_usage(start_time, group_by)
6
+ [
7
+ { name: "Incoming Mbps", data: average_for_period(:incoming_mbps, start_time, group_by) },
8
+ { name: "Outgoing Mbps", data: average_for_period(:outgoing_mbps, start_time, group_by) }
9
+ ]
10
+ end
11
+
12
+ private
13
+
14
+ def average_for_period(column, start_time, group_by)
15
+ where(created_at: start_time..)
16
+ .group_by_period(group_by, :created_at)
17
+ .average(column)
18
+ .transform_values { |value| value&.round(2) }
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CpuLoad < ActiveRecord::Base
4
+ def self.average_load(start_time, end_time, group_by)
5
+ [
6
+ { name: "1 minute", data: average_for_period(:one_minute, start_time, end_time, group_by) },
7
+ { name: "5 minutes", data: average_for_period(:five_minutes, start_time, end_time, group_by) },
8
+ { name: "15 minutes", data: average_for_period(:fifteen_minutes, start_time, end_time, group_by) }
9
+ ]
10
+ end
11
+
12
+ def self.average_for_period(column, start_time, end_time, group_by)
13
+ where(created_at: start_time..end_time)
14
+ .group_by_period(group_by, :created_at)
15
+ .average(column)
16
+ .transform_values { |value| value&.round(2) }
17
+ end
18
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CpuUsage < ActiveRecord::Base
4
+ def self.average_usage(start_time, group_by)
5
+ where(created_at: start_time..)
6
+ .group_by_period(group_by, :created_at, expand_range: true)
7
+ .average(:used_percent)
8
+ .transform_values { |value| value&.round(2) }
9
+ end
10
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DiskIO < ActiveRecord::Base
4
+ class << self
5
+ def average_io(start_time, group_by)
6
+ [
7
+ { name: "Read MB/s", data: average_for_period(:read_mb_per_sec, start_time, group_by) },
8
+ { name: "Write MB/s", data: average_for_period(:write_mb_per_sec, start_time, group_by) }
9
+ ]
10
+ end
11
+
12
+ private
13
+
14
+ def average_for_period(column, start_time, group_by)
15
+ where(created_at: start_time..)
16
+ .group_by_period(group_by, :created_at)
17
+ .average(column)
18
+ .transform_values { |value| value&.round(2) }
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ class FilesystemUsage < ActiveRecord::Base
4
+ def self.average_usage(start_time, group_by)
5
+ where(created_at: start_time..)
6
+ .group_by_period(group_by, :created_at)
7
+ .average(:used_percent)
8
+ .transform_values { |value| value&.round(2) }
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ class MemoryUsage < ActiveRecord::Base
4
+ def self.average_usage(start_time, group_by)
5
+ where(created_at: start_time..)
6
+ .group_by_period(group_by, :created_at)
7
+ .average(:used_percent)
8
+ .transform_values { |value| value&.round(2) }
9
+ end
10
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rufus-scheduler"
4
+ require_relative "scheduler"
5
+ require_relative "../lib/system_utils"
6
+
7
+ module PunyMonitor
8
+ class App < Sinatra::Base
9
+ configure do
10
+ register Sinatra::ActiveRecordExtension
11
+ @scheduler = Rufus::Scheduler.new
12
+ @scheduler.every("5s") { Scheduler.collect_data }
13
+ @scheduler.every("1h") { Scheduler.cleanup_old_data }
14
+ end
15
+
16
+ configure :development do
17
+ register Sinatra::Reloader
18
+ end
19
+
20
+ set :erb, layout: :layout
21
+ set :public_folder, File.join(__dir__, "..", "public")
22
+ set :database_file, "../config/database.yml"
23
+
24
+ get "/" do
25
+ erb :index, locals: { params:, logo: }
26
+ end
27
+
28
+ get "/up" do
29
+ 200
30
+ end
31
+
32
+ get "/data/cpu_usage" do
33
+ content_type :json
34
+ CpuUsage.average_usage(start_time, group_by).to_json
35
+ end
36
+
37
+ get "/data/cpu_load" do
38
+ content_type :json
39
+ CpuLoad.average_load(start_time, Time.now, group_by).to_json
40
+ end
41
+
42
+ get "/data/memory_usage" do
43
+ content_type :json
44
+ MemoryUsage.average_usage(start_time, group_by).to_json
45
+ end
46
+
47
+ get "/data/filesystem_usage" do
48
+ content_type :json
49
+ FilesystemUsage.average_usage(start_time, group_by).to_json
50
+ end
51
+
52
+ get "/data/disk_io" do
53
+ content_type :json
54
+ DiskIO.average_io(start_time, group_by).to_json
55
+ end
56
+
57
+ get "/data/bandwidth" do
58
+ content_type :json
59
+ Bandwidth.average_usage(start_time, group_by).to_json
60
+ end
61
+
62
+ private
63
+
64
+ def logo
65
+ @logo ||= begin
66
+ file = File.open("public/icon.svg")
67
+ file.read
68
+ end
69
+ end
70
+
71
+ def duration
72
+ params[:duration] || "1d"
73
+ end
74
+
75
+ def start_time
76
+ case duration
77
+ when "1h" then 1.hour.ago
78
+ when "3d" then 3.days.ago
79
+ when "1w" then 1.week.ago
80
+ when "1m" then 1.month.ago
81
+ else 1.day.ago
82
+ end
83
+ end
84
+
85
+ def group_by
86
+ case duration
87
+ when "1h", "1d" then :minute
88
+ else :hour
89
+ end
90
+ end
91
+ end
92
+ end
data/app/scheduler.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lib/system_utils"
4
+ require "debug"
5
+
6
+ module PunyMonitor
7
+ class Scheduler
8
+ class << self
9
+ def collect_data
10
+ CpuUsage.create(used_percent: SystemUtils.cpu_usage_percent)
11
+ cpu_load_averages = SystemUtils.cpu_load_average
12
+ CpuLoad.create(one_minute: cpu_load_averages[0],
13
+ five_minutes: cpu_load_averages[1],
14
+ fifteen_minutes: cpu_load_averages[2])
15
+ MemoryUsage.create(used_percent: SystemUtils.memory_usage_percent)
16
+ FilesystemUsage.create(used_percent: SystemUtils.filesystem_usage_percent)
17
+
18
+ disk_io = SystemUtils.disk_io_stats
19
+ DiskIO.create(read_mb_per_sec: disk_io[:read_mb_per_sec], write_mb_per_sec: disk_io[:write_mb_per_sec])
20
+
21
+ bandwidth = SystemUtils.bandwidth_usage
22
+ Bandwidth.create(incoming_mbps: bandwidth[:incoming_mbps], outgoing_mbps: bandwidth[:outgoing_mbps])
23
+ end
24
+
25
+ def cleanup_old_data
26
+ one_month_ago = 1.month.ago
27
+ [CpuUsage, CpuLoad, MemoryUsage, FilesystemUsage, DiskIO, Bandwidth].each do |model|
28
+ model.where("created_at < ?", one_month_ago).delete_all
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,96 @@
1
+ <section class="controls">
2
+ <form action="/" method="get">
3
+ <select name="duration" onchange="this.form.submit()">
4
+ <option value="1h" <%= params[:duration] == '1h' ? 'selected' : '' %>>1 Hour</option>
5
+ <option
6
+ value="1d"
7
+ <%= params[:duration] == '1d' || params[:duration].nil? ? 'selected' : '' %>
8
+ >1 Day</option>
9
+ <option value="3d" <%= params[:duration] == '3d' ? 'selected' : '' %>>3 Days</option>
10
+ <option value="1w" <%= params[:duration] == '1w' ? 'selected' : '' %>>1 Week</option>
11
+ <option value="1m" <%= params[:duration] == '1m' ? 'selected' : '' %>>1 Month</option>
12
+ </select>
13
+ </form>
14
+ </section>
15
+
16
+ <section class="charts">
17
+
18
+ <div class="tile">
19
+ <h2>CPU Usage</h2>
20
+ <%= area_chart "/data/cpu_usage?duration=#{params[:duration] || "1d"}",
21
+ ytitle: "CPU Usage (%)",
22
+ min: 0,
23
+ max: 100,
24
+ library: {
25
+ title: {
26
+ text: "CPU Usage",
27
+ },
28
+ },
29
+ refresh: 5 %>
30
+ </div>
31
+
32
+ <div class="tile">
33
+ <h2>CPU Load</h2>
34
+ <%= line_chart "/data/cpu_load?duration=#{params[:duration] || "1d"}",
35
+ ytitle: "Load Average",
36
+ library: {
37
+ title: {
38
+ text: "Load Average",
39
+ },
40
+ },
41
+ refresh: 5 %>
42
+ </div>
43
+
44
+ <div class="tile">
45
+ <h2>Memory Usage</h2>
46
+ <%= area_chart "/data/memory_usage?duration=#{params[:duration] || "1d"}",
47
+ ytitle: "Memory Usage (%)",
48
+ min: 0,
49
+ max: 100,
50
+ library: {
51
+ title: {
52
+ text: "Memory Usage",
53
+ },
54
+ },
55
+ refresh: 5 %>
56
+ </div>
57
+
58
+ <div class="tile">
59
+ <h2>Filesystem Usage</h2>
60
+ <%= area_chart "/data/filesystem_usage?duration=#{params[:duration] || "1d"}",
61
+ ytitle: "Used Space (%)",
62
+ min: 0,
63
+ max: 100,
64
+ library: {
65
+ title: {
66
+ text: "Filesystem Usage",
67
+ },
68
+ },
69
+ refresh: 5 %>
70
+ </div>
71
+
72
+ <div class="tile">
73
+ <h2>Disk I/O</h2>
74
+ <%= area_chart "/data/disk_io?duration=#{params[:duration] || "1d"}",
75
+ ytitle: "MB/s",
76
+ library: {
77
+ title: {
78
+ text: "Disk I/O",
79
+ },
80
+ },
81
+ refresh: 5 %>
82
+ </div>
83
+
84
+ <div class="tile">
85
+ <h2>Bandwidth</h2>
86
+ <%= area_chart "/data/bandwidth?duration=#{params[:duration] || "1d"}",
87
+ ytitle: "Bandwidth (Mbps)",
88
+ library: {
89
+ title: {
90
+ text: "Bandwidth",
91
+ },
92
+ },
93
+ refresh: 5 %>
94
+ </div>
95
+
96
+ </section>
@@ -0,0 +1,46 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta http-equiv="X-UA-Compatible" content="ie=edge">
7
+ <title>Puny Monitor - Lightweight System Monitoring</title>
8
+ <meta
9
+ name="description"
10
+ content="Puny Monitor is a lightweight system monitoring tool that tracks CPU usage, memory usage, disk I/O, and network bandwidth."
11
+ >
12
+ <meta name="robots" content="noindex">
13
+ <link rel="icon" href="favicon.ico" sizes="32x32">
14
+ <link rel="icon" href="icon.svg" type="image/svg+xml">
15
+
16
+ <script
17
+ src="https://cdn.jsdelivr.net/npm/chartkick@4.2.0/dist/chartkick.min.js"
18
+ ></script>
19
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@3.7.1/dist/chart.min.js"></script>
20
+ <script
21
+ src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@2.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"
22
+ ></script>
23
+ <link
24
+ rel="preload"
25
+ href="fonts/Rubik.woff2"
26
+ as="font"
27
+ type="font/woff2"
28
+ crossorigin
29
+ >
30
+ <link href="style.css" rel="stylesheet">
31
+
32
+ </head>
33
+ <body>
34
+ <header>
35
+ <%= logo %>
36
+ <h1>Puny Monitor</h1>
37
+ </header>
38
+ <main>
39
+ <%= yield %>
40
+ </main>
41
+ <footer>
42
+ Found an issue or need a feature? File an issue on
43
+ <a href="https://github.com/hschne/puny-monitor">GitHub!</a>
44
+ </footer>
45
+ </body>
46
+ </html>
@@ -0,0 +1,16 @@
1
+ default: &default
2
+ adapter: sqlite3
3
+ pool: 5
4
+ timeout: 5000
5
+
6
+ development:
7
+ <<: *default
8
+ database: db/development.sqlite3
9
+
10
+ test:
11
+ <<: *default
12
+ database: db/test.sqlite3
13
+
14
+ production:
15
+ <<: *default
16
+ database: db/production.sqlite3
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ ENV["RACK_ENV"] ||= "development"
4
+
5
+ require "bundler/setup"
6
+ Bundler.require(:default, ENV.fetch("RACK_ENV", nil))
7
+
8
+ require "sinatra/contrib"
9
+ require "sinatra/activerecord"
10
+ require "rufus-scheduler"
11
+ require "groupdate"
12
+ require "chartkick"
13
+ require "sqlite3"
14
+ require "sys-filesystem"
15
+
16
+ Dir["#{__dir__}/initializers/**/*.rb"].each { |file| require file }
17
+ Dir["#{__dir__}/../app/**/*.rb"].each { |file| require file }
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ Chartkick.options = {
4
+ xtitle: "Time",
5
+ points: false,
6
+ curve: false
7
+ }
data/config.ru ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config/environment"
4
+ require_relative "app/puny_monitor"
5
+ run PunyMonitor::App
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddIndicesToCreatedAtColumns < ActiveRecord::Migration[6.1]
4
+ def change
5
+ add_index :bandwidths, :created_at
6
+ add_index :cpu_loads, :created_at
7
+ add_index :cpu_usages, :created_at
8
+ add_index :disk_ios, :created_at
9
+ add_index :filesystem_usages, :created_at
10
+ add_index :memory_usages, :created_at
11
+ end
12
+ end
data/db/schema.rb ADDED
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file is auto-generated from the current state of the database. Instead
4
+ # of editing this file, please use the migrations feature of Active Record to
5
+ # incrementally modify your database, and then regenerate this schema definition.
6
+ #
7
+ # This file is the source Rails uses to define your schema when running `bin/rails
8
+ # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
9
+ # be faster and is potentially less error prone than running all of your
10
+ # migrations from scratch. Old migrations may fail to apply correctly if those
11
+ # migrations use external dependencies or application code.
12
+ #
13
+ # It's strongly recommended that you check this file into your version control system.
14
+
15
+ ActiveRecord::Schema[7.2].define(version: 20_240_930_155_845) do
16
+ create_table "bandwidths", force: :cascade do |t|
17
+ t.float "incoming_mbps"
18
+ t.float "outgoing_mbps"
19
+ t.datetime "created_at", null: false
20
+ t.datetime "updated_at", null: false
21
+ t.index ["created_at"], name: "index_bandwidths_on_created_at"
22
+ end
23
+
24
+ create_table "cpu_loads", force: :cascade do |t|
25
+ t.float "one_minute"
26
+ t.float "five_minutes"
27
+ t.float "fifteen_minutes"
28
+ t.datetime "created_at", null: false
29
+ t.datetime "updated_at", null: false
30
+ t.index ["created_at"], name: "index_cpu_loads_on_created_at"
31
+ end
32
+
33
+ create_table "cpu_usages", force: :cascade do |t|
34
+ t.float "used_percent"
35
+ t.datetime "created_at", null: false
36
+ t.datetime "updated_at", null: false
37
+ t.index ["created_at"], name: "index_cpu_usages_on_created_at"
38
+ end
39
+
40
+ create_table "disk_ios", force: :cascade do |t|
41
+ t.float "read_mb_per_sec"
42
+ t.float "write_mb_per_sec"
43
+ t.datetime "created_at", null: false
44
+ t.datetime "updated_at", null: false
45
+ t.index ["created_at"], name: "index_disk_ios_on_created_at"
46
+ end
47
+
48
+ create_table "filesystem_usages", force: :cascade do |t|
49
+ t.float "used_percent"
50
+ t.datetime "created_at", null: false
51
+ t.datetime "updated_at", null: false
52
+ t.index ["created_at"], name: "index_filesystem_usages_on_created_at"
53
+ end
54
+
55
+ create_table "memory_usages", force: :cascade do |t|
56
+ t.float "used_percent"
57
+ t.datetime "created_at", null: false
58
+ t.datetime "updated_at", null: false
59
+ t.index ["created_at"], name: "index_memory_usages_on_created_at"
60
+ end
61
+ end
data/db/seeds.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Add seeds here
data/exe/puny-monitor ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ require "bundler/setup"
6
+ require "puny_monitor"
7
+
8
+ PunyMonitor::App.run!
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PunyMonitor
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../config/environment"
4
+ require_relative "../app/puny_monitor"
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sys/filesystem"
4
+
5
+ class SystemUtils
6
+ class << self
7
+ def cpu_usage_percent
8
+ prev_cpu = read_cpu_stat
9
+ sleep(1)
10
+ current_cpu = read_cpu_stat
11
+
12
+ prev_idle = prev_cpu[:idle] + prev_cpu[:iowait]
13
+ idle = current_cpu[:idle] + current_cpu[:iowait]
14
+
15
+ prev_non_idle = prev_cpu[:user] + prev_cpu[:nice] + prev_cpu[:system] +
16
+ prev_cpu[:irq] + prev_cpu[:softirq] + prev_cpu[:steal]
17
+ non_idle = current_cpu[:user] + current_cpu[:nice] + current_cpu[:system] +
18
+ current_cpu[:irq] + current_cpu[:softirq] + current_cpu[:steal]
19
+
20
+ prev_total = prev_idle + prev_non_idle
21
+ total = idle + non_idle
22
+
23
+ total_diff = total - prev_total
24
+ idle_diff = idle - prev_idle
25
+
26
+ cpu_percentage = ((total_diff - idle_diff).to_f / total_diff * 100).round(2)
27
+ [cpu_percentage, 100.0].min.round(2)
28
+ end
29
+
30
+ def cpu_load_average
31
+ File.read("#{proc_path}/loadavg").split.take(3)
32
+ .map(&:to_f)
33
+ .map { |value| value.round(2) }
34
+ end
35
+
36
+ def memory_usage_percent
37
+ mem_info = File.read("#{proc_path}/meminfo")
38
+ total = mem_info.match(/MemTotal:\s+(\d+)/)[1].to_f
39
+ free = mem_info.match(/MemFree:\s+(\d+)/)[1].to_f
40
+ buffers = mem_info.match(/Buffers:\s+(\d+)/)[1].to_f
41
+ cached = mem_info.match(/Cached:\s+(\d+)/)[1].to_f
42
+ used = total - free - buffers - cached
43
+ (used / total * 100).round(2)
44
+ end
45
+
46
+ def filesystem_usage_percent
47
+ stat = Sys::Filesystem.stat(root_path)
48
+ total_blocks = stat.blocks
49
+ available_blocks = stat.blocks_available
50
+ used_blocks = total_blocks - available_blocks
51
+ used_percent = (used_blocks.to_f / total_blocks * 100).round(2)
52
+ [used_percent, 100.0].min.round(2)
53
+ end
54
+
55
+ def disk_io_stats
56
+ prev_stats = read_disk_stats
57
+ sleep(1)
58
+ curr_stats = read_disk_stats
59
+
60
+ read_sectors = curr_stats[:read_sectors] - prev_stats[:read_sectors]
61
+ write_sectors = curr_stats[:write_sectors] - prev_stats[:write_sectors]
62
+
63
+ sector_size = 512
64
+ read_mb_per_sec = (read_sectors * sector_size / 1_048_576.0).round(2)
65
+ write_mb_per_sec = (write_sectors * sector_size / 1_048_576.0).round(2)
66
+
67
+ {
68
+ read_mb_per_sec:,
69
+ write_mb_per_sec:
70
+ }
71
+ end
72
+
73
+ def bandwidth_usage
74
+ prev_stats = read_network_stats
75
+ sleep(1)
76
+ curr_stats = read_network_stats
77
+
78
+ incoming_bytes = curr_stats[:rx_bytes] - prev_stats[:rx_bytes]
79
+ outgoing_bytes = curr_stats[:tx_bytes] - prev_stats[:tx_bytes]
80
+
81
+ bytes_to_mbits = 8.0 / 1_000_000 # Convert bytes to megabits
82
+ {
83
+ incoming_mbps: (incoming_bytes * bytes_to_mbits).round(2),
84
+ outgoing_mbps: (outgoing_bytes * bytes_to_mbits).round(2)
85
+ }
86
+ end
87
+
88
+ private
89
+
90
+ def proc_path
91
+ File.join(root_path, "proc")
92
+ end
93
+
94
+ def root_path
95
+ ENV.fetch("ROOT_PATH", "/")
96
+ end
97
+
98
+ def read_cpu_stat
99
+ cpu_stats = File.read("#{proc_path}/stat").lines.first.split(/\s+/)
100
+ {
101
+ user: cpu_stats[1].to_i,
102
+ nice: cpu_stats[2].to_i,
103
+ system: cpu_stats[3].to_i,
104
+ idle: cpu_stats[4].to_i,
105
+ iowait: cpu_stats[5].to_i,
106
+ irq: cpu_stats[6].to_i,
107
+ softirq: cpu_stats[7].to_i,
108
+ steal: cpu_stats[8].to_i
109
+ }
110
+ end
111
+
112
+ def read_disk_stats
113
+ primary_disk = File.read("#{proc_path}/partitions")
114
+ .lines
115
+ .drop(2)
116
+ .first
117
+ .split
118
+ .last
119
+
120
+ stats = File.read("#{proc_path}/diskstats")
121
+ .lines
122
+ .map(&:split)
123
+ .find { |line| line[2] == primary_disk }
124
+
125
+ {
126
+ read_sectors: stats[5].to_i,
127
+ write_sectors: stats[9].to_i
128
+ }
129
+ end
130
+
131
+ def read_network_stats
132
+ primary_interface = File.read("#{proc_path}/net/route")
133
+ .lines
134
+ .drop(1)
135
+ .find { |line| line.split[1] == "00000000" }
136
+ &.split&.first
137
+ stats = File.read("#{proc_path}/net/dev")
138
+ .lines
139
+ .map(&:split)
140
+ .find { |line| line[0].chomp(":") == primary_interface }
141
+
142
+ {
143
+ rx_bytes: stats[1].to_i,
144
+ tx_bytes: stats[9].to_i
145
+ }
146
+ end
147
+ end
148
+ end
Binary file
Binary file
Binary file
data/public/icon.svg ADDED
@@ -0,0 +1,23 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg class="logo" version="1.1" viewBox="0 0 215 160" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
3
+ <style>
4
+ g {
5
+ stroke: oklch(10.91% 0.003 286.03);
6
+ }
7
+ @media (prefers-color-scheme: dark) {
8
+ g {
9
+ stroke:oklch(98.14% 0.001 286.38);
10
+ }
11
+ }
12
+ </style>
13
+ <g transform="translate(2.5 -37.876)" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit=".8">
14
+ <circle cx="30.662" cy="150.82" r="8" stroke-width="4.5" />
15
+ <circle cx="64.516" cy="101.87" r="8" stroke-width="4.5" />
16
+ <circle cx="133.07" cy="132.66" r="8" stroke-width="4.5" />
17
+ <circle cx="179.32" cy="78.569" r="8" stroke-width="4.5" />
18
+ <path d="m41.112 135.88 13.321-19.699" stroke-width="6" />
19
+ <path d="m81.341 109.81 34.616 16.25" stroke-width="6" />
20
+ <path d="m145.39 119.63 22.239-27.032" stroke-width="6" />
21
+ <rect x="7.5345" y="46.187" width="194.93" height="143.38" ry="7.6132" stroke-width="8" />
22
+ </g>
23
+ </svg>
@@ -0,0 +1,4 @@
1
+ import Chartkick from "chartkick";
2
+ import Chart from "chart.js";
3
+
4
+ Chartkick.use(Chart);
data/public/style.css ADDED
@@ -0,0 +1,161 @@
1
+ @font-face {
2
+ font-family: "Rubik";
3
+ src: url("fonts/Rubik.woff2") format("woff2");
4
+ font-weight: 1 999;
5
+ font-display: optional;
6
+ }
7
+
8
+ :root {
9
+ --font-base-size: 1rem;
10
+ --font-scale-ratio: 1.3;
11
+
12
+ --color-grey-50: oklch(98.14% 0.001 286.38);
13
+ --color-grey-100: oklch(91.34% 0.003 286.35);
14
+ --color-grey-200: oklch(83.09% 0.006 286.28);
15
+ --color-grey-300: oklch(73.99% 0.009 286.19);
16
+ --color-grey-400: oklch(65.59% 0.012 286.07);
17
+ --color-grey-500: oklch(55.86% 0.014 285.95);
18
+ --color-grey-600: oklch(47.36% 0.011 285.97);
19
+ --color-grey-700: oklch(37.33% 0.008 285.98);
20
+ --color-grey-800: oklch(27.39% 0.005 286.03);
21
+ --color-grey-900: oklch(15.95% 0.002 286.16);
22
+ --color-grey-950: oklch(10.91% 0.003 286.03);
23
+
24
+ --color-text: var(--color-grey-900);
25
+ --color-background: var(--color-grey-50);
26
+ --color-link-active: var(--color-grey-700);
27
+ --color-link-hover: var(--color-grey-600);
28
+ --color-border: var(--color-grey-400);
29
+
30
+ --font-size-sm: calc(var(--font-base-size) / var(--font-scale-ratio));
31
+ --font-size-md: var(--font-base-size);
32
+ --font-size-lg: calc(var(--font-size-md) * var(--font-scale-ratio));
33
+ --font-size-xl: calc(var(--font-size-lg) * var(--font-scale-ratio));
34
+ --font-size-2xl: calc(var(--font-size-xl) * var(--font-scale-ratio));
35
+ --font-size-3xl: calc(var(--font-size-2xl) * var(--font-scale-ratio));
36
+ --font-size-4xl: calc(var(--font-size-2xl) * var(--font-scale-ratio));
37
+
38
+ --space-unit: 1em;
39
+ --space-xxs: calc(0.25 * var(--space-unit));
40
+ --space-xs: calc(0.5 * var(--space-unit));
41
+ --space-sm: calc(0.75 * var(--space-unit));
42
+ --space-md: calc(1.25 * var(--space-unit));
43
+ --space-lg: calc(1.5 * var(--space-unit));
44
+ --space-xl: calc(2 * var(--space-unit));
45
+ --space-2xl: calc(3.25 * var(--space-unit));
46
+ --space-3xl: calc(5.25 * var(--space-unit));
47
+
48
+ --screen--size-md: 55rem;
49
+ --screen--size-lg: 100rem;
50
+ }
51
+
52
+ * {
53
+ margin: 0;
54
+ padding: 0;
55
+ box-sizing: border-box;
56
+ }
57
+
58
+ body {
59
+ margin: 0 auto;
60
+ font-family: "Rubik", sans-serif;
61
+ font-optical-sizing: auto;
62
+ font-style: normal;
63
+ color: var(--color-text);
64
+ background-color: var(--color-background);
65
+ max-width: var(--screen--size-lg);
66
+ padding: 0 var(--space-md);
67
+ }
68
+
69
+ header {
70
+ display: flex;
71
+ flex-direction: row;
72
+ gap: var(--space-md);
73
+ padding: var(--space-md) 0;
74
+
75
+ .logo {
76
+ width: 3rem;
77
+ }
78
+ }
79
+
80
+ main {
81
+ padding: var(--space-md) 0;
82
+ }
83
+
84
+ h1 {
85
+ font-size: var(--font-size-2xl);
86
+ }
87
+
88
+ a:visited {
89
+ color: var(--color-link-hover);
90
+ }
91
+
92
+ footer {
93
+ padding: var(--space-sm) 0;
94
+ }
95
+
96
+ .charts {
97
+ display: grid;
98
+ grid-template-columns: 1fr;
99
+ gap: var(--space-md);
100
+ }
101
+
102
+ .tile {
103
+ display: flex;
104
+ flex-direction: column;
105
+ gap: var(--space-sm);
106
+ padding: var(--space-md);
107
+ border: solid 1px var(--color-border);
108
+ border-radius: 10px;
109
+ height: 20rem;
110
+ }
111
+
112
+ .controls {
113
+ display: flex;
114
+ justify-content: flex-end;
115
+ align-items: center;
116
+ gap: var(--space-md);
117
+ margin-bottom: var(--space-sm);
118
+
119
+ select {
120
+ font-size: var(--font-size-md);
121
+ padding: var(--space-xxs) var(--space-xs);
122
+ border: 1px solid var(--color-border);
123
+ border-radius: 5px;
124
+ background-color: var(--color-background);
125
+ color: var(--color-text);
126
+ cursor: pointer;
127
+ transition:
128
+ border-color 0.3s,
129
+ box-shadow 0.3s;
130
+ }
131
+
132
+ select:hover {
133
+ border-color: var(--color-link-hover);
134
+ }
135
+
136
+ select:focus {
137
+ outline: none;
138
+ border-color: var(--color-link-active);
139
+ box-shadow: 0 0 0 2px var(--color-border);
140
+ }
141
+ }
142
+
143
+ @media (min-width: 55rem) {
144
+ .charts {
145
+ grid-template-columns: repeat(2, 1fr);
146
+ }
147
+
148
+ .tile {
149
+ height: 25rem;
150
+ }
151
+ }
152
+
153
+ @media (prefers-color-scheme: dark) {
154
+ :root {
155
+ --color-text: var(--color-grey-50);
156
+ --color-background: var(--color-grey-900);
157
+ --color-link-active: var(--color-grey-300);
158
+ --color-link-hover: var(--color-grey-400);
159
+ --color-border: var(--color-grey-600);
160
+ }
161
+ }
metadata ADDED
@@ -0,0 +1,206 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: puny-monitor
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Hans Schnedlitz
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-10-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: chartkick
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: groupdate
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '6.4'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '6.4'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rackup
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '2.1'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '2.1'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rufus-scheduler
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.9'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.9'
69
+ - !ruby/object:Gem::Dependency
70
+ name: sinatra
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '4.0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '4.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: sinatra-activerecord
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '2.0'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '2.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: sinatra-contrib
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '4.0'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '4.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: sqlite3
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '2.0'
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '2.0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: sys-filesystem
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '1.4'
132
+ type: :runtime
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '1.4'
139
+ description:
140
+ email:
141
+ - hello@hansschnedlitz.com
142
+ executables:
143
+ - puny-monitor
144
+ extensions: []
145
+ extra_rdoc_files: []
146
+ files:
147
+ - ".ruby-version"
148
+ - LICENSE
149
+ - README.md
150
+ - Rakefile
151
+ - app/models/bandwidth.rb
152
+ - app/models/cpu_load.rb
153
+ - app/models/cpu_usage.rb
154
+ - app/models/disk_io.rb
155
+ - app/models/filesystem_usage.rb
156
+ - app/models/memory_usage.rb
157
+ - app/puny_monitor.rb
158
+ - app/scheduler.rb
159
+ - app/views/index.erb
160
+ - app/views/layout.erb
161
+ - config.ru
162
+ - config/database.yml
163
+ - config/environment.rb
164
+ - config/initializers/chartkick.rb
165
+ - db/migrate/20231023000000_add_indices_to_created_at_columns.rb
166
+ - db/schema.rb
167
+ - db/seeds.rb
168
+ - exe/puny-monitor
169
+ - lib/puny_monitor.rb
170
+ - lib/puny_monitor/version.rb
171
+ - lib/system_utils.rb
172
+ - public/favicon.ico
173
+ - public/fonts/Rubik.woff2
174
+ - public/icon-512.png
175
+ - public/icon.svg
176
+ - public/javascript/index.js
177
+ - public/style.css
178
+ homepage: https://github.com/hschne/puny-monitor
179
+ licenses:
180
+ - MIT
181
+ metadata:
182
+ allowed_push_host: https://rubygems.org
183
+ homepage_uri: https://github.com/hschne/puny-monitor
184
+ source_code_uri: https://github.com/hschne/puny-monitor
185
+ changelog_uri: https://github.com/hschne/puny-monitor/CHANGELOG
186
+ rubygems_mfa_required: 'true'
187
+ post_install_message:
188
+ rdoc_options: []
189
+ require_paths:
190
+ - lib
191
+ required_ruby_version: !ruby/object:Gem::Requirement
192
+ requirements:
193
+ - - ">="
194
+ - !ruby/object:Gem::Version
195
+ version: 3.1.0
196
+ required_rubygems_version: !ruby/object:Gem::Requirement
197
+ requirements:
198
+ - - ">="
199
+ - !ruby/object:Gem::Version
200
+ version: '0'
201
+ requirements: []
202
+ rubygems_version: 3.5.21
203
+ signing_key:
204
+ specification_version: 4
205
+ summary: A batteries-included monitoring tool for single hosts. Works great with Kamal.
206
+ test_files: []