mirror of
https://github.com/we-promise/sure.git
synced 2026-09-04 14:21:23 +00:00
* feat(self-hosting): surface Sidekiq-unhealthy nudge + admin system health page (#1481) When the Sidekiq worker container isn't running — the most common Docker Compose misconfiguration in self-hosted setups — every background job silently never executes. Balance calculations, net-worth updates, and account syncs stall. The UI shows zeros and "No balance data available for this date" without explaining why (#1481, #1047). Per jjmata's resolution on the issue, this PR ships both halves of the fix in one pass: 1. A user-facing nudge banner that appears on every authenticated page when Sidekiq isn't processing jobs. Tells the user their data may be stale; doesn't pretend zeros are real. 2. An admin-only deep link from that banner into a new `/settings/admin/system_health` page (super-admin gated, matching the existing admin namespace contract) showing live Sidekiq state: process count, last heartbeat, max queue latency, job counters, and per-queue depth. ## What changed - New `SidekiqHealth` PORO (`app/models/sidekiq_health.rb`) eagerly loads ProcessSet + Queue + Stats in one pass and exposes `healthy?` plus a stable `reason` symbol (`:redis_unreachable`, `:no_worker_processes`, `:stale_heartbeat`, `:queue_backed_up`). Any Redis/Sidekiq failure during the eager load is caught and surfaced as `:redis_unreachable` so a degraded broker never crashes the layout. - `ApplicationController#current_sidekiq_health` memoizes a single instance per request via `helper_method` so the layout, banner partial, and any controller checks share one Redis round-trip. - New `app/views/shared/_sidekiq_health_banner.html.erb` rendered from `_htmldoc.html.erb` when `Current.user` is present and the health check is failing. Banner shows the user-facing message to everyone; the "View system health" CTA + reason detail are gated on `Current.user&.super_admin?`. - New `Admin::SystemHealthController#show` (inherits the existing `Admin::BaseController`, so super-admin gating is enforced for free) + view rendering status, counters, and per-queue breakdown. - Routes: `resource :system_health, only: :show` inside the existing `namespace :admin`. - Settings nav: new "System health" entry under the Advanced section, gated on `super_admin?` to match `sso_providers_label` and `users_label`. - i18n: new `shared.sidekiq_health_banner.*` keys (title, body, CTA, per-reason explanations) and a full `admin.system_health.show.*` namespace for the new admin page. English-only, matching how `ds.pill.*` and other DS keys are scoped. ## Why - jjmata: "Let's take both approaches ... a nudge about 'data unavailable' which hyperlinks to the admin UI if you are an admin only (not for other types of users) sounds like the best path forward. **Any takers for the PR?**" (#1481) - smurfpandey: "We can add a section in Settings for superadmins to see 'health' of the application/host." - The detection signal is conservative on purpose: - `PROCESS_HEARTBEAT_TIMEOUT = 2.minutes` tolerates deploy restarts and brief Redis blips without flapping. - `LATENCY_THRESHOLD = 5.minutes` is well above the sync-job tail under default `config/sidekiq.yml` concurrency. ## Validation This worktree runs on Windows without a local Ruby toolchain, so I could not run `bin/rubocop`, `bundle exec erb_lint`, `bin/brakeman`, or `bin/rails test` locally. CI will run the full matrix on the PR: - `lint` — `bin/rubocop -f github` - `lint_js` — `npm run lint` (no JS touched, should be green) - `scan_ruby` — `bin/brakeman --no-pager` - `scan_js` — `bin/importmap audit` - `test_unit` — `bin/rails test` (includes 7 new tests under `test/models/sidekiq_health_test.rb` and 4 new under `test/controllers/admin/system_health_controller_test.rb`) - `test_system` — `DISABLE_PARALLELIZATION=true bin/rails test:system` - `pipelock` — secret + agent-security diff scan Manual checks done in this worktree: - Re-read `CONTRIBUTING.md` and `.cursor/rules/project-conventions.mdc`. PORO under `app/models/` per Convention 2. No new gem dependency per Convention 1. Banner uses semantic tokens (`bg-warning/10`, `text-warning`) per the design-system rules. No `lucide_icon` direct call — uses the `icon` helper per CLAUDE.md. - Confirmed `Sidekiq::ProcessSet` / `Sidekiq::Queue` / `Sidekiq::Stats` are the same APIs Sidekiq 7+ exposes (we're on Sidekiq 8.x per the `Gemfile.lock` comment in `config/initializers/sidekiq.rb`). - Tests stub `Sidekiq::ProcessSet.new` / `Sidekiq::Queue.all` / `Sidekiq::Stats.new` so the suite doesn't need Redis populated. - The admin route lives inside the existing `namespace :admin` so `Admin::BaseController#require_super_admin!` enforces auth — no new authorization surface added. ## Notes - No public API endpoints, no rswag specs, no OpenAPI changes. - No migrations, no model changes outside the new PORO. - No background jobs touched. - English-only locale entry, mirroring the `ds.*` / `admin.invitations.*` precedent in this repo. Other locales fall back to English. - Detection thresholds are constants on `SidekiqHealth` so they're easy to tune from a follow-up PR if the defaults turn out to flap on any real-world deployment. - The banner positions itself at `top-20` (below the impersonation / super-admin bars) and uses `z-40` (below the `z-50` notification tray). Single-screen overlap with mobile flash toasts is acceptable for V1. Refs: #1481, #1047 * fix(self-hosting): address review on Sidekiq health PR (#1481) - `Admin::SystemHealthController#show` now reads from the request-memoized `current_sidekiq_health` instead of building a fresh `SidekiqHealth.new`, so the controller and the layout banner share one Redis round-trip. - `SidekiqHealth#reason` now treats `last_heartbeat_at.nil?` the same as a stale beat: a registered process that hasn't published a heartbeat is not "healthy". Previously the check short-circuited on the nil guard and silently fell through to the queue-latency branch. Added a unit test covering the `ProcessSet` entry with `"beat" => nil` case. - Settings nav: switched the "System health" entry's icon from `activity` to `heart-pulse` so it no longer duplicates the LLM Usage icon. - Routes: dropped the redundant `controller: "system_health"` option from the `resource :system_health` declaration — Rails infers `Admin::SystemHealthController` from the namespace, matching the style of the sibling `:sso_providers`, `:users`, `:invitations`, and `:families` admin resources. * fix(self-hosting): scope + cache Sidekiq health, admin-only banner (#1481) Addresses the second round of maintainer review on the Sidekiq health PR. - Skip the check entirely in managed mode. `current_sidekiq_health` returns `nil` unless `Rails.application.config.app_mode.self_hosted?`, so authenticated requests in managed deployments add zero Redis round-trips for this feature. - Cache the snapshot across requests via `SidekiqHealth.current` (Rails.cache, TTL `CACHE_TTL` = 60s default, env-overridable). The per-request memoization on `ApplicationController` is preserved on top, so even back-to-back self-hosted pages share one fetch. - Make thresholds operator-tunable. `PROCESS_HEARTBEAT_TIMEOUT`, `LATENCY_THRESHOLD`, and the new `CACHE_TTL` read from `SIDEKIQ_HEALTH_HEARTBEAT_TIMEOUT`, `SIDEKIQ_HEALTH_LATENCY_THRESHOLD`, and `SIDEKIQ_HEALTH_CACHE_TTL` env vars (seconds), with the previous values as defaults. Comments now explain the tuning rationale. - Gate the banner on `Current.user&.super_admin?` at the layout level rather than rendering a vague warning to family members who can't act on it. The partial no longer carries an internal admin check since the call site does it; non-admins see nothing. - Replace the hard-coded `top-20` offset with a computed offset based on which impersonation bars are visible (`top-4` / `top-20` / `top-36`) so the banner doesn't collide with the super-admin or approval bars when both are stacked above it. - `Admin::SystemHealthController#show` now bypasses the cache (`SidekiqHealth.expire_cache!` + `SidekiqHealth.new`) so an operator who just restarted the worker sees fresh state instead of a stale 60-second snapshot. Also lets the page render in managed mode where `current_sidekiq_health` is nil. - Tests: add coverage for `.current` cache reuse and `.expire_cache!` forcing a re-query, swapping `Rails.cache` to a MemoryStore since the test env defaults to `:null_store`. * fix(self-hosting): route singular resource + drop assert_same on cached snapshot (#1481) Two CI failures surfaced once the full pipeline ran on this branch for the first time (it was gated on contributor approval until d04b78e): - Admin system-health controller tests returned 404. Singular `resource :system_health` in `config/routes.rb` makes Rails infer `Admin::SystemHealthsController` (it pluralizes the controller name even for singular resources), but the controller file is named `system_health_controller.rb` / `Admin::SystemHealthController`. Restore the explicit `controller: "system_health"` override that the previous "address review" commit dropped on the (mistaken) premise that Rails would infer it from the namespace — the sibling admin routes all use plural `resources` so they round-trip cleanly, this one doesn't. Comment now spells the gotcha out so the next reviewer doesn't try to "simplify" it again. - `SidekiqHealthTest#test_current_memoizes_across_calls_inside_the_cache_TTL` used `assert_same` on the two returns from `SidekiqHealth.current`. `ActiveSupport::Cache::MemoryStore` defaults to `dup_values: true` and Marshals on read, so a cache hit returns an `==`-equal but `equal?`-different instance. Replace the identity check with the behavioral assertion we actually care about: re-stub `ProcessSet` to raise on the second call, then assert the second `current` return is still healthy (proving Redis was not re-queried). * fix(i18n): drop redundant inline default on system_health nav label (#1481) `system_health_label` is already defined in config/locales/views/settings/en.yml, so the inline `default: "System health"` was a hard-coded English string in the template (DS Drift Patrol Rule 5). Use the bare locale lookup like the sibling nav entries. --------- Co-authored-by: John Baillie <johnbaillie2007@gmail.com> Co-authored-by: Khaostica <256858950+Khaostica@users.noreply.github.com>
119 lines
5.6 KiB
ERB
119 lines
5.6 KiB
ERB
<%
|
|
nav_sections = [
|
|
{
|
|
header: t(".general_section_title"),
|
|
items: [
|
|
{ label: t(".accounts_label"), path: accounts_path, icon: "layers" },
|
|
{ label: t(".bank_sync_label"), path: settings_providers_path, icon: "banknote", if: Current.user&.admin? },
|
|
{ label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" },
|
|
{ label: t(".appearance_label"), path: settings_appearance_path, icon: "palette" },
|
|
{ label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" },
|
|
{ label: t(".security_label"), path: settings_security_path, icon: "shield-check" },
|
|
{ label: t(".payment_label"), path: settings_payment_path, icon: "circle-dollar-sign", if: !self_hosted? && Current.family&.can_manage_subscription? }
|
|
]
|
|
},
|
|
{
|
|
header: t(".transactions_section_title"),
|
|
items: [
|
|
{ label: t(".categories_label"), path: categories_path, icon: "shapes" },
|
|
{ label: t(".tags_label"), path: tags_path, icon: "tags" },
|
|
{ label: t(".rules_label"), path: rules_path, icon: "git-branch" },
|
|
{ label: t(".merchants_label"), path: family_merchants_path, icon: "store" },
|
|
{ label: t(".recurring_transactions_label"), path: recurring_transactions_path, icon: "repeat" },
|
|
{ label: t(".statement_vault_label"), path: account_statements_path, icon: "archive", if: Current.user&.admin? }
|
|
]
|
|
},
|
|
(
|
|
Current.user&.admin? ? {
|
|
header: t(".advanced_section_title"),
|
|
items: [
|
|
{ label: t(".ai_prompts_label"), path: settings_ai_prompts_path, icon: "bot" },
|
|
{ label: t(".llm_usage_label"), path: settings_llm_usage_path, icon: "activity" },
|
|
{ label: t(".api_keys_label"), path: settings_api_keys_path, icon: "key" },
|
|
{ label: t(".mcp_label"), path: settings_mcp_path, icon: "plug" },
|
|
{ label: t(".debug_label", default: "Debug"), path: settings_debug_path, icon: "bug", if: Current.user&.super_admin? },
|
|
{ label: t(".background_jobs_label"), path: settings_background_jobs_path, icon: "list-checks", if: Current.user&.super_admin? },
|
|
{ label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? },
|
|
{ label: t(".imports_label"), path: imports_path, icon: "download" },
|
|
{ label: t(".exports_label"), path: family_exports_path, icon: "upload" },
|
|
{ label: t(".sso_providers_label"), path: admin_sso_providers_path, icon: "key-round", if: Current.user&.super_admin? },
|
|
{ label: t(".users_label"), path: admin_users_path, icon: "users", if: Current.user&.super_admin? },
|
|
{ label: t(".system_health_label"), path: admin_system_health_path, icon: "heart-pulse", if: Current.user&.super_admin? }
|
|
]
|
|
} : nil
|
|
),
|
|
{
|
|
header: t(".other_section_title"),
|
|
items: [
|
|
{ label: t(".guides_label"), path: settings_guides_path, icon: "book-open" },
|
|
{ label: t(".whats_new_label"), path: changelog_path, icon: "box" },
|
|
{ label: t(".feedback_label"), path: feedback_path, icon: "megaphone" }
|
|
]
|
|
}
|
|
]
|
|
%>
|
|
|
|
<div class="space-y-4">
|
|
<div class="hidden lg:flex items-center gap-2 p-1.5">
|
|
<%= render DS::Link.new(
|
|
text: t("settings.settings_nav_link_large.previous"),
|
|
icon: "chevron-left",
|
|
href: previous_path,
|
|
variant: "ghost",
|
|
) %>
|
|
<%= link_to previous_path, class: "hidden md:block uppercase bg-surface-inset-hover rounded-sm px-1 py-0.5 text-xs text-secondary shadow-sm ml-1 pointer-events-none", data: { controller: "hotkey", hotkey: "Escape" } do %>
|
|
<kbd>esc</kbd>
|
|
<% end %>
|
|
</div>
|
|
<nav class="space-y-4 hidden md:block">
|
|
<% nav_sections.compact.each do |section| %>
|
|
<section class="space-y-2">
|
|
<div class="flex items-center gap-2 px-3">
|
|
<h3 class="uppercase text-secondary font-medium text-xs"><%= section[:header] %></h3>
|
|
<%= render "shared/ruler", classes: "w-full" %>
|
|
</div>
|
|
<ul class="space-y-1">
|
|
<% section[:items].each do |item| %>
|
|
<% next if item[:if] == false %>
|
|
<li>
|
|
<%= render "settings/settings_nav_item", name: item[:label], path: item[:path], icon: item[:icon] %>
|
|
</li>
|
|
<% end %>
|
|
</ul>
|
|
</section>
|
|
<% end %>
|
|
<section>
|
|
<%= button_to session_path(Current.session), method: :delete, class: "flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-destructive hover:bg-surface-hover w-full" do %>
|
|
<%= icon("log-out", color: "current") %>
|
|
<span><%= t(".logout") %></span>
|
|
<% end %>
|
|
</section>
|
|
</nav>
|
|
<nav class="space-y-4 overflow-x-auto md:hidden no-scrollbar overscroll-none" id="mobile-settings-nav" data-controller="preserve-scroll scroll-on-connect">
|
|
<ul class="flex flex-nowrap space-x-1">
|
|
<li>
|
|
<%= render DS::Link.new(
|
|
text: t("settings.settings_nav_link_large.previous"),
|
|
icon: "chevron-left",
|
|
href: previous_path,
|
|
variant: "ghost",
|
|
) %>
|
|
</li>
|
|
<% nav_sections.compact.each do |section| %>
|
|
<% section[:items].each do |item| %>
|
|
<% next if item[:if] == false %>
|
|
<li>
|
|
<%= render "settings/settings_nav_item", name: item[:label], path: item[:path], icon: item[:icon] %>
|
|
</li>
|
|
<% end %>
|
|
<% end %>
|
|
<li>
|
|
<%= button_to session_path(Current.session), method: :delete, class: "flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-destructive hover:bg-surface-hover w-full" do %>
|
|
<%= icon("log-out", color: "current") %>
|
|
<span><%= t(".logout") %></span>
|
|
<% end %>
|
|
</li>
|
|
</ul>
|
|
</nav>
|
|
</div>
|