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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +56 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/tnw/application.css +183 -0
- data/app/controllers/tnw/application_controller.rb +32 -0
- data/app/controllers/tnw/dashboard_controller.rb +14 -0
- data/app/controllers/tnw/internal/metric_samples_controller.rb +62 -0
- data/app/helpers/tnw/application_helper.rb +21 -0
- data/app/jobs/tnw/application_job.rb +4 -0
- data/app/jobs/tnw/metrics/retention_job.rb +10 -0
- data/app/models/tnw/application_record.rb +5 -0
- data/app/models/tnw/metric_sample.rb +16 -0
- data/app/models/tnw/server.rb +16 -0
- data/app/presenters/tnw/server_metric_status.rb +32 -0
- data/app/services/tnw/metric_samples/ingest.rb +160 -0
- data/app/views/layouts/tnw/application.html.erb +15 -0
- data/app/views/tnw/dashboard/index.html.erb +62 -0
- data/config/routes.rb +6 -0
- data/db/migrate/20260711000001_create_tnw_servers.rb +15 -0
- data/db/migrate/20260711000002_create_tnw_metric_samples.rb +33 -0
- data/lib/generators/tnw/install/install_generator.rb +44 -0
- data/lib/generators/tnw/install/templates/tnw.rb +12 -0
- data/lib/tasks/tnw_tasks.rake +4 -0
- data/lib/tnw/configuration.rb +95 -0
- data/lib/tnw/engine.rb +5 -0
- data/lib/tnw/metrics/ingestion_error.rb +12 -0
- data/lib/tnw/metrics/parse_error.rb +6 -0
- data/lib/tnw/metrics/parsers/df.rb +37 -0
- data/lib/tnw/metrics/parsers/proc_loadavg.rb +20 -0
- data/lib/tnw/metrics/parsers/proc_meminfo.rb +37 -0
- data/lib/tnw/metrics/parsers/proc_stat.rb +37 -0
- data/lib/tnw/metrics/replay_guard.rb +17 -0
- data/lib/tnw/metrics/request_verifier.rb +59 -0
- data/lib/tnw/metrics.rb +14 -0
- data/lib/tnw/version.rb +3 -0
- data/lib/tnw.rb +21 -0
- 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,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,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>
|