tnw 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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +56 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/stylesheets/tnw/application.css +183 -0
  6. data/app/controllers/tnw/application_controller.rb +32 -0
  7. data/app/controllers/tnw/dashboard_controller.rb +14 -0
  8. data/app/controllers/tnw/internal/metric_samples_controller.rb +62 -0
  9. data/app/helpers/tnw/application_helper.rb +21 -0
  10. data/app/jobs/tnw/application_job.rb +4 -0
  11. data/app/jobs/tnw/metrics/retention_job.rb +10 -0
  12. data/app/models/tnw/application_record.rb +5 -0
  13. data/app/models/tnw/metric_sample.rb +16 -0
  14. data/app/models/tnw/server.rb +16 -0
  15. data/app/presenters/tnw/server_metric_status.rb +32 -0
  16. data/app/services/tnw/metric_samples/ingest.rb +160 -0
  17. data/app/views/layouts/tnw/application.html.erb +15 -0
  18. data/app/views/tnw/dashboard/index.html.erb +62 -0
  19. data/config/routes.rb +6 -0
  20. data/db/migrate/20260711000001_create_tnw_servers.rb +15 -0
  21. data/db/migrate/20260711000002_create_tnw_metric_samples.rb +33 -0
  22. data/lib/generators/tnw/install/install_generator.rb +44 -0
  23. data/lib/generators/tnw/install/templates/tnw.rb +12 -0
  24. data/lib/tasks/tnw_tasks.rake +4 -0
  25. data/lib/tnw/configuration.rb +95 -0
  26. data/lib/tnw/engine.rb +5 -0
  27. data/lib/tnw/metrics/ingestion_error.rb +12 -0
  28. data/lib/tnw/metrics/parse_error.rb +6 -0
  29. data/lib/tnw/metrics/parsers/df.rb +37 -0
  30. data/lib/tnw/metrics/parsers/proc_loadavg.rb +20 -0
  31. data/lib/tnw/metrics/parsers/proc_meminfo.rb +37 -0
  32. data/lib/tnw/metrics/parsers/proc_stat.rb +37 -0
  33. data/lib/tnw/metrics/replay_guard.rb +17 -0
  34. data/lib/tnw/metrics/request_verifier.rb +59 -0
  35. data/lib/tnw/metrics.rb +14 -0
  36. data/lib/tnw/version.rb +3 -0
  37. data/lib/tnw.rb +21 -0
  38. metadata +97 -0
