Skip to content

Maintenance mode: layout override + scheduled windows + PubSub - #491

Merged
ddon merged 4 commits into
BeamLabEU:devfrom
mdon:dev
Apr 15, 2026
Merged

Maintenance mode: layout override + scheduled windows + PubSub#491
ddon merged 4 commits into
BeamLabEU:devfrom
mdon:dev

Conversation

@mdon

@mdonmdon commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Refactors maintenance mode from a redirect-based approach to a layout override — when maintenance turns on, a user's LiveView keeps running and their form state is preserved,
but the rendered layout is dynamically swapped to the maintenance page. URL never changes. When it ends, PubSub pushes the update and the layout swaps back, revealing the user's
page exactly as they left it.

Adds scheduled maintenance windows (UTC start/end with full validation), a scheduled auto-shutoff timer so users sitting on a blocked page get unblocked at the scheduled
end, and a live admin settings UI with real-time preview.

Highlights

Layout override approach (lib/phoenix_kit_web/users/auth.ex)

  • check_maintenance_mode/1 sets socket.private[:live_layout] to {PhoenixKitWeb.Layouts, :maintenance} for non-admins via the existing plugin-layout pattern already used
    elsewhere in phoenix_kit
  • PubSub hook attached via attach_hook fires on every status change — non-admin tabs swap layouts instantly, admin tabs stay put with no reload
  • save_original_layout + defensive restore (deletes the key when original was nil) prevents renderer crashes
  • Process.send_after timer on connected mount unblocks users when a scheduled end arrives (clamped to Erlang's 32-bit timeout max)

Scheduled maintenance (lib/modules/maintenance/maintenance.ex)

  • Start only (activates indefinitely), end only (auto-disables manual toggle), or both (active during window)
  • validate_schedule/2 rejects: empty, past start/end (60s tolerance for minute-precision inputs), end ≤ start, and >1 year in future
  • cleanup_expired_schedule/0 auto-cleans stale state on every page access
  • active?/0 wrapped in rescue so a DB outage fails open with a logged error

Plug for non-LiveView routes (lib/modules/maintenance/web/plugs/maintenance_mode.ex)

  • Renders inline 503 HTML with Phoenix.HTML.html_escape (XSS-safe) and Retry-After header
  • Auth routes and static assets always pass through

Admin settings UI (lib/modules/maintenance/settings.ex)

  • Manual toggle, scheduled datetime pickers with system timezone display, content editor with live preview
  • Current time display ticks every 30s
  • Activity logging for all admin actions
  • All user-facing strings wrapped in gettext
  • Form-level phx-change so preview updates on every keystroke (not just blur)

Timezone helpers extracted to PhoenixKit.Utils.Date (with doctests):
offset_to_seconds/1, shift_to_offset/2, parse_datetime_local/2, format_datetime_local/2

mdonand others added 2 commits April 14, 2026 21:32
Replaces the old @show_maintenance assign approach with a dynamic layout
swap via socket.private[:live_layout] — the underlying LiveView keeps
running so form state and scroll position are preserved when maintenance
toggles on or off. URL never changes.
Core changes:
- Layout override in on_mount hook instead of redirect. When maintenance
turns on, put_in socket.private[:live_layout] swaps the layout live;
when it ends, PubSub triggers restoration of the original layout
- New PhoenixKitWeb.Layouts :maintenance template with countdown timer
- HTTP plug renders inline 503 HTML (with Retry-After header) for
controller routes, with proper Phoenix.HTML escaping to prevent XSS
- Scheduled maintenance windows with start/end UTC datetimes, 1-year
upper bound, and 60-second tolerance for datetime-local minute precision
- cleanup_expired_schedule auto-disables stale state on every page access
- Process.send_after timer unblocks users when scheduled end arrives
(clamped to Erlang's 32-bit timeout limit)
- PubSub broadcasts on every state change so all connected LiveViews
react instantly; admin tabs stay put, user tabs swap layouts
- Manual toggle clears expired schedule on enable to avoid stale locks
- Activity logging for all admin actions (toggle, content, schedule)
- All user-facing strings wrapped in gettext
Schedule validation rejects: empty, past start/end, end before start,
dates >1 year in the future. Datetime inputs use the system time_zone
setting for display and convert to UTC for storage.
Extracts timezone helpers (offset_to_seconds, shift_to_offset,
parse_datetime_local, format_datetime_local) to PhoenixKit.Utils.Date
so they can be tested in isolation.
Adds 93 new tests: unit tests for validate_schedule and PubSub,
integration tests for Maintenance context and the plug (including
XSS regression test), and doctested timezone helpers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Move phx-change from individual inputs to the form element so the
live preview updates on every keystroke (input-level phx-change on
text inputs only fires on blur, making the preview appear broken)
- Remove the "Preview" link that navigated to /maintenance. The path
went through locale-prefixed routes and got caught by the publishing
module's /:language/:group catch-all. The settings page already has
an inline Live Preview card rendering the same content, so the link
was redundant
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ddon

ddon commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

Hi @mdon — nice work on this one. Architecture is solid (layout-swap + PubSub live restore is a real UX win), the validate_schedule/2 design is clean, and the test coverage — especially the XSS regression on the plug and the doctests on the extracted Utils.Date helpers — is at the bar we want.

Full review at dev_docs/pull_requests/2026/491-maintenance-layout-override/CLAUDE_REVIEW.md. Summary of what needs to change before merge:

BUG — HIGH (blocking)

  • MaintenanceCountdown phx-hook is referenced but never defined. Both lib/phoenix_kit_web/components/layouts/maintenance.html.heex:31 and lib/modules/maintenance/web/maintenance_page_live.ex:139 emit phx-hook="MaintenanceCountdown", and the check_status handler is wired to receive the "countdown finished" event. But priv/static/assets/phoenix_kit.js doesn't register window.PhoenixKitHooks.MaintenanceCountdown. Result: the <span id="countdown-value"> stays empty and "Expected back in …" renders with no timer — a feature advertised in the PR body. Either add the JS hook (and make sure parent apps pick it up via the existing phoenix_kit.js include) or remove the markup + dead server handler.

  • Plug's 503 HTML references a stylesheet path that doesn't exist.lib/modules/maintenance/web/plugs/maintenance_mode.ex:108 emits <link rel="stylesheet" href="/assets/css/app.css" />, but the real root layout (lib/phoenix_kit_web/components/layouts/root.html.heex:49) serves ~p"/assets/app.css" (no css/ segment, typically digested). Non-LiveView routes hitting the plug render unstyled. Either inline critical CSS, resolve the digested path, or serve a bare page without relying on daisyUI classes.

BUG — MEDIUM

  • disable_system clears scheduled_start but not scheduled_end (maintenance.ex:131), leaving a surprising stop-signal for later re-enables.
  • Plug's auth-route / favicon skip uses String.contains? (maintenance_mode.ex:53, 75-78) — a parent-app URL like /blog/users/log-in-to-us would bypass maintenance. Switch to String.starts_with? for path prefixes.
  • Stale Process.send_after timer (auth.ex:1267) isn't canceled when the schedule changes. Saved today only because the handle_info handler re-checks Maintenance.active?/0 rather than trusting the payload — worth either canceling on re-subscribe or adding a comment that the payload is intentionally distrusted.

IMPROVEMENT — MEDIUM

  • check_maintenance_mode is called from 6 different on_mount clauses in auth.ex — fold into a shared helper so new live_sessions can't forget it.
  • put_in socket.private[:live_layout] is Phoenix internals — add a # HACK: note tying it to the LV version so future upgrades flag it.
  • Settings page PubSub refresh doesn't re-read header/subtext — for multi-admin UX, broadcast content changes too.
  • schedule_error_message(_) catch-all swallows unknown atoms silently — log a warning so future validation additions are visible.

Let me know when you're ready for another pass. The HIGH items (missing JS hook + wrong CSS path + String.contains?String.starts_with?) are the merge blockers; the rest is polish.

mdonand others added 2 commits April 15, 2026 02:46
HIGH (merge blockers):
- Register MaintenanceCountdown hook in priv/static/assets/phoenix_kit.js
alongside the other phoenix_kit hooks. Parent apps already include this
file, so the hook is now reliably available (was previously injected by
a plug script that wasn't guaranteed to run before LiveSocket init).
Remove the fragile inline injection from the Integration plug.
- Plug's 503 HTML now uses inline CSS instead of linking to
/assets/css/app.css (which wasn't actually served — the real digested
path is /assets/app.css). The page is now self-contained with light +
dark mode support via prefers-color-scheme, so it works on any route
regardless of the parent app's asset pipeline.
- Replace String.contains?/2 with String.starts_with?/2 in the plug's
auth_route?/1 and static_asset?/1. A parent-app path like
/blog/users/log-in-to-us would have bypassed maintenance mode. Add a
regression test covering parent-app look-alike paths.
MEDIUM:
- disable_system/0 now clears maintenance_scheduled_end in addition to
maintenance_scheduled_start so a stale end time doesn't surprise-disable
the next re-enable. Update the test to assert both fields are cleared.
- Track the Process.send_after timer ref in socket assigns and cancel it
on reschedule (via new reschedule_maintenance_end_timer/1) so schedule
changes don't leave a stale "auto-off" signal in flight.
- Settings LiveView's PubSub handler now re-reads header and subtext so
multi-admin editing stays in sync. The save handler broadcasts status
change to trigger this sync.
- schedule_error_message/1 catch-all now logs a warning with the unknown
atom so future validation additions surface instead of being silently
swallowed.
- Document check_maintenance_mode/1's required call sites (all 6 on_mount
hooks listed in the @doc) so new live_sessions don't forget it.
- Add a HACK comment near put_in socket.private[:live_layout] noting it
relies on Phoenix LiveView internals (same pattern as
maybe_apply_plugin_layout) and should be revisited on major LV upgrades.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses the final MEDIUM improvement from PR review: instead of each
of the 6 on_mount hooks explicitly calling check_maintenance_mode/1,
fold it into mount_phoenix_kit_current_scope/3 which all 6 already use.
New live_sessions that use a scope-mounting on_mount hook now inherit
maintenance mode enforcement automatically — no way to forget it.
Removes 6 redundant call sites and updates the @doc comment.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ddon

ddon commented Apr 15, 2026

Copy link
Copy Markdown
Contributor

Thanks @mdon — re-reviewed the two follow-up commits (89e5633d, 48c1f6f8) and everything from the prior review is addressed. Spot-checked:

  • MaintenanceCountdown registered in priv/static/assets/phoenix_kit.js:2116 — no more silent no-op
  • ✅ Plug 503 page is now self-contained inline CSS (no broken /assets/css/app.css link)
  • auth_route? and static_asset? use String.starts_with? — plus a regression test for parent-app look-alike paths, nice touch
  • disable_system/0 clears both scheduled_start and scheduled_end
  • ✅ Timer ref tracked in :phoenix_kit_maintenance_timer_ref and cancelled on reschedule
  • check_maintenance_mode folded into mount_phoenix_kit_current_scope/3 — new live_sessions inherit it automatically
  • ✅ HACK comment near socket.private[:live_layout], schedule error catch-all logs warning, content PubSub sync in settings

LGTM from my side. 🚀

@ddonddon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — follow-up commits address all HIGH/MEDIUM items from the review.

@ddon
ddon merged commit 868a9b8 into BeamLabEU:devApr 15, 2026
ddon pushed a commit that referenced this pull request Apr 15, 2026
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@mdon@ddon