commit 0ee365b743862a103271421a20e509c39ca5ca41 Author: rulingcom Date: Fri Aug 28 14:13:18 2026 +0800 health_check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7857328 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.bundle/ +log/*.log +pkg/ +test/dummy/db/*.sqlite3 +test/dummy/log/*.log +test/dummy/tmp/ diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..b4e2a20 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gemspec diff --git a/MIT-LICENSE b/MIT-LICENSE new file mode 100644 index 0000000..a904b58 --- /dev/null +++ b/MIT-LICENSE @@ -0,0 +1,3 @@ +MIT License + +Copyright (c) Ruling Digital diff --git a/README.rdoc b/README.rdoc new file mode 100644 index 0000000..4e1778b --- /dev/null +++ b/README.rdoc @@ -0,0 +1,3 @@ += HealthCheck + +Health check module for orbit-kernel. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..9d30e3e --- /dev/null +++ b/Rakefile @@ -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 diff --git a/app/controllers/admin/health_check_monitors_controller.rb b/app/controllers/admin/health_check_monitors_controller.rb new file mode 100644 index 0000000..381b971 --- /dev/null +++ b/app/controllers/admin/health_check_monitors_controller.rb @@ -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 diff --git a/app/helpers/admin/health_check_monitors_helper.rb b/app/helpers/admin/health_check_monitors_helper.rb new file mode 100644 index 0000000..681d339 --- /dev/null +++ b/app/helpers/admin/health_check_monitors_helper.rb @@ -0,0 +1,2 @@ +module Admin::HealthCheckMonitorsHelper +end diff --git a/app/models/health_check_alert.rb b/app/models/health_check_alert.rb new file mode 100644 index 0000000..f2360bb --- /dev/null +++ b/app/models/health_check_alert.rb @@ -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 diff --git a/app/models/health_check_log.rb b/app/models/health_check_log.rb new file mode 100644 index 0000000..a3cab38 --- /dev/null +++ b/app/models/health_check_log.rb @@ -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 diff --git a/app/models/health_check_monitor.rb b/app/models/health_check_monitor.rb new file mode 100644 index 0000000..ad2e120 --- /dev/null +++ b/app/models/health_check_monitor.rb @@ -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 diff --git a/app/views/admin/health_check_monitors/_form.html.erb b/app/views/admin/health_check_monitors/_form.html.erb new file mode 100644 index 0000000..9e4402f --- /dev/null +++ b/app/views/admin/health_check_monitors/_form.html.erb @@ -0,0 +1,57 @@ +
+ +
+ +
+ <%= f.text_field :name, :class => "input-block-level" %> +
+
+ +
+ +
+ <%= f.text_area :target_urls_text, :rows => 8, :class => "input-block-level", :placeholder => t("health_check_monitor.one_per_line") %> +
+
+ +
+ +
+ <%= f.text_field :notification_emails_text, :class => "input-block-level", :placeholder => t("health_check_monitor.comma_separated") %> +
+
+ +
+ +
+ <%= f.number_field :check_interval %> +
+
+ +
+ +
+ <%= f.number_field :fail_threshold %> +
+
+ +
+ +
+ <%= f.number_field :timeout %> +
+
+ +
+ +
+ <%= f.check_box :enabled %> +
+
+ +
+ +
+ <%= f.submit t(:submit), :class => "btn btn-primary" %> + <%= link_to t(:cancel), admin_health_check_monitors_path, :class => "btn" %> +
diff --git a/app/views/admin/health_check_monitors/_index.html.erb b/app/views/admin/health_check_monitors/_index.html.erb new file mode 100644 index 0000000..c962d10 --- /dev/null +++ b/app/views/admin/health_check_monitors/_index.html.erb @@ -0,0 +1,39 @@ + + + + + + + + + + + + <% @monitors.each do |monitor| %> + + + + + + + + <% end %> + +
<%= t("health_check_monitor.name") %><%= t("health_check_monitor.target_urls") %><%= t("health_check_monitor.check_interval") %><%= t("health_check_monitor.enabled") %><%= t("health_check_monitor.last_checked") %>
+ <%= monitor.name %> +
+ +
+
<%= monitor.target_urls.join("
").html_safe %>
<%= monitor.check_interval %><%= monitor.enabled ? t(:yes_) : t(:no_) %><%= monitor.last_checked_at.blank? ? "-" : monitor.last_checked_at.strftime("%Y-%m-%d %H:%M") %>
+ +<%= + 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 +%> diff --git a/app/views/admin/health_check_monitors/edit.html.erb b/app/views/admin/health_check_monitors/edit.html.erb new file mode 100644 index 0000000..7a48ac0 --- /dev/null +++ b/app/views/admin/health_check_monitors/edit.html.erb @@ -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 %> diff --git a/app/views/admin/health_check_monitors/index.html.erb b/app/views/admin/health_check_monitors/index.html.erb new file mode 100644 index 0000000..c6c8ee4 --- /dev/null +++ b/app/views/admin/health_check_monitors/index.html.erb @@ -0,0 +1 @@ +<%= render :partial => "index" %> diff --git a/app/views/admin/health_check_monitors/logs.html.erb b/app/views/admin/health_check_monitors/logs.html.erb new file mode 100644 index 0000000..d7ec4a2 --- /dev/null +++ b/app/views/admin/health_check_monitors/logs.html.erb @@ -0,0 +1,36 @@ +

<%= @monitor.name %> - <%= t("health_check_monitor.logs") %>

+ +

+ <%= 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" %> +

+ + + + + + + + + + + + + <% @logs.each do |log| %> + + + + + + + + <% end %> + +
URL<%= t("health_check_monitor.status") %><%= t("health_check_monitor.response_time") %>Error<%= t("health_check_monitor.last_checked") %>
<%= log.url %><%= log.status %><%= log.response_time %><%= log.error_message %><%= log.created_at.strftime("%Y-%m-%d %H:%M:%S") %>
+ +
+ +
diff --git a/app/views/admin/health_check_monitors/new.html.erb b/app/views/admin/health_check_monitors/new.html.erb new file mode 100644 index 0000000..4057e3c --- /dev/null +++ b/app/views/admin/health_check_monitors/new.html.erb @@ -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 %> diff --git a/app/views/email/health_check_alert.html.erb b/app/views/email/health_check_alert.html.erb new file mode 100644 index 0000000..e8946e9 --- /dev/null +++ b/app/views/email/health_check_alert.html.erb @@ -0,0 +1,25 @@ + + + + + + +

監控項目:<%= @data['monitor_name'] %>

+

以下 <%= @data['rows'].size %> 個網址連續 <%= @data['threshold'] %> 次檢查失敗:

+ + + + + + + <% @data['rows'].each do |row| %> + + + + + + <% end %> +
網址狀態碼錯誤訊息
<%= row['url'] %><%= row['status'] %><%= row['error'] %>
+

檢查時間:<%= @data['checked_at'] %>

+ + diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 0000000..3f4a640 --- /dev/null +++ b/config/locales/en.yml @@ -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 diff --git a/config/locales/zh_tw.yml b/config/locales/zh_tw.yml new file mode 100644 index 0000000..eb455fe --- /dev/null +++ b/config/locales/zh_tw.yml @@ -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: 以逗號分隔 diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..5f9446c --- /dev/null +++ b/config/routes.rb @@ -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 diff --git a/health_check.gemspec b/health_check.gemspec new file mode 100644 index 0000000..1d14ac4 --- /dev/null +++ b/health_check.gemspec @@ -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 diff --git a/lib/health_check.rb b/lib/health_check.rb new file mode 100644 index 0000000..32c5d8b --- /dev/null +++ b/lib/health_check.rb @@ -0,0 +1,4 @@ +require "health_check/engine" + +module HealthCheck +end diff --git a/lib/health_check/engine.rb b/lib/health_check/engine.rb new file mode 100644 index 0000000..2f4dc21 --- /dev/null +++ b/lib/health_check/engine.rb @@ -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 diff --git a/lib/health_check/version.rb b/lib/health_check/version.rb new file mode 100644 index 0000000..96bd98c --- /dev/null +++ b/lib/health_check/version.rb @@ -0,0 +1,3 @@ +module HealthCheck + VERSION = "0.0.1" +end diff --git a/lib/tasks/health_check_tasks.rake b/lib/tasks/health_check_tasks.rake new file mode 100644 index 0000000..ed93a34 --- /dev/null +++ b/lib/tasks/health_check_tasks.rake @@ -0,0 +1,3 @@ +# desc "Explaining what the task does" +# task :health_check do +# end