diff --git a/.env.sample b/.env.sample index a6e0f97aa..93edb89a0 100644 --- a/.env.sample +++ b/.env.sample @@ -20,3 +20,4 @@ APP_HOST=localhost:3000 RAILS_SERVE_STATIC_FILES=true ORGANIZATION_NAME=A Window Between Worlds REPLY_TO_EMAIL=umberto.user@example.com +BLAZER_DATABASE_URL=db_url # Optional if you want to use a different db for Blazer diff --git a/Gemfile b/Gemfile index cdd06c41f..9d088be3b 100644 --- a/Gemfile +++ b/Gemfile @@ -42,6 +42,9 @@ gem "groupdate" # Charts and graphs gem "chartkick" +# Business intelligence for database queries +gem "blazer" + # Geocoding for charts and other features gem "geocoder" diff --git a/Gemfile.lock b/Gemfile.lock index 6df8d0773..f5b1a8b6f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -131,6 +131,12 @@ GEM rack (>= 0.9.0) rouge (>= 1.0.0) bigdecimal (4.0.1) + blazer (3.3.0) + activerecord (>= 7.1) + chartkick (>= 5) + csv + railties (>= 7.1) + safely_block (>= 0.4) bootsnap (1.22.0) msgpack (~> 1.2) brakeman (8.0.2) @@ -603,6 +609,7 @@ DEPENDENCIES bcrypt (= 3.1.16) better_errors binding_of_caller! + blazer bootsnap brakeman (~> 8.0.1) bullet @@ -689,6 +696,7 @@ CHECKSUMS better_errors (2.10.1) sha256=f798f1bac93f3e775925b7fcb24cffbcf0bb62ee2210f5350f161a6b75fc0a73 bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 binding_of_caller (1.0.1) + blazer (3.3.0) sha256=01e151091ce1e7d27c156243916b2f13109ef2ef1a16cfa62bef67f4c5fd169f bootsnap (1.22.0) sha256=5820c9d42c2efef095bee6565484bdc511f1223bf950140449c9385ae775793e brakeman (8.0.2) sha256=7b02065ce8b1de93949cefd3f2ad78e8eb370e644b95c8556a32a912a782426a builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f diff --git a/config/blazer.yml b/config/blazer.yml new file mode 100644 index 000000000..5bdb4571e --- /dev/null +++ b/config/blazer.yml @@ -0,0 +1,79 @@ +# see https://github.com/ankane/blazer for more info + +data_sources: + main: + url: <%= ENV["BLAZER_DATABASE_URL"].presence || ENV["DATABASE_URL"].presence || "trilogy://root@127.0.0.1/awbw_development" %> + + # statement timeout, in seconds + # none by default + # timeout: 15 + + # caching settings + # can greatly improve speed + # off by default + # cache: + # mode: slow # or all + # expires_in: 60 # min + # slow_threshold: 15 # sec, only used in slow mode + + # wrap queries in a transaction for safety + # not necessary if you use a read-only user + # true by default + # use_transaction: false + + smart_variables: + # zone_id: "SELECT id, name FROM zones ORDER BY name ASC" + # period: ["day", "week", "month"] + # status: {0: "Active", 1: "Archived"} + + linked_columns: + # user_id: "/admin/users/{value}" + + smart_columns: + # user_id: "SELECT id, name FROM users WHERE id IN {value}" + +# create audits +audit: true + +# change the time zone +# time_zone: "Pacific Time (US & Canada)" + +# class name of the user model +user_class: User + +# method name for the current user +user_method: current_user + +# method name for the display name +user_name: email + +# custom before_action to use for auth +# auth handled via Devise route constraint in routes.rb + +# email to send checks from +# from_email: blazer@example.org + +# webhook for Slack +# slack_webhook_url: <%= ENV["BLAZER_SLACK_WEBHOOK_URL"] %> + +check_schedules: + - "1 day" + - "1 hour" + - "5 minutes" + +# enable anomaly detection +# note: with trend, time series are sent to https://trendapi.org +# anomaly_checks: prophet / trend / anomaly_detection + +# enable forecasting +# note: with trend, time series are sent to https://trendapi.org +# forecasting: prophet / trend + +# enable map +# mapbox_access_token: <%= ENV["MAPBOX_ACCESS_TOKEN"] %> + +# enable uploads +# uploads: +# url: <%= ENV["BLAZER_UPLOADS_URL"] %> +# schema: uploads +# data_source: main diff --git a/config/initializers/blazer.rb b/config/initializers/blazer.rb new file mode 100644 index 000000000..96570c410 --- /dev/null +++ b/config/initializers/blazer.rb @@ -0,0 +1,14 @@ +# Blazer authentication configuration +# Only allow super_users to access Blazer +Rails.application.config.to_prepare do + Blazer::BaseController.class_eval do + before_action :authenticate_user! + before_action :require_blazer_access + + private + + def require_blazer_access + redirect_to root_path, alert: "You are not authorized to access this page." unless current_user&.super_user? + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 2c19c6d22..90a47df0d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,6 +18,9 @@ # mount Ckeditor::Engine, at: '/admin/ckeditor', as: 'ckeditor' apipie + authenticate :user, ->(user) { user.super_user? } do + mount Blazer::Engine, at: "blazer" + end devise_for :users, controllers: { registrations: "registrations", confirmations: "confirmations", diff --git a/db/migrate/20260215064655_install_blazer.rb b/db/migrate/20260215064655_install_blazer.rb new file mode 100644 index 000000000..f0e55b43f --- /dev/null +++ b/db/migrate/20260215064655_install_blazer.rb @@ -0,0 +1,47 @@ +class InstallBlazer < ActiveRecord::Migration[8.1] + def change + create_table :blazer_queries do |t| + t.references :creator, type: :integer, foreign_key: { to_table: :users } + t.string :name + t.text :description + t.text :statement + t.string :data_source + t.string :status + t.timestamps null: false + end + + create_table :blazer_audits do |t| + t.references :user, type: :integer, foreign_key: { to_table: :users } + t.references :query, foreign_key: { to_table: :blazer_queries } + t.text :statement + t.string :data_source + t.timestamp :created_at, null: false + end + + create_table :blazer_dashboards do |t| + t.references :creator, type: :integer, foreign_key: { to_table: :users } + t.string :name + t.timestamps null: false + end + + create_table :blazer_dashboard_queries do |t| + t.references :dashboard, foreign_key: { to_table: :blazer_dashboards } + t.references :query, foreign_key: { to_table: :blazer_queries } + t.integer :position + t.timestamps null: false + end + + create_table :blazer_checks do |t| + t.references :creator, type: :integer, foreign_key: { to_table: :users } + t.references :query, foreign_key: { to_table: :blazer_queries } + t.string :state + t.string :schedule + t.text :emails + t.text :slack_channels + t.string :check_type + t.text :message + t.datetime :last_run_at + t.timestamps null: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 68b9fc3c4..851a6ebed 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_02_14_230901) do +ActiveRecord::Schema[8.1].define(version: 2026_02_16_120927) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false @@ -202,6 +202,62 @@ t.index ["updated_by_id"], name: "index_banners_on_updated_by_id" end + create_table "blazer_audits", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.timestamp "created_at", null: false + t.string "data_source" + t.bigint "query_id" + t.text "statement" + t.integer "user_id" + t.index ["query_id"], name: "index_blazer_audits_on_query_id" + t.index ["user_id"], name: "index_blazer_audits_on_user_id" + end + + create_table "blazer_checks", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.string "check_type" + t.datetime "created_at", null: false + t.integer "creator_id" + t.text "emails" + t.datetime "last_run_at" + t.text "message" + t.bigint "query_id" + t.string "schedule" + t.text "slack_channels" + t.string "state" + t.datetime "updated_at", null: false + t.index ["creator_id"], name: "index_blazer_checks_on_creator_id" + t.index ["query_id"], name: "index_blazer_checks_on_query_id" + end + + create_table "blazer_dashboard_queries", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "dashboard_id" + t.integer "position" + t.bigint "query_id" + t.datetime "updated_at", null: false + t.index ["dashboard_id"], name: "index_blazer_dashboard_queries_on_dashboard_id" + t.index ["query_id"], name: "index_blazer_dashboard_queries_on_query_id" + end + + create_table "blazer_dashboards", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "creator_id" + t.string "name" + t.datetime "updated_at", null: false + t.index ["creator_id"], name: "index_blazer_dashboards_on_creator_id" + end + + create_table "blazer_queries", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "creator_id" + t.string "data_source" + t.text "description" + t.string "name" + t.text "statement" + t.string "status" + t.datetime "updated_at", null: false + t.index ["creator_id"], name: "index_blazer_queries_on_creator_id" + end + create_table "bookmark_annotations", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.text "annotation", size: :long t.integer "bookmark_id" @@ -1164,6 +1220,14 @@ add_foreign_key "age_ranges", "windows_types" add_foreign_key "banners", "users", column: "created_by_id" add_foreign_key "banners", "users", column: "updated_by_id" + add_foreign_key "blazer_audits", "blazer_queries", column: "query_id" + add_foreign_key "blazer_audits", "users" + add_foreign_key "blazer_checks", "blazer_queries", column: "query_id" + add_foreign_key "blazer_checks", "users", column: "creator_id" + add_foreign_key "blazer_dashboard_queries", "blazer_dashboards", column: "dashboard_id" + add_foreign_key "blazer_dashboard_queries", "blazer_queries", column: "query_id" + add_foreign_key "blazer_dashboards", "users", column: "creator_id" + add_foreign_key "blazer_queries", "users", column: "creator_id" add_foreign_key "bookmark_annotations", "bookmarks" add_foreign_key "bookmarks", "users" add_foreign_key "categories", "category_types"