health_check

This commit is contained in:
rulingcom 2026-08-28 14:13:18 +08:00
commit 0ee365b743
25 changed files with 545 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.bundle/
log/*.log
pkg/
test/dummy/db/*.sqlite3
test/dummy/log/*.log
test/dummy/tmp/

3
Gemfile Normal file
View File

@ -0,0 +1,3 @@
source "https://rubygems.org"
gemspec

3
MIT-LICENSE Normal file
View File

@ -0,0 +1,3 @@
MIT License
Copyright (c) Ruling Digital

3
README.rdoc Normal file
View File

@ -0,0 +1,3 @@
= HealthCheck
Health check module for orbit-kernel.

7
Rakefile Normal file
View File

@ -0,0 +1,7 @@
begin
require "bundler/setup"
rescue LoadError
puts "You must `gem install bundler` and `bundle install` to run rake tasks"
end
Bundler::GemHelper.install_tasks

View File

@ -0,0 +1,79 @@
class Admin::HealthCheckMonitorsController < OrbitAdminController
before_action ->(module_app = @app_title) { set_variables module_app }
before_action :set_monitor, only: [:show, :edit, :update, :destroy, :manual_check, :logs, :clean_logs]
def index
@monitors = HealthCheckMonitor.all.desc(:created_at).page(params[:page]).per(10)
render :partial => "index" if request.xhr?
end
def show
redirect_to logs_admin_health_check_monitor_path(@monitor)
end
def new
session[:return_to] = request.referer
@monitor = HealthCheckMonitor.new
end
def create
@monitor = HealthCheckMonitor.new(monitor_params)
@monitor.create_user_id = current_user.id
@monitor.update_user_id = current_user.id
@monitor.save
redirect_to session.delete(:return_to) || admin_health_check_monitors_path
end
def edit
session[:return_to] = request.referer
end
def update
@monitor.update_attributes(monitor_params)
@monitor.update_user_id = current_user.id
@monitor.save
redirect_to session.delete(:return_to) || admin_health_check_monitors_path
end
def destroy
@monitor.destroy
redirect_to admin_health_check_monitors_path
end
def manual_check
@results = @monitor.check_all
alert_logs = []
@results.each do |url, result|
HealthCheckLog.create(
:health_check_monitor_id => @monitor.id,
:url => url,
:status => result[:status],
:response_time => result[:response_time],
:error_message => result[:error]
)
target = @monitor.alert_target(url)
alert_logs << target if target
end
@monitor.send_alert_email(alert_logs) if alert_logs.present?
redirect_to logs_admin_health_check_monitor_path(@monitor)
end
def logs
@logs = HealthCheckLog.where(:health_check_monitor_id => @monitor.id).desc(:created_at).page(params[:page]).per(20)
end
def clean_logs
HealthCheckLog.where(:health_check_monitor_id => @monitor.id).destroy_all
redirect_to logs_admin_health_check_monitor_path(@monitor)
end
private
def set_monitor
@monitor = HealthCheckMonitor.find(params[:id])
end
def monitor_params
params.require(:health_check_monitor).permit!
end
end

View File

@ -0,0 +1,2 @@
module Admin::HealthCheckMonitorsHelper
end

View File

@ -0,0 +1,10 @@
# encoding: utf-8
class HealthCheckAlert
include Mongoid::Document
include Mongoid::Timestamps
field :health_check_monitor_id, type: BSON::ObjectId
field :url, type: String
index({ health_check_monitor_id: 1, url: 1, created_at: -1 })
end

View File

@ -0,0 +1,14 @@
# encoding: utf-8
class HealthCheckLog
include Mongoid::Document
include Mongoid::Timestamps
field :url, type: String
field :status, type: Integer
field :response_time, type: Float
field :error_message, type: String
belongs_to :health_check_monitor, class_name: "HealthCheckMonitor", optional: true
index({ health_check_monitor_id: 1, created_at: -1 })
end

View File

@ -0,0 +1,140 @@
# encoding: utf-8
require 'net/http'
require 'uri'
class HealthCheckMonitor
include Mongoid::Document
include Mongoid::Timestamps
# 視為失敗的狀態碼。0 代表連不上逾時、DNS 失敗、憑證錯誤等)
# 若只要 502 才算失敗,改成 [502]
FAILURE_STATUSES = [0, 500, 502, 503, 504]
# 告警視窗。只看這段時間內的檢查紀錄,同一網址在這段時間內也只寄一封
ALERT_WINDOW = 4.hours
field :name, type: String
field :target_urls, type: Array, default: []
field :notification_emails, type: Array, default: []
field :check_interval, type: Integer, default: 300
field :fail_threshold, type: Integer, default: 3
field :timeout, type: Integer, default: 10
field :enabled, type: Boolean, default: true
field :last_checked_at, type: DateTime
field :create_user_id
field :update_user_id
validates :name, presence: true
scope :is_enabled, -> { where(enabled: true) }
def target_urls_text
(target_urls || []).join("\n")
end
def target_urls_text=(value)
self.target_urls = value.to_s.split(/\r?\n/).map(&:strip).reject(&:blank?).map do |u|
u.match(/\Ahttps?:\/\//) ? u : "https://#{u}"
end
end
def notification_emails_text
(notification_emails || []).join(", ")
end
def notification_emails_text=(value)
self.notification_emails = value.to_s.split(",").map(&:strip).reject(&:blank?)
end
def threshold
fail_threshold.to_i > 0 ? fail_threshold.to_i : 3
end
def check_all
results = {}
(target_urls || []).each do |url|
results[url] = check_url(url)
end
self.last_checked_at = Time.now
self.save
results
end
def check_url(url)
uri = URI.parse(url)
started = Time.now
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == 'https')
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
http.open_timeout = self.timeout
http.read_timeout = self.timeout
response = http.request(Net::HTTP::Get.new(uri.request_uri))
elapsed = ((Time.now - started) * 1000).round(2)
{ :status => response.code.to_i, :response_time => elapsed, :error => nil }
rescue => e
{ :status => 0, :response_time => 0, :error => e.message }
end
def failure?(status)
FAILURE_STATUSES.include?(status.to_i)
end
# 這次檢查寫完 log 之後呼叫。
# 條件4 小時內已檢查滿 threshold 次、最近 threshold 次全部失敗、且 4 小時內還沒寄過這個網址
def alert_target(url)
n = threshold
window_start = Time.now - ALERT_WINDOW
logs = HealthCheckLog.where(:health_check_monitor_id => self.id,
:url => url,
:created_at.gte => window_start)
.desc(:created_at).limit(n).to_a
return nil if logs.size < n
return nil unless logs.all? { |log| failure?(log.status) }
return nil if alerted_recently?(url)
logs.first
end
def alerted_recently?(url)
HealthCheckAlert.where(:health_check_monitor_id => self.id,
:url => url,
:created_at.gte => Time.now - ALERT_WINDOW).exists?
end
def mark_alerted(url)
HealthCheckAlert.create(:health_check_monitor_id => self.id, :url => url)
end
# logs 是這次剛好達標的 HealthCheckLog 陣列,全部合併成一封
def send_alert_email(logs)
return false if notification_emails.blank? || logs.blank?
rows = logs.map do |log|
{
'url' => log.url.to_s,
'status' => log.status.to_s,
'error' => log.error_message.to_s
}
end
email = Email.new(
:module_app_key => 'health_check',
:mail_to => notification_emails,
:mail_subject => "[網站監控] #{self.name} - #{logs.size} 個網址失敗",
:template => 'email/health_check_alert.html.erb',
:template_data => {
'monitor_name' => self.name.to_s,
'threshold' => threshold,
'rows' => rows,
'checked_at' => logs.first.created_at.strftime('%Y-%m-%d %H:%M:%S')
},
:mail_sentdate => Time.now - 1.minute
)
email.save
begin
email.deliver
logs.each { |log| mark_alerted(log.url) }
true
rescue => e
Rails.logger.error("[health_check] 寄信失敗: #{e}")
false
end
end
end

View File

@ -0,0 +1,57 @@
<div class="input-area">
<div class="control-group">
<label class="control-label muted"><%= t("health_check_monitor.name") %></label>
<div class="controls">
<%= f.text_field :name, :class => "input-block-level" %>
</div>
</div>
<div class="control-group">
<label class="control-label muted"><%= t("health_check_monitor.target_urls") %></label>
<div class="controls">
<%= f.text_area :target_urls_text, :rows => 8, :class => "input-block-level", :placeholder => t("health_check_monitor.one_per_line") %>
</div>
</div>
<div class="control-group">
<label class="control-label muted"><%= t("health_check_monitor.notification_emails") %></label>
<div class="controls">
<%= f.text_field :notification_emails_text, :class => "input-block-level", :placeholder => t("health_check_monitor.comma_separated") %>
</div>
</div>
<div class="control-group">
<label class="control-label muted"><%= t("health_check_monitor.check_interval") %></label>
<div class="controls">
<%= f.number_field :check_interval %>
</div>
</div>
<div class="control-group">
<label class="control-label muted"><%= t("health_check_monitor.fail_threshold") %></label>
<div class="controls">
<%= f.number_field :fail_threshold %>
</div>
</div>
<div class="control-group">
<label class="control-label muted"><%= t("health_check_monitor.timeout") %></label>
<div class="controls">
<%= f.number_field :timeout %>
</div>
</div>
<div class="control-group">
<label class="control-label muted"><%= t("health_check_monitor.enabled") %></label>
<div class="controls">
<%= f.check_box :enabled %>
</div>
</div>
</div>
<div class="form-actions">
<%= f.submit t(:submit), :class => "btn btn-primary" %>
<%= link_to t(:cancel), admin_health_check_monitors_path, :class => "btn" %>
</div>

View File

@ -0,0 +1,39 @@
<table class="table main-list">
<thead>
<tr>
<th><%= t("health_check_monitor.name") %></th>
<th><%= t("health_check_monitor.target_urls") %></th>
<th><%= t("health_check_monitor.check_interval") %></th>
<th><%= t("health_check_monitor.enabled") %></th>
<th><%= t("health_check_monitor.last_checked") %></th>
</tr>
</thead>
<tbody>
<% @monitors.each do |monitor| %>
<tr>
<td>
<%= monitor.name %>
<div class="quick-edit">
<ul class="nav nav-pills">
<li><%= link_to t(:edit), edit_admin_health_check_monitor_path(monitor) %></li>
<li><%= link_to t("health_check_monitor.logs"), logs_admin_health_check_monitor_path(monitor) %></li>
<li><%= link_to t("health_check_monitor.manual_check"), manual_check_admin_health_check_monitor_path(monitor), :method => :post %></li>
<li><%= link_to t(:delete_), admin_health_check_monitor_path(monitor), :method => :delete, :data => { :confirm => t(:sure?) } %></li>
</ul>
</div>
</td>
<td><%= monitor.target_urls.join("<br/>").html_safe %></td>
<td><%= monitor.check_interval %></td>
<td><%= monitor.enabled ? t(:yes_) : t(:no_) %></td>
<td><%= monitor.last_checked_at.blank? ? "-" : monitor.last_checked_at.strftime("%Y-%m-%d %H:%M") %></td>
</tr>
<% end %>
</tbody>
</table>
<%=
content_tag :div, class: "bottomnav clearfix" do
content_tag(:div, paginate(@monitors), class: "pagination pagination-centered") +
content_tag(:div, link_to(t(:new_), new_admin_health_check_monitor_path, :class=>"btn btn-primary"), class: "pull-right")
end
%>

View File

@ -0,0 +1,3 @@
<%= form_for @monitor, :url => admin_health_check_monitor_path(@monitor), :html => { :class => "form-horizontal main-forms" } do |f| %>
<%= render :partial => "form", :locals => { :f => f } %>
<% end %>

View File

@ -0,0 +1 @@
<%= render :partial => "index" %>

View File

@ -0,0 +1,36 @@
<h3><%= @monitor.name %> - <%= t("health_check_monitor.logs") %></h3>
<p>
<%= link_to t("health_check_monitor.manual_check"), manual_check_admin_health_check_monitor_path(@monitor), :method => :post, :class => "btn btn-primary" %>
<%= link_to t("health_check_monitor.clean_logs"), clean_logs_admin_health_check_monitor_path(@monitor), :method => :post, :class => "btn", :data => { :confirm => t(:sure?) } %>
<%= link_to t(:back), admin_health_check_monitors_path, :class => "btn" %>
</p>
<table class="table main-list">
<thead>
<tr>
<th>URL</th>
<th><%= t("health_check_monitor.status") %></th>
<th><%= t("health_check_monitor.response_time") %></th>
<th>Error</th>
<th><%= t("health_check_monitor.last_checked") %></th>
</tr>
</thead>
<tbody>
<% @logs.each do |log| %>
<tr>
<td><%= log.url %></td>
<td><%= log.status %></td>
<td><%= log.response_time %></td>
<td><%= log.error_message %></td>
<td><%= log.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
</tr>
<% end %>
</tbody>
</table>
<div class="bottomnav clearfix">
<div class="pagination pagination-centered">
<%= paginate @logs %>
</div>
</div>

View File

@ -0,0 +1,3 @@
<%= form_for @monitor, :url => admin_health_check_monitors_path, :html => { :class => "form-horizontal main-forms" } do |f| %>
<%= render :partial => "form", :locals => { :f => f } %>
<% end %>

View File

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<meta content='text/html; charset=UTF-8' http-equiv='Content-Type' />
</head>
<body>
<p>監控項目:<%= @data['monitor_name'] %></p>
<p>以下 <%= @data['rows'].size %> 個網址連續 <%= @data['threshold'] %> 次檢查失敗:</p>
<table border="1" cellpadding="6" style="border-collapse:collapse">
<tr>
<th>網址</th>
<th>狀態碼</th>
<th>錯誤訊息</th>
</tr>
<% @data['rows'].each do |row| %>
<tr>
<td><%= row['url'] %></td>
<td><%= row['status'] %></td>
<td><%= row['error'] %></td>
</tr>
<% end %>
</table>
<p>檢查時間:<%= @data['checked_at'] %></p>
</body>
</html>

18
config/locales/en.yml Normal file
View File

@ -0,0 +1,18 @@
en:
health_check: Health Check
health_check_monitor:
name: Name
target_urls: Target URLs
check_interval: Check Interval (sec)
fail_threshold: Alert After N Consecutive Failures
timeout: Timeout (sec)
notification_emails: Notification Emails
enabled: Enabled
status: Status
response_time: Response Time (ms)
last_checked: Last Checked
manual_check: Check Now
logs: Logs
clean_logs: Clear All Logs
one_per_line: One URL per line, must include http:// or https:// (defaults to https://)
comma_separated: Comma separated

18
config/locales/zh_tw.yml Normal file
View File

@ -0,0 +1,18 @@
zh_tw:
health_check: 健康檢查
health_check_monitor:
name: 監控名稱
target_urls: 監控網址
check_interval: 檢查間隔(秒)
fail_threshold: 連續失敗幾次寄信
timeout: 逾時(秒)
notification_emails: 通知信箱
enabled: 啟用
status: 狀態
response_time: 回應時間(ms)
last_checked: 最後檢查
manual_check: 立即檢查
logs: 檢查紀錄
clean_logs: 清除全部紀錄
one_per_line: 一行一個網址,需含 http:// 或 https://(未填會自動補 https://
comma_separated: 以逗號分隔

17
config/routes.rb Normal file
View File

@ -0,0 +1,17 @@
Rails.application.routes.draw do
locales = Site.find_by(site_active: true).in_use_locales rescue I18n.available_locales
scope "(:locale)", locale: Regexp.new(locales.join("|")) do
namespace :admin do
resources :health_check_monitors do
member do
post :manual_check
get :logs
post :clean_logs
end
end
end
end
end

18
health_check.gemspec Normal file
View File

@ -0,0 +1,18 @@
$:.push File.expand_path("../lib", __FILE__)
# Maintain your gem's version:
require "health_check/version"
# Describe your gem and declare its dependencies:
Gem::Specification.new do |s|
s.name = "health_check"
s.version = HealthCheck::VERSION
s.authors = ["Orbitek"]
s.email = ["service@orbitek.co"]
s.homepage = "https://orbitek.co"
s.summary = "Health Check for orbitek"
s.description = "Health Check for orbitek"
s.license = "MIT"
s.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.rdoc"]
end

4
lib/health_check.rb Normal file
View File

@ -0,0 +1,4 @@
require "health_check/engine"
module HealthCheck
end

View File

@ -0,0 +1,33 @@
module HealthCheck
class Engine < ::Rails::Engine
initializer "health_check" do
Rails.application.config.to_prepare do
OrbitApp.registration "HealthCheck", :type => "ModuleApp" do
module_label "health_check.health_check"
base_url File.expand_path File.dirname(__FILE__)
authorizable
side_bar do
head_label_i18n 'health_check', :icon_class=>"icons-cycle"
available_for "users"
active_for_controllers (['admin/health_check_monitors'])
head_link_path "admin_health_check_monitors_path"
context_link 'list_',
:link_path=>"admin_health_check_monitors_path" ,
:priority=>1,
:active_for_action=>{'admin/health_check_monitors'=>'index'},
:available_for => 'users'
context_link 'new_',
:link_path=>"new_admin_health_check_monitor_path" ,
:priority=>2,
:active_for_action=>{'admin/health_check_monitors'=>'new'},
:available_for => 'sub_managers'
end
end
end
end
end
end

View File

@ -0,0 +1,3 @@
module HealthCheck
VERSION = "0.0.1"
end

View File

@ -0,0 +1,3 @@
# desc "Explaining what the task does"
# task :health_check do
# end