@@ -0,0 +1,62 @@
1
+ <main>
2
+ <header class="tnw-page-header">
3
+ <div>
4
+ <p class="tnw-eyebrow">The Night Watch</p>
5
+ <h1>Server health</h1>
6
+ </div>
7
+ <span class="tnw-server-count"><%= pluralize(@server_metrics.size, "server") %></span>
8
+ </header>
9
+
10
+ <% if @server_metrics.empty? %>
11
+ <section class="tnw-empty-state">
12
+ <h2>No servers registered</h2>
13
+ </section>
14
+ <% else %>
15
+ <section class="tnw-server-grid">
16
+ <% @server_metrics.each do |server_metrics| %>
17
+ <% sample = server_metrics.sample %>
18
+ <article class="tnw-server" data-status="<%= server_metrics.status %>">
19
+ <header class="tnw-server-header">
20
+ <div>
21
+ <h2><%= server_metrics.server.display_name %></h2>
22
+ <p><%= server_metrics.server.identifier %></p>
23
+ </div>
24
+ <span class="tnw-status"><%= server_metrics.status_label %></span>
25
+ </header>
26
+
27
+ <dl class="tnw-metrics">
28
+ <div>
29
+ <dt>CPU</dt>
30
+ <dd><%= metric_percentage(sample&.cpu_percent) %></dd>
31
+ </div>
32
+ <div>
33
+ <dt>Memory used</dt>
34
+ <dd><%= metric_bytes(sample&.memory_used_bytes) %></dd>
35
+ </div>
36
+ <div>
37
+ <dt>Disk used</dt>
38
+ <dd><%= metric_bytes(sample&.disk_used_bytes) %></dd>
39
+ </div>
40
+ <div>
41
+ <dt>Load 1m</dt>
42
+ <dd><%= metric_decimal(sample&.load_1m) %></dd>
43
+ </div>
44
+ </dl>
45
+
46
+ <footer class="tnw-server-footer">
47
+ <% if sample %>
48
+ Last received <time datetime="<%= sample.received_at.iso8601 %>"><%= time_ago_in_words(sample.received_at) %> ago</time>
49
+ <% else %>
50
+ Waiting for first sample
51
+ <% end %>
52
+ <% if server_metrics.server.last_ingestion_error_code.present? %>
53
+ <span>Error: <%= server_metrics.server.last_ingestion_error_code.humanize %></span>
54
+ <% elsif sample&.parse_errors&.any? %>
55
+ <span>Partial: <%= sample.parse_errors.map(&:humanize).join(", ") %></span>
56
+ <% end %>
57
+ </footer>
58
+ </article>
59
+ <% end %>
60
+ </section>
61
+ <% end %>
62
+ </main>
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ Tnw::Engine.routes.draw do
2
+ root "dashboard#index"
3
+ namespace :internal do
4
+ resources :metric_samples, only: :create
5
+ end
6
+ end
@@ -0,0 +1,15 @@
1
+ class CreateTnwServers < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :tnw_servers do |t|
4
+ t.string :identifier, null: false
5
+ t.string :name
6
+ t.boolean :enabled, null: false, default: true
7
+ t.string :last_ingestion_status
8
+ t.string :last_ingestion_error_code
9
+ t.datetime :last_ingestion_at
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :tnw_servers, :identifier, unique: true
14
+ end
15
+ end
@@ -0,0 +1,33 @@
1
+ class CreateTnwMetricSamples < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :tnw_metric_samples do |t|
4
+ t.references :server, null: false, foreign_key: { to_table: :tnw_servers }
5
+ t.datetime :collected_at, null: false
6
+ t.datetime :received_at, null: false
7
+ t.decimal :cpu_percent, precision: 5, scale: 2
8
+ t.decimal :load_1m, precision: 10, scale: 3
9
+ t.decimal :load_5m, precision: 10, scale: 3
10
+ t.decimal :load_15m, precision: 10, scale: 3
11
+ t.bigint :memory_total_bytes
12
+ t.bigint :memory_used_bytes
13
+ t.bigint :memory_available_bytes
14
+ t.bigint :disk_total_bytes
15
+ t.bigint :disk_used_bytes
16
+ t.bigint :disk_available_bytes
17
+ t.string :disk_mount
18
+ t.string :collector_name, null: false
19
+ t.string :collector_version, null: false
20
+ t.string :parser_version, null: false
21
+ t.string :source_ip
22
+ t.string :sample_uid, null: false
23
+ t.json :parse_errors, null: false, default: []
24
+ t.json :validation_errors, null: false, default: []
25
+ t.timestamps
26
+ end
27
+
28
+ add_index :tnw_metric_samples, %i[server_id collected_at]
29
+ add_index :tnw_metric_samples, %i[server_id sample_uid], unique: true
30
+ add_index :tnw_metric_samples, :collected_at
31
+ add_index :tnw_metric_samples, :received_at
32
+ end
33
+ end
@@ -0,0 +1,44 @@
1
+ require "rails/generators"
2
+
3
+ module Tnw
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ def create_initializer
9
+ template "tnw.rb", "config/initializers/tnw.rb"
10
+ end
11
+
12
+ def mount_engine
13
+ routes_path = destination_root_path.join("config/routes.rb")
14
+ return if routes_path.exist? && routes_path.read.include?("mount Tnw::Engine")
15
+
16
+ route 'mount Tnw::Engine => "/tnw"'
17
+ end
18
+
19
+ def copy_migrations
20
+ migration_sources.each_with_index do |source, index|
21
+ migration_name = File.basename(source).sub(/\A\d+_/, "")
22
+ next if migration_installed?(migration_name)
23
+
24
+ timestamp = (Time.now.utc + index.seconds).strftime("%Y%m%d%H%M%S")
25
+ copy_file source, "db/migrate/#{timestamp}_#{migration_name}"
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def migration_sources
32
+ Dir[Tnw::Engine.root.join("db/migrate/*.rb")].sort
33
+ end
34
+
35
+ def migration_installed?(migration_name)
36
+ Dir[destination_root_path.join("db/migrate/*_#{migration_name}")].any?
37
+ end
38
+
39
+ def destination_root_path
40
+ Pathname(destination_root)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,12 @@
1
+ Tnw.configure do |config|
2
+ # Required outside development. Connect this to the host application's
3
+ # existing administrator authentication method.
4
+ # config.authenticate_with do |controller|
5
+ # controller.authenticate_admin!
6
+ # end
7
+
8
+ config.metrics_sample_interval = 5.seconds
9
+ config.metrics_retention = 7.days
10
+ config.metrics_ingestion_token = ENV["TNW_METRICS_INGESTION_TOKEN"]
11
+ config.metrics_accepted_clock_skew = 30.seconds
12
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :tnw do
3
+ # # Task goes here
4
+ # end
@@ -0,0 +1,95 @@
1
+ require "active_support/core_ext/numeric/time"
2
+
3
+ module Tnw
4
+ class Configuration
5
+ attr_reader :metrics_sample_interval,
6
+ :metrics_retention,
7
+ :metrics_ingestion_token,
8
+ :metrics_accepted_clock_skew,
9
+ :authentication_handler,
10
+ :basic_auth_username,
11
+ :basic_auth_password
12
+
13
+ def initialize
14
+ @metrics_sample_interval = 5.seconds
15
+ @metrics_retention = 7.days
16
+ @metrics_ingestion_token = nil
17
+ @metrics_accepted_clock_skew = 30.seconds
18
+ @authentication_handler = nil
19
+ @basic_auth_username = nil
20
+ @basic_auth_password = nil
21
+ end
22
+
23
+ def metrics_sample_interval=(interval)
24
+ @metrics_sample_interval = positive_duration!(interval, :metrics_sample_interval)
25
+ end
26
+
27
+ def metrics_retention=(retention)
28
+ @metrics_retention = positive_duration!(retention, :metrics_retention)
29
+ end
30
+
31
+ def metrics_ingestion_token=(token)
32
+ unless token.nil? || (token.is_a?(String) && !token.strip.empty?)
33
+ raise ArgumentError, "metrics_ingestion_token must be a non-blank String or nil"
34
+ end
35
+
36
+ @metrics_ingestion_token = token
37
+ end
38
+
39
+ def metrics_accepted_clock_skew=(clock_skew)
40
+ @metrics_accepted_clock_skew = positive_duration!(clock_skew, :metrics_accepted_clock_skew)
41
+ end
42
+
43
+ def authenticate_with(&block)
44
+ raise ArgumentError, "authenticate_with requires a block" unless block
45
+
46
+ @authentication_handler = block
47
+ end
48
+
49
+ def basic_auth_username=(username)
50
+ @basic_auth_username = optional_non_blank_string!(username, :basic_auth_username)
51
+ end
52
+
53
+ def basic_auth_password=(password)
54
+ @basic_auth_password = optional_non_blank_string!(password, :basic_auth_password)
55
+ end
56
+
57
+ def basic_auth_configured?
58
+ basic_auth_username && basic_auth_password
59
+ end
60
+
61
+ def inspect
62
+ token = metrics_ingestion_token.nil? ? nil : "[FILTERED]"
63
+ password = basic_auth_password.nil? ? nil : "[FILTERED]"
64
+
65
+ "#<#{self.class.name} " \
66
+ "metrics_sample_interval=#{metrics_sample_interval.inspect} " \
67
+ "metrics_retention=#{metrics_retention.inspect} " \
68
+ "metrics_ingestion_token=#{token.inspect} " \
69
+ "metrics_accepted_clock_skew=#{metrics_accepted_clock_skew.inspect} " \
70
+ "authentication_handler=#{authentication_handler.inspect} " \
71
+ "basic_auth_username=#{basic_auth_username.inspect} " \
72
+ "basic_auth_password=#{password.inspect}>"
73
+ end
74
+
75
+ private
76
+
77
+ def positive_duration!(value, name)
78
+ seconds = value.respond_to?(:in_seconds) ? value.in_seconds : value
79
+
80
+ unless seconds.is_a?(Numeric) && seconds.positive? && seconds.finite?
81
+ raise ArgumentError, "#{name} must be positive"
82
+ end
83
+
84
+ value
85
+ end
86
+
87
+ def optional_non_blank_string!(value, name)
88
+ unless value.nil? || (value.is_a?(String) && !value.strip.empty?)
89
+ raise ArgumentError, "#{name} must be a non-blank String or nil"
90
+ end
91
+
92
+ value
93
+ end
94
+ end
95
+ end
data/lib/tnw/engine.rb ADDED
@@ -0,0 +1,5 @@
1
+ module Tnw
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Tnw
4
+ end
5
+ end
@@ -0,0 +1,12 @@
1
+ module Tnw
2
+ module Metrics
3
+ class IngestionError < StandardError
4
+ attr_reader :code
5
+
6
+ def initialize(code, message)
7
+ @code = code.to_s
8
+ super(message)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,6 @@
1
+ module Tnw
2
+ module Metrics
3
+ class ParseError < StandardError
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,37 @@
1
+ module Tnw
2
+ module Metrics
3
+ module Parsers
4
+ class Df
5
+ BLOCK_BYTES = 1024
6
+
7
+ def self.parse(output, mount: "/")
8
+ rows = output.lines.drop(1).filter_map do |line|
9
+ fields = line.strip.split(/\s+/, 6)
10
+ fields if fields.length == 6
11
+ end
12
+ row = rows.find { |fields| fields.fetch(5) == mount }
13
+ raise ParseError, "df output is missing mount #{mount}" unless row
14
+
15
+ total = blocks_to_bytes(row.fetch(1))
16
+ used = blocks_to_bytes(row.fetch(2))
17
+ available = blocks_to_bytes(row.fetch(3))
18
+
19
+ {
20
+ total_bytes: total,
21
+ used_bytes: used,
22
+ available_bytes: available,
23
+ mount: row.fetch(5)
24
+ }
25
+ end
26
+
27
+ def self.blocks_to_bytes(value)
28
+ Integer(value, 10) * BLOCK_BYTES
29
+ rescue ArgumentError
30
+ raise ParseError, "df output contains an invalid block count"
31
+ end
32
+
33
+ private_class_method :blocks_to_bytes
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,20 @@
1
+ module Tnw
2
+ module Metrics
3
+ module Parsers
4
+ class ProcLoadavg
5
+ def self.parse(output)
6
+ values = output.split.first(3)
7
+ raise ParseError, "/proc/loadavg has too few values" if values.length < 3
8
+
9
+ {
10
+ one: Float(values.fetch(0)),
11
+ five: Float(values.fetch(1)),
12
+ fifteen: Float(values.fetch(2))
13
+ }
14
+ rescue ArgumentError
15
+ raise ParseError, "/proc/loadavg contains an invalid value"
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,37 @@
1
+ module Tnw
2
+ module Metrics
3
+ module Parsers
4
+ class ProcMeminfo
5
+ KIBIBYTE = 1024
6
+
7
+ def self.parse(output)
8
+ values = output.each_line.to_h do |line|
9
+ key, value = line.split(":", 2)
10
+ [ key, value.to_s.split.first ]
11
+ end
12
+
13
+ total = kibibytes(values, "MemTotal")
14
+ available = kibibytes(values, "MemAvailable")
15
+ raise ParseError, "/proc/meminfo reports available memory above total memory" if available > total
16
+
17
+ {
18
+ total_bytes: total,
19
+ used_bytes: total - available,
20
+ available_bytes: available
21
+ }
22
+ end
23
+
24
+ def self.kibibytes(values, key)
25
+ value = values[key]
26
+ raise ParseError, "/proc/meminfo is missing #{key}" unless value
27
+
28
+ Integer(value, 10) * KIBIBYTE
29
+ rescue ArgumentError
30
+ raise ParseError, "/proc/meminfo contains an invalid #{key} value"
31
+ end
32
+
33
+ private_class_method :kibibytes
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,37 @@
1
+ module Tnw
2
+ module Metrics
3
+ module Parsers
4
+ class ProcStat
5
+ CPU_COUNTER_COUNT = 8
6
+
7
+ def self.parse(previous:, current:)
8
+ previous_counters = counters(previous)
9
+ current_counters = counters(current)
10
+ deltas = current_counters.zip(previous_counters).map { |after, before| after - before }
11
+
12
+ raise ParseError, "/proc/stat counters moved backwards" if deltas.any?(&:negative?)
13
+
14
+ total_delta = deltas.sum
15
+ raise ParseError, "/proc/stat contains no elapsed CPU time" unless total_delta.positive?
16
+
17
+ idle_delta = deltas.fetch(3) + deltas.fetch(4)
18
+ { percent: ((total_delta - idle_delta) * 100.0 / total_delta).round(2) }
19
+ end
20
+
21
+ def self.counters(output)
22
+ line = output.each_line.find { |candidate| candidate.start_with?("cpu ") }
23
+ raise ParseError, "/proc/stat is missing the aggregate cpu row" unless line
24
+
25
+ values = line.split.drop(1).first(CPU_COUNTER_COUNT)
26
+ raise ParseError, "/proc/stat has too few CPU counters" if values.length < CPU_COUNTER_COUNT
27
+
28
+ values.map { |value| Integer(value, 10) }
29
+ rescue ArgumentError
30
+ raise ParseError, "/proc/stat contains an invalid CPU counter"
31
+ end
32
+
33
+ private_class_method :counters
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,17 @@
1
+ module Tnw
2
+ module Metrics
3
+ class ReplayGuard
4
+ def self.verify!(server:, sample_uid:)
5
+ if sample_uid.blank?
6
+ raise IngestionError.new(:missing_sample_uid, "Metric sample id is missing")
7
+ end
8
+
9
+ if server.metric_samples.exists?(sample_uid: sample_uid)
10
+ raise IngestionError.new(:replayed_sample, "Metric sample has already been accepted")
11
+ end
12
+
13
+ true
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,59 @@
1
+ require "openssl"
2
+
3
+ module Tnw
4
+ module Metrics
5
+ class RequestVerifier
6
+ def self.signature(raw_body:, timestamp:, secret:)
7
+ OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
8
+ end
9
+
10
+ def initialize(raw_body:, timestamp:, signature:, now: Time.current, configuration: Tnw.configuration)
11
+ @raw_body = raw_body
12
+ @timestamp = timestamp
13
+ @signature = signature
14
+ @now = now
15
+ @configuration = configuration
16
+ end
17
+
18
+ def verify!
19
+ secret = @configuration.metrics_ingestion_token
20
+ raise IngestionError.new(:not_configured, "Metric ingestion is not configured") unless secret
21
+ raise IngestionError.new(:missing_authentication, "Metric authentication is missing") if authentication_missing?
22
+
23
+ signed_at = parse_timestamp
24
+ verify_freshness!(signed_at)
25
+ verify_signature!(secret)
26
+
27
+ true
28
+ end
29
+
30
+ private
31
+
32
+ def authentication_missing?
33
+ @timestamp.blank? || @signature.blank?
34
+ end
35
+
36
+ def parse_timestamp
37
+ Time.at(Integer(@timestamp, 10)).utc
38
+ rescue ArgumentError, TypeError, RangeError
39
+ raise IngestionError.new(:invalid_timestamp, "Metric timestamp is invalid")
40
+ end
41
+
42
+ def verify_freshness!(signed_at)
43
+ skew = @configuration.metrics_accepted_clock_skew.in_seconds
44
+ return if (@now.to_f - signed_at.to_f).abs <= skew
45
+
46
+ raise IngestionError.new(:stale_timestamp, "Metric timestamp is outside the accepted clock skew")
47
+ end
48
+
49
+ def verify_signature!(secret)
50
+ expected = self.class.signature(raw_body: @raw_body, timestamp: @timestamp, secret: secret)
51
+ valid = @signature.bytesize == expected.bytesize &&
52
+ ActiveSupport::SecurityUtils.secure_compare(@signature, expected)
53
+ return if valid
54
+
55
+ raise IngestionError.new(:invalid_signature, "Metric signature is invalid")
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,14 @@
1
+ require "tnw/metrics/parse_error"
2
+ require "tnw/metrics/ingestion_error"
3
+ require "tnw/metrics/request_verifier"
4
+ require "tnw/metrics/replay_guard"
5
+ require "tnw/metrics/parsers/proc_stat"
6
+ require "tnw/metrics/parsers/proc_meminfo"
7
+ require "tnw/metrics/parsers/df"
8
+ require "tnw/metrics/parsers/proc_loadavg"
9
+
10
+ module Tnw
11
+ module Metrics
12
+ PARSER_VERSION = "1"
13
+ end
14
+ end
@@ -0,0 +1,3 @@
1
+ module Tnw
2
+ VERSION = "0.1.0"
3
+ end
data/lib/tnw.rb ADDED
@@ -0,0 +1,21 @@
1
+ require "tnw/version"
2
+ require "tnw/configuration"
3
+ require "tnw/metrics"
4
+ require "tnw/engine"
5
+
6
+ module Tnw
7
+ class << self
8
+ def configuration
9
+ @configuration ||= Configuration.new
10
+ end
11
+
12
+ def configure
13
+ yield(configuration)
14
+ configuration
15
+ end
16
+
17
+ def reset_configuration!
18
+ @configuration = Configuration.new
19
+ end
20
+ end
21
+ end
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tnw
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Catalin Ionescu
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '8.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ description: The Night Watch is a mountable Rails engine for inspecting and operating
33
+ a Kamal-backed Rails application.
34
+ executables: []
35
+ extensions: []
36
+ extra_rdoc_files: []
37
+ files:
38
+ - MIT-LICENSE
39
+ - README.md
40
+ - Rakefile
41
+ - app/assets/stylesheets/tnw/application.css
42
+ - app/controllers/tnw/application_controller.rb
43
+ - app/controllers/tnw/dashboard_controller.rb
44
+ - app/controllers/tnw/internal/metric_samples_controller.rb
45
+ - app/helpers/tnw/application_helper.rb
46
+ - app/jobs/tnw/application_job.rb
47
+ - app/jobs/tnw/metrics/retention_job.rb
48
+ - app/models/tnw/application_record.rb
49
+ - app/models/tnw/metric_sample.rb
50
+ - app/models/tnw/server.rb
51
+ - app/presenters/tnw/server_metric_status.rb
52
+ - app/services/tnw/metric_samples/ingest.rb
53
+ - app/views/layouts/tnw/application.html.erb
54
+ - app/views/tnw/dashboard/index.html.erb
55
+ - config/routes.rb
56
+ - db/migrate/20260711000001_create_tnw_servers.rb
57
+ - db/migrate/20260711000002_create_tnw_metric_samples.rb
58
+ - lib/generators/tnw/install/install_generator.rb
59
+ - lib/generators/tnw/install/templates/tnw.rb
60
+ - lib/tasks/tnw_tasks.rake
61
+ - lib/tnw.rb
62
+ - lib/tnw/configuration.rb
63
+ - lib/tnw/engine.rb
64
+ - lib/tnw/metrics.rb
65
+ - lib/tnw/metrics/ingestion_error.rb
66
+ - lib/tnw/metrics/parse_error.rb
67
+ - lib/tnw/metrics/parsers/df.rb
68
+ - lib/tnw/metrics/parsers/proc_loadavg.rb
69
+ - lib/tnw/metrics/parsers/proc_meminfo.rb
70
+ - lib/tnw/metrics/parsers/proc_stat.rb
71
+ - lib/tnw/metrics/replay_guard.rb
72
+ - lib/tnw/metrics/request_verifier.rb
73
+ - lib/tnw/version.rb
74
+ homepage: https://github.com/cionescu/tnw
75
+ licenses:
76
+ - MIT
77
+ metadata:
78
+ source_code_uri: https://github.com/cionescu/tnw
79
+ rubygems_mfa_required: 'true'
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '3.2'
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 3.6.9
95
+ specification_version: 4
96
+ summary: A protected operational UI for Kamal-backed Rails applications.
97
+ test_files: []