puny-monitor 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []