upright 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/LICENSE.md +10 -0
- data/README.md +455 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/upright/_global.css +104 -0
- data/app/assets/stylesheets/upright/artifact.css +148 -0
- data/app/assets/stylesheets/upright/base.css +68 -0
- data/app/assets/stylesheets/upright/buttons.css +21 -0
- data/app/assets/stylesheets/upright/dashboard.css +287 -0
- data/app/assets/stylesheets/upright/forms.css +104 -0
- data/app/assets/stylesheets/upright/header.css +124 -0
- data/app/assets/stylesheets/upright/layout.css +100 -0
- data/app/assets/stylesheets/upright/map.css +25 -0
- data/app/assets/stylesheets/upright/pagination.css +45 -0
- data/app/assets/stylesheets/upright/probes.css +72 -0
- data/app/assets/stylesheets/upright/reset.css +26 -0
- data/app/assets/stylesheets/upright/tables.css +63 -0
- data/app/assets/stylesheets/upright/typography.css +27 -0
- data/app/assets/stylesheets/upright/uptime-bars.css +154 -0
- data/app/controllers/concerns/upright/authentication.rb +21 -0
- data/app/controllers/concerns/upright/subdomain_scoping.rb +18 -0
- data/app/controllers/upright/alertmanager_proxy_controller.rb +21 -0
- data/app/controllers/upright/application_controller.rb +12 -0
- data/app/controllers/upright/artifacts_controller.rb +5 -0
- data/app/controllers/upright/dashboards/uptimes_controller.rb +6 -0
- data/app/controllers/upright/jobs_controller.rb +4 -0
- data/app/controllers/upright/probe_results_controller.rb +17 -0
- data/app/controllers/upright/prometheus_proxy_controller.rb +62 -0
- data/app/controllers/upright/sessions_controller.rb +29 -0
- data/app/controllers/upright/sites_controller.rb +5 -0
- data/app/helpers/upright/application_helper.rb +11 -0
- data/app/helpers/upright/dashboards_helper.rb +31 -0
- data/app/helpers/upright/probe_results_helper.rb +49 -0
- data/app/javascript/upright/application.js +2 -0
- data/app/javascript/upright/controllers/application.js +5 -0
- data/app/javascript/upright/controllers/form_controller.js +7 -0
- data/app/javascript/upright/controllers/index.js +4 -0
- data/app/javascript/upright/controllers/popover_controller.js +15 -0
- data/app/javascript/upright/controllers/probe_results_chart_controller.js +79 -0
- data/app/javascript/upright/controllers/results_table_controller.js +16 -0
- data/app/javascript/upright/controllers/sites_map_controller.js +33 -0
- data/app/jobs/upright/application_job.rb +2 -0
- data/app/jobs/upright/probe_check_job.rb +42 -0
- data/app/models/concerns/upright/exception_recording.rb +38 -0
- data/app/models/concerns/upright/playwright/form_authentication.rb +27 -0
- data/app/models/concerns/upright/playwright/helpers.rb +7 -0
- data/app/models/concerns/upright/playwright/lifecycle.rb +44 -0
- data/app/models/concerns/upright/playwright/logging.rb +87 -0
- data/app/models/concerns/upright/playwright/otel_tracing.rb +137 -0
- data/app/models/concerns/upright/playwright/video_recording.rb +60 -0
- data/app/models/concerns/upright/probe_yaml_source.rb +10 -0
- data/app/models/concerns/upright/probeable.rb +125 -0
- data/app/models/concerns/upright/staggerable.rb +22 -0
- data/app/models/concerns/upright/traceroute/otel_tracing.rb +108 -0
- data/app/models/upright/application_record.rb +3 -0
- data/app/models/upright/artifact.rb +61 -0
- data/app/models/upright/current.rb +9 -0
- data/app/models/upright/http/request.rb +59 -0
- data/app/models/upright/http/response.rb +55 -0
- data/app/models/upright/playwright/authenticator/base.rb +128 -0
- data/app/models/upright/playwright/storage_state.rb +31 -0
- data/app/models/upright/probe_result.rb +31 -0
- data/app/models/upright/probes/http_probe.rb +102 -0
- data/app/models/upright/probes/playwright/base.rb +48 -0
- data/app/models/upright/probes/smtp_probe.rb +48 -0
- data/app/models/upright/probes/traceroute_probe.rb +32 -0
- data/app/models/upright/probes/uptime/summary.rb +36 -0
- data/app/models/upright/probes/uptime.rb +36 -0
- data/app/models/upright/traceroute/hop.rb +49 -0
- data/app/models/upright/traceroute/ip_metadata_lookup.rb +107 -0
- data/app/models/upright/traceroute/mtr_parser.rb +47 -0
- data/app/models/upright/traceroute/result.rb +57 -0
- data/app/models/upright/user.rb +14 -0
- data/app/views/layouts/upright/_header.html.erb +23 -0
- data/app/views/layouts/upright/application.html.erb +25 -0
- data/app/views/upright/active_storage/attachments/_attachment.html.erb +21 -0
- data/app/views/upright/alertmanager_proxy/show.html.erb +1 -0
- data/app/views/upright/artifacts/show.html.erb +9 -0
- data/app/views/upright/dashboards/_uptime_bars.html.erb +17 -0
- data/app/views/upright/dashboards/_uptime_probe_row.html.erb +22 -0
- data/app/views/upright/dashboards/uptimes/show.html.erb +17 -0
- data/app/views/upright/jobs/show.html.erb +1 -0
- data/app/views/upright/probe_results/_pagination.html.erb +19 -0
- data/app/views/upright/probe_results/index.html.erb +72 -0
- data/app/views/upright/prometheus_proxy/show.html.erb +1 -0
- data/app/views/upright/sessions/new.html.erb +6 -0
- data/app/views/upright/sites/index.html.erb +22 -0
- data/config/brakeman.ignore +39 -0
- data/config/ci.rb +7 -0
- data/config/importmap.rb +18 -0
- data/config/routes.rb +41 -0
- data/db/migrate/20250114000001_create_upright_probe_results.rb +19 -0
- data/lib/generators/upright/install/install_generator.rb +83 -0
- data/lib/generators/upright/install/templates/alertmanager.yml +14 -0
- data/lib/generators/upright/install/templates/deploy.yml +118 -0
- data/lib/generators/upright/install/templates/development_alertmanager.yml +11 -0
- data/lib/generators/upright/install/templates/development_prometheus.yml +12 -0
- data/lib/generators/upright/install/templates/docker-compose.yml +38 -0
- data/lib/generators/upright/install/templates/http_probes.yml +14 -0
- data/lib/generators/upright/install/templates/omniauth.rb +8 -0
- data/lib/generators/upright/install/templates/otel_collector.yml +24 -0
- data/lib/generators/upright/install/templates/prometheus.yml +10 -0
- data/lib/generators/upright/install/templates/puma.rb +40 -0
- data/lib/generators/upright/install/templates/sites.yml +26 -0
- data/lib/generators/upright/install/templates/smtp_probes.yml +9 -0
- data/lib/generators/upright/install/templates/upright.rb +21 -0
- data/lib/generators/upright/install/templates/upright.rules.yml +256 -0
- data/lib/generators/upright/playwright_probe/playwright_probe_generator.rb +30 -0
- data/lib/generators/upright/playwright_probe/templates/authenticator.rb.tt +14 -0
- data/lib/generators/upright/playwright_probe/templates/probe.rb.tt +14 -0
- data/lib/omniauth/strategies/static_credentials.rb +57 -0
- data/lib/tasks/upright_tasks.rake +4 -0
- data/lib/upright/configuration.rb +106 -0
- data/lib/upright/engine.rb +157 -0
- data/lib/upright/metrics.rb +62 -0
- data/lib/upright/playwright/collect_performance_metrics.js +36 -0
- data/lib/upright/site.rb +49 -0
- data/lib/upright/tracing.rb +49 -0
- data/lib/upright/version.rb +3 -0
- data/lib/upright.rb +68 -0
- metadata +513 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class Upright::ProbeResultsController < Upright::ApplicationController
|
|
2
|
+
def index
|
|
3
|
+
set_page_and_extract_portion_from probe_results, ordered_by: { id: :desc }
|
|
4
|
+
|
|
5
|
+
@probe_names = Upright::ProbeResult.by_type(params[:probe_type]).distinct.pluck(:probe_name).sort
|
|
6
|
+
@chart_data = @page.records.map(&:to_chart)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
def probe_results
|
|
11
|
+
Upright::ProbeResult
|
|
12
|
+
.by_type(params[:probe_type])
|
|
13
|
+
.by_status(params[:status])
|
|
14
|
+
.by_name(params[:probe_name])
|
|
15
|
+
.with_attached_artifacts
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
class Upright::PrometheusProxyController < Upright::ApplicationController
|
|
2
|
+
skip_forgery_protection
|
|
3
|
+
|
|
4
|
+
skip_before_action :authenticate_user, only: :otlp
|
|
5
|
+
before_action :authenticate_otlp_token, only: :otlp
|
|
6
|
+
|
|
7
|
+
UNSUPPORTED_PATHS = %w[/api/v1/notifications]
|
|
8
|
+
|
|
9
|
+
def show
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def proxy
|
|
13
|
+
path = request.fullpath.sub(%r{^/prometheus}, "")
|
|
14
|
+
|
|
15
|
+
if path.start_with?(*UNSUPPORTED_PATHS)
|
|
16
|
+
head :not_found
|
|
17
|
+
else
|
|
18
|
+
proxy_to_prometheus(path)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def otlp
|
|
23
|
+
response = prometheus_connection.post("/api/v1/otlp/v1/metrics") do |req|
|
|
24
|
+
req.headers["Content-Type"] = request.content_type
|
|
25
|
+
req.body = request.body.read
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
render body: response.body, status: response.status, content_type: response.headers["content-type"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
def proxy_to_prometheus(path, method: request.method, body: nil)
|
|
33
|
+
response = prometheus_connection.run_request(
|
|
34
|
+
method.downcase.to_sym,
|
|
35
|
+
path,
|
|
36
|
+
body,
|
|
37
|
+
{ "Content-Type" => request.content_type }
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if response.status.in?([ 301, 302 ]) && response.headers["location"]
|
|
41
|
+
redirect_to "/prometheus#{response.headers['location']}", status: response.status, allow_other_host: true
|
|
42
|
+
else
|
|
43
|
+
render body: response.body, status: response.status, content_type: response.headers["content-type"]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def prometheus_connection
|
|
48
|
+
@prometheus_connection ||= Faraday.new(url: prometheus_url) do |f|
|
|
49
|
+
f.options.timeout = 30
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def prometheus_url
|
|
54
|
+
ENV.fetch("PROMETHEUS_URL", "http://localhost:9090")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def authenticate_otlp_token
|
|
58
|
+
authenticate_or_request_with_http_token do |token|
|
|
59
|
+
ActiveSupport::SecurityUtils.secure_compare(token, ENV.fetch("PROMETHEUS_OTLP_TOKEN", ""))
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
class Upright::SessionsController < Upright::ApplicationController
|
|
2
|
+
skip_before_action :authenticate_user, only: [ :new, :create ]
|
|
3
|
+
skip_forgery_protection only: :create
|
|
4
|
+
|
|
5
|
+
before_action :ensure_not_signed_in, only: [ :new, :create ]
|
|
6
|
+
|
|
7
|
+
def new
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def create
|
|
11
|
+
user = Upright::User.from_omniauth(request.env["omniauth.auth"])
|
|
12
|
+
session[:user_info] = { email: user.email, name: user.name }
|
|
13
|
+
redirect_to upright.root_path
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def destroy
|
|
17
|
+
reset_session
|
|
18
|
+
redirect_to upright.root_path(subdomain: Upright.configuration.global_subdomain), allow_other_host: true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
def ensure_not_signed_in
|
|
23
|
+
redirect_to upright.site_root_path if session[:user_info].present?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def upright
|
|
27
|
+
Upright::Engine.routes.url_helpers
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module Upright::ApplicationHelper
|
|
2
|
+
def current_or_default_site
|
|
3
|
+
Upright::Current.site || Upright.sites.first
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
def upright_stylesheet_link_tag(**options)
|
|
7
|
+
Upright::Engine.root.join("app/assets/stylesheets/upright").glob("*.css")
|
|
8
|
+
.map { |f| "upright/#{f.basename('.css')}" }.sort
|
|
9
|
+
.then { |stylesheets| stylesheet_link_tag(*stylesheets, **options) }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Upright::DashboardsHelper
|
|
2
|
+
def date_range
|
|
3
|
+
(29.days.ago.to_date..Date.current).to_a
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
def uptime_label(percentage)
|
|
7
|
+
case percentage
|
|
8
|
+
when 100 then "excellent"
|
|
9
|
+
when 99..100 then "good"
|
|
10
|
+
when 95..99 then "warning"
|
|
11
|
+
when 0.01..95 then "critical"
|
|
12
|
+
else "down"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def uptime_bar_tooltip(date, uptime_percent, downtime_minutes)
|
|
17
|
+
tooltip = "#{date.to_fs(:short)}: #{number_with_precision(uptime_percent, precision: 1)}% uptime"
|
|
18
|
+
tooltip += " (#{format_downtime(downtime_minutes)} down)" if downtime_minutes > 0
|
|
19
|
+
tooltip
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def format_downtime(minutes)
|
|
23
|
+
if minutes < 60
|
|
24
|
+
"#{minutes}m"
|
|
25
|
+
else
|
|
26
|
+
hours = minutes / 60
|
|
27
|
+
mins = minutes % 60
|
|
28
|
+
mins.zero? ? "#{hours}h" : "#{hours}h #{mins}m"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Upright::ProbeResultsHelper
|
|
2
|
+
PROBE_TYPE_ICONS = {
|
|
3
|
+
http: "🌐",
|
|
4
|
+
playwright: "🎭",
|
|
5
|
+
ping: "📶",
|
|
6
|
+
smtp: "✉️",
|
|
7
|
+
traceroute: "🛤️"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
def probe_type_icon(probe_type)
|
|
11
|
+
icon = PROBE_TYPE_ICONS.fetch(probe_type.to_s.downcase.to_sym)
|
|
12
|
+
content_tag(:span, icon, title: probe_type.titleize)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def type_filter_link(label, probe_type = nil)
|
|
16
|
+
display_label = probe_type ? safe_join([ probe_type_icon(probe_type), " ", label ]) : label
|
|
17
|
+
|
|
18
|
+
link_to display_label,
|
|
19
|
+
site_root_path(probe_type: probe_type.presence, status: params[:status].presence),
|
|
20
|
+
class: class_names(active: params[:probe_type].presence == probe_type)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def artifact_icon(artifact)
|
|
24
|
+
case artifact.filename
|
|
25
|
+
when Upright::ExceptionRecording::EXCEPTION_FILENAME then "💥"
|
|
26
|
+
when /\.webm$/ then "🎬"
|
|
27
|
+
when /^request\.log$/ then "📤"
|
|
28
|
+
when /^response\./ then "📥"
|
|
29
|
+
when /^smtp\.log$/ then "📧"
|
|
30
|
+
else "📎"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def results_summary(page)
|
|
35
|
+
total = page.recordset.records_count
|
|
36
|
+
current_count = page.records.size
|
|
37
|
+
|
|
38
|
+
parts = if page.recordset.page_count > 1
|
|
39
|
+
[ "Showing #{current_count} of #{total} results" ]
|
|
40
|
+
else
|
|
41
|
+
[ "Showing #{total} results" ]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
parts << "for #{params[:probe_type].titleize} probes" if params[:probe_type].present?
|
|
45
|
+
parts << "named #{params[:probe_name]}" if params[:probe_name].present?
|
|
46
|
+
parts << "with status #{params[:status]}" if params[:status].present?
|
|
47
|
+
parts.join(" ")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["frame"]
|
|
5
|
+
|
|
6
|
+
loadFrame() {
|
|
7
|
+
if (this.element.open && this.hasFrameTarget) {
|
|
8
|
+
this.frameTarget.loading = "eager"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
close() {
|
|
13
|
+
this.element.removeAttribute("open")
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { Chart } from "frappe-charts"
|
|
3
|
+
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
static values = { results: Array }
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this.results = this.resultsValue.slice().reverse()
|
|
9
|
+
|
|
10
|
+
if (this.results.length > 0) {
|
|
11
|
+
this.renderChart()
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
renderChart() {
|
|
16
|
+
new Chart(this.element, {
|
|
17
|
+
data: this.chartData,
|
|
18
|
+
type: "line",
|
|
19
|
+
height: 200,
|
|
20
|
+
colors: this.colors,
|
|
21
|
+
lineOptions: { hideDots: 0, dotSize: 3, regionFill: 0 },
|
|
22
|
+
axisOptions: { xAxisMode: "tick", xIsSeries: true },
|
|
23
|
+
tooltipOptions: {
|
|
24
|
+
formatTooltipX: d => this.tooltipLabels[this.labels.indexOf(d)] || d,
|
|
25
|
+
formatTooltipY: d => d ? `${d.toFixed(3)}s` : null
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get chartData() {
|
|
31
|
+
return { labels: this.labels, datasets: this.datasets }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get labels() {
|
|
35
|
+
return this._labels ||= this.results.map(r => this.formatTime(r.created_at))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get tooltipLabels() {
|
|
39
|
+
return this._tooltipLabels ||= this.results.map(r => `${r.probe_name} @ ${this.formatTime(r.created_at)}`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get datasets() {
|
|
43
|
+
const datasets = []
|
|
44
|
+
if (this.hasOkResults) datasets.push({ name: "Ok", values: this.okData, chartType: "line" })
|
|
45
|
+
if (this.hasFailResults) datasets.push({ name: "Fail", values: this.failData, chartType: "line" })
|
|
46
|
+
return datasets
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get colors() {
|
|
50
|
+
const colors = []
|
|
51
|
+
if (this.hasOkResults) colors.push(this.isDarkMode ? "#4ade80" : "#22c55e")
|
|
52
|
+
if (this.hasFailResults) colors.push(this.isDarkMode ? "#f87171" : "#ef4444")
|
|
53
|
+
return colors
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get okData() {
|
|
57
|
+
return this._okData ||= this.results.map(r => r.status === "ok" ? r.duration : null)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get failData() {
|
|
61
|
+
return this._failData ||= this.results.map(r => r.status !== "ok" ? r.duration : null)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get hasOkResults() {
|
|
65
|
+
return this.okData.some(d => d !== null)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get hasFailResults() {
|
|
69
|
+
return this.failData.some(d => d !== null)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get isDarkMode() {
|
|
73
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
formatTime(timestamp) {
|
|
77
|
+
return new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["popover"]
|
|
5
|
+
|
|
6
|
+
openArtifact(event) {
|
|
7
|
+
if (event.target.closest("details, a, button")) return
|
|
8
|
+
|
|
9
|
+
const row = event.target.closest("tr")
|
|
10
|
+
const popover = this.popoverTargets.find(p => row.contains(p))
|
|
11
|
+
|
|
12
|
+
if (popover) {
|
|
13
|
+
popover.open = true
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import * as L from "leaflet"
|
|
3
|
+
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
static values = { sites: Array }
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
const map = L.map(this.element, { scrollWheelZoom: false }).setView([30, 0], 2)
|
|
9
|
+
|
|
10
|
+
L.tileLayer(this.tileUrl, { attribution: this.attribution }).addTo(map)
|
|
11
|
+
|
|
12
|
+
for (const site of this.sitesValue) {
|
|
13
|
+
if (!site.lat || !site.lon) continue
|
|
14
|
+
|
|
15
|
+
L.marker([site.lat, site.lon])
|
|
16
|
+
.addTo(map)
|
|
17
|
+
.bindPopup(`<strong>${site.hostname}</strong><br>${site.city}`)
|
|
18
|
+
.on("mouseover", function() { this.openPopup() })
|
|
19
|
+
.on("mouseout", function() { this.closePopup() })
|
|
20
|
+
.on("click", () => window.location.href = site.url)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get tileUrl() {
|
|
25
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
26
|
+
? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
|
27
|
+
: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get attribution() {
|
|
31
|
+
return '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>'
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
class Upright::ProbeCheckJob < Upright::ApplicationJob
|
|
2
|
+
MAX_ATTEMPTS = 2
|
|
3
|
+
RETRY_DELAY = 5.seconds
|
|
4
|
+
|
|
5
|
+
before_enqueue { self.scheduled_at = stagger_delay.from_now }
|
|
6
|
+
before_perform :discard_if_stale
|
|
7
|
+
around_perform { |job, block| Timeout.timeout(3.minutes, &block) }
|
|
8
|
+
|
|
9
|
+
def perform(klass, name = nil)
|
|
10
|
+
result = resolve_probe(klass, name).check_and_record
|
|
11
|
+
|
|
12
|
+
if !result.ok? && executions < MAX_ATTEMPTS
|
|
13
|
+
retry_job(wait: RETRY_DELAY)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
def stagger_delay
|
|
19
|
+
arguments[0].constantize.stagger_delay
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def discard_if_stale
|
|
23
|
+
if scheduled_at && scheduled_at < 5.minutes.ago
|
|
24
|
+
logger.info "Discarding stale probe job scheduled at #{scheduled_at}"
|
|
25
|
+
throw :abort
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def resolve_probe(klass, name)
|
|
30
|
+
klass = klass.safe_constantize
|
|
31
|
+
|
|
32
|
+
instance = if klass < FrozenRecord::Base
|
|
33
|
+
klass.find_by(name: name)
|
|
34
|
+
else
|
|
35
|
+
klass.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
instance.logger = logger
|
|
39
|
+
|
|
40
|
+
instance
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Upright::ExceptionRecording
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
EXCEPTION_FILENAME = "exception.txt"
|
|
5
|
+
|
|
6
|
+
included do
|
|
7
|
+
attr_accessor :error
|
|
8
|
+
|
|
9
|
+
after_create :attach_exception
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def attach_exception
|
|
13
|
+
if error
|
|
14
|
+
artifacts.attach(
|
|
15
|
+
io: StringIO.new(format_exception(error)),
|
|
16
|
+
filename: EXCEPTION_FILENAME,
|
|
17
|
+
content_type: "text/plain"
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def exception_artifact
|
|
23
|
+
artifacts.find { |a| a.filename.to_s == EXCEPTION_FILENAME }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def exception_report
|
|
27
|
+
exception_artifact&.download
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
def format_exception(exception)
|
|
32
|
+
lines = [ "#{exception.class}: #{exception.message}" ]
|
|
33
|
+
if exception.backtrace
|
|
34
|
+
lines.concat(exception.backtrace.first(20).map { |line| " #{line}" })
|
|
35
|
+
end
|
|
36
|
+
lines.join("\n")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Upright::Playwright::FormAuthentication
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
included do
|
|
5
|
+
class_attribute :authentication_service
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
class_methods do
|
|
9
|
+
def authenticate_with_form(service)
|
|
10
|
+
self.authentication_service = service
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
def authenticated_context(browser, context_options = {})
|
|
16
|
+
if authentication_service
|
|
17
|
+
authenticator_for(authentication_service).new(browser, context_options).authenticated_context
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def authenticator_for(service)
|
|
22
|
+
# First try the host app's authenticator, then fall back to engine's
|
|
23
|
+
"::Playwright::Authenticator::#{service.to_s.camelize}".constantize
|
|
24
|
+
rescue NameError
|
|
25
|
+
"Upright::Playwright::Authenticator::#{service.to_s.camelize}".constantize
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Upright::Playwright::Lifecycle
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
DEFAULT_USER_AGENT = "Upright/1.0 Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
|
5
|
+
|
|
6
|
+
included do
|
|
7
|
+
attr_accessor :context, :page
|
|
8
|
+
|
|
9
|
+
define_callbacks :page_ready
|
|
10
|
+
define_callbacks :page_close
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def user_agent
|
|
14
|
+
Upright.configuration.user_agent.presence || DEFAULT_USER_AGENT
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
def with_browser(&block)
|
|
19
|
+
if ENV["LOCAL_PLAYWRIGHT"]
|
|
20
|
+
::Playwright.create(playwright_cli_executable_path: "./node_modules/.bin/playwright") do |playwright|
|
|
21
|
+
playwright.chromium.launch(headless: false, &block)
|
|
22
|
+
end
|
|
23
|
+
else
|
|
24
|
+
server_url = Upright.configuration.playwright_server_url ||
|
|
25
|
+
Rails.application.config_for(:playwright).fetch(:server_url)
|
|
26
|
+
::Playwright.connect_to_browser_server(server_url, &block)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def with_context(browser, **options, &block)
|
|
31
|
+
self.context = create_context(browser, **options)
|
|
32
|
+
self.page = context.new_page
|
|
33
|
+
run_callbacks :page_ready
|
|
34
|
+
yield
|
|
35
|
+
ensure
|
|
36
|
+
page&.close
|
|
37
|
+
context&.close
|
|
38
|
+
run_callbacks :page_close
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def create_context(browser, **options)
|
|
42
|
+
authenticated_context(browser, options) || browser.new_context(userAgent: user_agent, **options)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
module Upright::Playwright::Logging
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
included do
|
|
5
|
+
attr_reader :log_lines
|
|
6
|
+
|
|
7
|
+
set_callback :perform_check, :before, :start_resource_logging
|
|
8
|
+
set_callback :perform_check, :after, :log_performance_metrics
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def start_resource_logging
|
|
12
|
+
@log_lines = []
|
|
13
|
+
|
|
14
|
+
# Use structured logging if available, otherwise just log normally
|
|
15
|
+
if defined?(RailsStructuredLogging::Recorder)
|
|
16
|
+
RailsStructuredLogging::Recorder.instance.messages.tap do |messages|
|
|
17
|
+
page.on("response", ->(response) {
|
|
18
|
+
next if skip_logging?(response)
|
|
19
|
+
RailsStructuredLogging::Recorder.instance.sharing(messages)
|
|
20
|
+
log_response(response)
|
|
21
|
+
})
|
|
22
|
+
end
|
|
23
|
+
else
|
|
24
|
+
page.on("response", ->(response) {
|
|
25
|
+
next if skip_logging?(response)
|
|
26
|
+
log_response(response)
|
|
27
|
+
})
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def log_response(response)
|
|
32
|
+
headers = response.headers.slice("x-request-id", "x-runtime", "x-ratelimit-limit", "x-ratelimit-remaining").compact.map { |k, v| "#{k}=#{v}" }.join(" ")
|
|
33
|
+
"#{response.status} #{response.request.resource_type.upcase} #{response.url} #{headers}".strip.tap do |line|
|
|
34
|
+
log_lines << line
|
|
35
|
+
Rails.logger.info line
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def log_performance_metrics
|
|
40
|
+
current_site = Upright.current_site
|
|
41
|
+
|
|
42
|
+
log_metrics url: page.url,
|
|
43
|
+
total_resource_bytes: fetch_total_resource_bytes,
|
|
44
|
+
total_load_ms: fetch_total_load_ms,
|
|
45
|
+
site_code: current_site.code,
|
|
46
|
+
site_city: current_site.city,
|
|
47
|
+
site_country: current_site.country,
|
|
48
|
+
site_geohash: current_site.geohash
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def log_metrics(**metrics)
|
|
52
|
+
if logger.respond_to?(:struct)
|
|
53
|
+
logger.struct probe: metrics
|
|
54
|
+
else
|
|
55
|
+
logger.info metrics.to_json
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def fetch_total_resource_bytes
|
|
60
|
+
page.evaluate <<~JS
|
|
61
|
+
performance.getEntriesByType("resource").reduce((size, item) => {
|
|
62
|
+
size += item.decodedBodySize;
|
|
63
|
+
return size;
|
|
64
|
+
}, 0);
|
|
65
|
+
JS
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def fetch_total_load_ms
|
|
69
|
+
page.evaluate <<~JS
|
|
70
|
+
const perfEntries = performance.getEntriesByType("navigation")
|
|
71
|
+
perfEntries[0].loadEventEnd - perfEntries[0].startTime;
|
|
72
|
+
JS
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def attach_log(probe_result)
|
|
76
|
+
if log_lines&.any?
|
|
77
|
+
Upright::Artifact.new(name: "#{probe_name}.log", content: log_lines.join("\n")).attach_to(probe_result, timestamped: true)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
SKIP_URL_PATTERNS = %w[ image asset avatar ]
|
|
83
|
+
|
|
84
|
+
def skip_logging?(response)
|
|
85
|
+
SKIP_URL_PATTERNS.any? { |skip_pattern| response.url.include?(skip_pattern) }
|
|
86
|
+
end
|
|
87
|
+
end
|