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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6a4787102291111902c43e70fbf3f0edf7e076ae38f4b3bae52b35f2e829b829
4
+ data.tar.gz: 4ba3be9e039f8bc95f151b725347c8cc6957462d8c236a09e0e90effac593a7c
5
+ SHA512:
6
+ metadata.gz: 0c3fb1cbe3664a48895adddc93f79234b52024a4cdc3abe0cea9af9665857ec6b31a88f288d05abdb43ad401c3847a78640b321a63b87c37c021d9984328fb76
7
+ data.tar.gz: 82f3e5b0f288e73e08dc52221af4a46a1a5d4937d79e78731373c06e42cf6cfc70ebd353bcd58928dee9e6c61b1e42a0a71745a0856997883d68afae3be7bb64
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2026 Catalin Ionescu
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # The Night Watch
2
+
3
+ The Night Watch (`tnw`) is a mountable Rails engine for inspecting and operating a Kamal-backed Rails application from a protected web UI.
4
+
5
+ The project is under active development. The engine and dummy host application are scaffolded; operational features are not available yet.
6
+
7
+ ## Installation
8
+
9
+ Add the gem to the host application's `Gemfile`:
10
+
11
+ ```ruby
12
+ gem "tnw"
13
+ ```
14
+
15
+ Mount the engine in `config/routes.rb`:
16
+
17
+ ```ruby
18
+ mount Tnw::Engine => "/tnw"
19
+ ```
20
+
21
+ The install generator creates the initializer, mounts the engine, and copies its migrations:
22
+
23
+ ```bash
24
+ bin/rails generate tnw:install
25
+ bin/rails db:migrate
26
+ ```
27
+
28
+ Configure either the host authentication callback shown in the generated initializer or basic auth credentials. Outside development, the dashboard fails closed when neither mode is complete.
29
+
30
+ Configure server metrics from a host-app initializer:
31
+
32
+ ```ruby
33
+ Tnw.configure do |config|
34
+ config.metrics_sample_interval = 5.seconds
35
+ config.metrics_retention = 7.days
36
+ config.metrics_ingestion_token = ENV["TNW_METRICS_INGESTION_TOKEN"]
37
+ config.metrics_accepted_clock_skew = 30.seconds
38
+ end
39
+ ```
40
+
41
+ Metric ingestion fails closed when the ingestion token is missing.
42
+
43
+ Host collector installation is documented in [the server stats installation guide](https://github.com/cionescu/TNW/blob/main/docs/server-stats-install.md).
44
+
45
+ ## Development
46
+
47
+ The generated dummy Rails application lives in `test/dummy`.
48
+
49
+ ```bash
50
+ bundle install
51
+ bin/rails test
52
+ ```
53
+
54
+ ## License
55
+
56
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1,183 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
16
+
17
+ .tnw {
18
+ margin: 0;
19
+ background: #f5f6f7;
20
+ color: #17202a;
21
+ font-family: ui-sans-serif, system-ui, sans-serif;
22
+ }
23
+
24
+ .tnw main {
25
+ width: min(1120px, calc(100% - 32px));
26
+ margin: 0 auto;
27
+ padding: 32px 0;
28
+ }
29
+
30
+ .tnw-page-header,
31
+ .tnw-server-header,
32
+ .tnw-server-footer {
33
+ display: flex;
34
+ align-items: center;
35
+ justify-content: space-between;
36
+ gap: 16px;
37
+ }
38
+
39
+ .tnw-page-header {
40
+ margin-bottom: 24px;
41
+ }
42
+
43
+ .tnw h1,
44
+ .tnw h2,
45
+ .tnw p,
46
+ .tnw dl {
47
+ margin: 0;
48
+ }
49
+
50
+ .tnw h1 {
51
+ font-size: 28px;
52
+ line-height: 1.2;
53
+ }
54
+
55
+ .tnw h2 {
56
+ font-size: 17px;
57
+ line-height: 1.3;
58
+ }
59
+
60
+ .tnw-eyebrow,
61
+ .tnw-server-header p,
62
+ .tnw-server-footer {
63
+ color: #65717d;
64
+ font-size: 13px;
65
+ }
66
+
67
+ .tnw-eyebrow {
68
+ margin-bottom: 4px;
69
+ font-weight: 700;
70
+ }
71
+
72
+ .tnw-server-count,
73
+ .tnw-status {
74
+ border: 1px solid #cbd2d9;
75
+ border-radius: 4px;
76
+ background: #fff;
77
+ padding: 5px 8px;
78
+ font-size: 12px;
79
+ font-weight: 700;
80
+ }
81
+
82
+ .tnw-server-grid {
83
+ display: grid;
84
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 440px), 1fr));
85
+ gap: 16px;
86
+ }
87
+
88
+ .tnw-server,
89
+ .tnw-empty-state {
90
+ border: 1px solid #d7dce1;
91
+ border-radius: 6px;
92
+ background: #fff;
93
+ }
94
+
95
+ .tnw-server {
96
+ overflow: hidden;
97
+ }
98
+
99
+ .tnw-server-header,
100
+ .tnw-server-footer {
101
+ padding: 14px 16px;
102
+ }
103
+
104
+ .tnw-server[data-status="healthy"] .tnw-status {
105
+ border-color: #86b89a;
106
+ background: #edf8f1;
107
+ color: #205c38;
108
+ }
109
+
110
+ .tnw-server[data-status="stale"] .tnw-status,
111
+ .tnw-server[data-status="partial"] .tnw-status,
112
+ .tnw-server[data-status="never_received"] .tnw-status {
113
+ border-color: #d6b46b;
114
+ background: #fff8e6;
115
+ color: #73510b;
116
+ }
117
+
118
+ .tnw-server[data-status="rejected"] .tnw-status,
119
+ .tnw-server[data-status="disabled"] .tnw-status {
120
+ border-color: #d79595;
121
+ background: #fff0f0;
122
+ color: #812a2a;
123
+ }
124
+
125
+ .tnw-metrics {
126
+ display: grid;
127
+ grid-template-columns: repeat(4, minmax(0, 1fr));
128
+ border-top: 1px solid #e5e8eb;
129
+ border-bottom: 1px solid #e5e8eb;
130
+ }
131
+
132
+ .tnw-metrics div {
133
+ min-width: 0;
134
+ padding: 14px 12px;
135
+ border-right: 1px solid #e5e8eb;
136
+ }
137
+
138
+ .tnw-metrics div:last-child {
139
+ border-right: 0;
140
+ }
141
+
142
+ .tnw-metrics dt {
143
+ color: #65717d;
144
+ font-size: 11px;
145
+ font-weight: 700;
146
+ text-transform: uppercase;
147
+ }
148
+
149
+ .tnw-metrics dd {
150
+ margin: 5px 0 0;
151
+ overflow-wrap: anywhere;
152
+ font-size: 16px;
153
+ font-weight: 700;
154
+ }
155
+
156
+ .tnw-server-footer {
157
+ align-items: flex-start;
158
+ flex-wrap: wrap;
159
+ }
160
+
161
+ .tnw-empty-state {
162
+ padding: 32px;
163
+ text-align: center;
164
+ }
165
+
166
+ @media (max-width: 620px) {
167
+ .tnw main {
168
+ width: min(100% - 20px, 1120px);
169
+ padding: 20px 0;
170
+ }
171
+
172
+ .tnw-metrics {
173
+ grid-template-columns: repeat(2, minmax(0, 1fr));
174
+ }
175
+
176
+ .tnw-metrics div:nth-child(2) {
177
+ border-right: 0;
178
+ }
179
+
180
+ .tnw-metrics div:nth-child(-n+2) {
181
+ border-bottom: 1px solid #e5e8eb;
182
+ }
183
+ }
@@ -0,0 +1,32 @@
1
+ module Tnw
2
+ class ApplicationController < ActionController::Base
3
+ before_action :authenticate_tnw_operator!
4
+
5
+ private
6
+
7
+ def authenticate_tnw_operator!
8
+ configuration = Tnw.configuration
9
+
10
+ if configuration.authentication_handler
11
+ authenticate_with_host!(configuration.authentication_handler)
12
+ elsif configuration.basic_auth_configured?
13
+ authenticate_with_basic_auth!(configuration)
14
+ elsif !Rails.env.development?
15
+ render plain: "The Night Watch authentication is not configured.", status: :service_unavailable
16
+ end
17
+ end
18
+
19
+ def authenticate_with_host!(handler)
20
+ allowed = handler.call(self)
21
+ head :forbidden unless allowed || performed?
22
+ end
23
+
24
+ def authenticate_with_basic_auth!(configuration)
25
+ authenticate_or_request_with_http_basic("The Night Watch") do |username, password|
26
+ username_valid = ActiveSupport::SecurityUtils.secure_compare(username, configuration.basic_auth_username)
27
+ password_valid = ActiveSupport::SecurityUtils.secure_compare(password, configuration.basic_auth_password)
28
+ username_valid & password_valid
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,14 @@
1
+ module Tnw
2
+ class DashboardController < ApplicationController
3
+ def index
4
+ servers = Tnw::Server.order(:name, :identifier)
5
+ latest_samples = Tnw::MetricSample
6
+ .where(id: Tnw::MetricSample.select("MAX(id)").group(:server_id))
7
+ .index_by(&:server_id)
8
+
9
+ @server_metrics = servers.map do |server|
10
+ Tnw::ServerMetricStatus.new(server: server, sample: latest_samples[server.id])
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,62 @@
1
+ module Tnw
2
+ module Internal
3
+ class MetricSamplesController < ActionController::API
4
+ MAX_PAYLOAD_BYTES = 64.kilobytes
5
+
6
+ rescue_from JSON::ParserError, with: :render_invalid_json
7
+ rescue_from Tnw::Metrics::IngestionError, with: :render_ingestion_error
8
+
9
+ def create
10
+ raise_payload_too_large! if request.content_length.to_i > MAX_PAYLOAD_BYTES
11
+
12
+ raw_body = request.raw_post
13
+ raise_payload_too_large! if raw_body.bytesize > MAX_PAYLOAD_BYTES
14
+
15
+ verify_request!(raw_body)
16
+ sample = Tnw::MetricSamples::Ingest.new(
17
+ payload: JSON.parse(raw_body),
18
+ source_ip: request.remote_ip
19
+ ).call
20
+
21
+ render json: { status: "accepted", sample_id: sample.id }, status: :created
22
+ end
23
+
24
+ private
25
+
26
+ def verify_request!(raw_body)
27
+ Tnw::Metrics::RequestVerifier.new(
28
+ raw_body: raw_body,
29
+ timestamp: request.headers["X-TNW-Timestamp"],
30
+ signature: request.headers["X-TNW-Signature"]
31
+ ).verify!
32
+ end
33
+
34
+ def raise_payload_too_large!
35
+ raise Tnw::Metrics::IngestionError.new(:payload_too_large, "Metric payload is too large")
36
+ end
37
+
38
+ def render_invalid_json
39
+ render json: { error: "invalid_json" }, status: :bad_request
40
+ end
41
+
42
+ def render_ingestion_error(error)
43
+ render json: { error: error.code }, status: status_for(error.code)
44
+ end
45
+
46
+ def status_for(code)
47
+ case code
48
+ when "not_configured"
49
+ :service_unavailable
50
+ when "missing_authentication", "invalid_signature", "invalid_timestamp", "stale_timestamp"
51
+ :unauthorized
52
+ when "payload_too_large"
53
+ :content_too_large
54
+ when "replayed_sample"
55
+ :conflict
56
+ else
57
+ :unprocessable_content
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,21 @@
1
+ module Tnw
2
+ module ApplicationHelper
3
+ def metric_percentage(value)
4
+ return "Unavailable" if value.nil?
5
+
6
+ number_to_percentage(value, precision: 1)
7
+ end
8
+
9
+ def metric_bytes(value)
10
+ return "Unavailable" if value.nil?
11
+
12
+ number_to_human_size(value, precision: 3)
13
+ end
14
+
15
+ def metric_decimal(value)
16
+ return "Unavailable" if value.nil?
17
+
18
+ number_with_precision(value, precision: 2)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,4 @@
1
+ module Tnw
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,10 @@
1
+ module Tnw
2
+ module Metrics
3
+ class RetentionJob < ApplicationJob
4
+ def perform(now: Time.current)
5
+ cutoff = now - Tnw.configuration.metrics_retention
6
+ Tnw::MetricSample.where(collected_at: ...cutoff).delete_all
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,5 @@
1
+ module Tnw
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,16 @@
1
+ module Tnw
2
+ class MetricSample < ApplicationRecord
3
+ belongs_to :server
4
+
5
+ validates :collected_at, :received_at, :collector_name, :collector_version, :parser_version, :sample_uid,
6
+ presence: true
7
+ validates :sample_uid, uniqueness: { scope: :server_id }
8
+ validates :cpu_percent, numericality: { in: 0..100 }, allow_nil: true
9
+ validates :load_1m, :load_5m, :load_15m, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
10
+ validates :memory_total_bytes, :memory_used_bytes, :memory_available_bytes,
11
+ :disk_total_bytes, :disk_used_bytes, :disk_available_bytes,
12
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true
13
+
14
+ scope :chronological, -> { order(:collected_at) }
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ module Tnw
2
+ class Server < ApplicationRecord
3
+ INGESTION_STATUSES = %w[accepted rejected].freeze
4
+
5
+ has_many :metric_samples, dependent: :delete_all
6
+
7
+ validates :identifier, presence: true, uniqueness: true
8
+ validates :last_ingestion_status, inclusion: { in: INGESTION_STATUSES }, allow_nil: true
9
+
10
+ scope :enabled, -> { where(enabled: true) }
11
+
12
+ def display_name
13
+ name.presence || identifier
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,32 @@
1
+ module Tnw
2
+ class ServerMetricStatus
3
+ attr_reader :server, :sample
4
+
5
+ def initialize(server:, sample:, now: Time.current, configuration: Tnw.configuration)
6
+ @server = server
7
+ @sample = sample
8
+ @now = now
9
+ @configuration = configuration
10
+ end
11
+
12
+ def status
13
+ return :disabled unless server.enabled?
14
+ return :rejected if server.last_ingestion_status == "rejected"
15
+ return :never_received unless sample
16
+ return :stale if stale?
17
+ return :partial if sample.parse_errors.any?
18
+
19
+ :healthy
20
+ end
21
+
22
+ def status_label
23
+ status.to_s.humanize
24
+ end
25
+
26
+ private
27
+
28
+ def stale?
29
+ sample.received_at < @now - (@configuration.metrics_sample_interval * 3)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,160 @@
1
+ require "time"
2
+
3
+ module Tnw
4
+ module MetricSamples
5
+ class Ingest
6
+ ERROR_CODE_PATTERN = /\A[a-z0-9_.-]+\z/
7
+ MAX_ERROR_CODES = 10
8
+ MAX_ERROR_CODE_LENGTH = 100
9
+
10
+ def initialize(payload:, source_ip:, now: Time.current)
11
+ @payload = payload
12
+ @source_ip = source_ip
13
+ @now = now
14
+ @server = nil
15
+ end
16
+
17
+ def call
18
+ validate_payload_shape!
19
+ @server = find_server!
20
+ sample_attributes = build_sample_attributes
21
+
22
+ @server.with_lock do
23
+ Tnw::Metrics::ReplayGuard.verify!(server: @server, sample_uid: sample_attributes[:sample_uid])
24
+ sample = @server.metric_samples.create!(sample_attributes)
25
+ mark_server!(:accepted)
26
+ sample
27
+ end
28
+ rescue ActiveRecord::RecordInvalid => error
29
+ ingestion_error = Tnw::Metrics::IngestionError.new(
30
+ :invalid_payload,
31
+ error.record.errors.full_messages.to_sentence
32
+ )
33
+ mark_server!(:rejected, ingestion_error.code) if @server
34
+ raise ingestion_error
35
+ rescue ActiveRecord::RecordNotUnique
36
+ ingestion_error = Tnw::Metrics::IngestionError.new(
37
+ :replayed_sample,
38
+ "Metric sample has already been accepted"
39
+ )
40
+ mark_server!(:rejected, ingestion_error.code) if @server
41
+ raise ingestion_error
42
+ rescue Tnw::Metrics::IngestionError => error
43
+ mark_server!(:rejected, error.code) if @server
44
+ raise
45
+ end
46
+
47
+ private
48
+
49
+ def validate_payload_shape!
50
+ return if @payload.is_a?(Hash) && @payload["metrics"].is_a?(Hash)
51
+
52
+ raise Tnw::Metrics::IngestionError.new(:invalid_payload, "Metric payload must contain a metrics object")
53
+ end
54
+
55
+ def find_server!
56
+ identifier = required_string!("server_id")
57
+ server = Tnw::Server.find_by(identifier: identifier)
58
+ raise Tnw::Metrics::IngestionError.new(:unknown_server, "Metric server is not registered") unless server
59
+ raise Tnw::Metrics::IngestionError.new(:disabled_server, "Metric server is disabled") unless server.enabled?
60
+
61
+ server
62
+ end
63
+
64
+ def build_sample_attributes
65
+ metrics = @payload.fetch("metrics")
66
+ {
67
+ collected_at: collected_at,
68
+ received_at: @now,
69
+ cpu_percent: optional_float(metrics.dig("cpu", "percent")),
70
+ load_1m: optional_float(metrics.dig("load", "one")),
71
+ load_5m: optional_float(metrics.dig("load", "five")),
72
+ load_15m: optional_float(metrics.dig("load", "fifteen")),
73
+ memory_total_bytes: optional_integer(metrics.dig("memory", "total_bytes")),
74
+ memory_used_bytes: optional_integer(metrics.dig("memory", "used_bytes")),
75
+ memory_available_bytes: optional_integer(metrics.dig("memory", "available_bytes")),
76
+ disk_total_bytes: optional_integer(metrics.dig("disk", "total_bytes")),
77
+ disk_used_bytes: optional_integer(metrics.dig("disk", "used_bytes")),
78
+ disk_available_bytes: optional_integer(metrics.dig("disk", "available_bytes")),
79
+ disk_mount: optional_string(metrics.dig("disk", "mount")),
80
+ collector_name: required_string!("collector_name"),
81
+ collector_version: required_string!("collector_version"),
82
+ parser_version: required_string!("parser_version"),
83
+ source_ip: @source_ip,
84
+ sample_uid: required_string!("sample_uid"),
85
+ parse_errors: parse_error_codes
86
+ }
87
+ rescue TypeError
88
+ raise Tnw::Metrics::IngestionError.new(:invalid_payload, "Metric groups must be objects")
89
+ end
90
+
91
+ def collected_at
92
+ value = Time.iso8601(required_string!("collected_at"))
93
+ skew = Tnw.configuration.metrics_accepted_clock_skew.in_seconds
94
+ if (@now.to_f - value.to_f).abs > skew
95
+ raise Tnw::Metrics::IngestionError.new(:stale_sample, "Metric sample time is outside the accepted clock skew")
96
+ end
97
+
98
+ value
99
+ rescue ArgumentError
100
+ raise Tnw::Metrics::IngestionError.new(:invalid_payload, "collected_at must be an ISO 8601 timestamp")
101
+ end
102
+
103
+ def required_string!(key)
104
+ value = @payload[key]
105
+ return value if value.is_a?(String) && !value.strip.empty? && value.bytesize <= 255
106
+
107
+ raise Tnw::Metrics::IngestionError.new(:invalid_payload, "#{key} must be a non-blank string")
108
+ end
109
+
110
+ def optional_string(value)
111
+ return if value.nil?
112
+ return value if value.is_a?(String) && value.bytesize <= 255
113
+
114
+ raise Tnw::Metrics::IngestionError.new(:invalid_payload, "Metric string value is invalid")
115
+ end
116
+
117
+ def optional_float(value)
118
+ return if value.nil?
119
+
120
+ number = Float(value)
121
+ return number if number.finite?
122
+
123
+ raise ArgumentError
124
+ rescue ArgumentError, TypeError
125
+ raise Tnw::Metrics::IngestionError.new(:invalid_payload, "Metric numeric value is invalid")
126
+ end
127
+
128
+ def optional_integer(value)
129
+ return if value.nil?
130
+
131
+ Integer(value)
132
+ rescue ArgumentError, TypeError
133
+ raise Tnw::Metrics::IngestionError.new(:invalid_payload, "Metric byte value is invalid")
134
+ end
135
+
136
+ def parse_error_codes
137
+ values = @payload.fetch("parse_errors", [])
138
+ unless values.is_a?(Array) && values.length <= MAX_ERROR_CODES
139
+ raise Tnw::Metrics::IngestionError.new(:invalid_payload, "parse_errors must be a bounded array")
140
+ end
141
+
142
+ values.map do |value|
143
+ valid = value.is_a?(String) && value.length <= MAX_ERROR_CODE_LENGTH && value.match?(ERROR_CODE_PATTERN)
144
+ raise Tnw::Metrics::IngestionError.new(:invalid_payload, "parse_errors contains an invalid code") unless valid
145
+
146
+ value
147
+ end
148
+ end
149
+
150
+ def mark_server!(status, error_code = nil)
151
+ @server.update_columns(
152
+ last_ingestion_status: status,
153
+ last_ingestion_error_code: error_code,
154
+ last_ingestion_at: @now,
155
+ updated_at: @now
156
+ )
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>The Night Watch</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= yield :head %>
9
+
10
+ <%= stylesheet_link_tag "tnw/application", media: "all" %>
11
+ </head>
12
+ <body class="tnw">
13
+ <%= yield %>
14
+ </body>
15
+ </html>