Skip to content

Admin area

Rails
Status ✅
Code app/controllers/admin/, app/views/admin/, app/views/shared/_admin_navigation.html.erb, app/views/layouts/admin.html.erb, app/models/admin/dashboard.rb, app/helpers/admin_helper.rb, app/models/{user,support_request,reply,beta_applicant}.rb, app/views/components/_header_account.html.erb, lib/tasks/users.rake

Verified against code: 2026-10-24

Purpose

The owner runs SailEver from /admin: watches the key figures, answers support requests, invites beta applicants, deletes user accounts on request, watches device registrations, reprocesses quarantined track segments and retries failed jobs. The admin area is English only, has no locale prefix and tells search engines not to index it.

Sign-in

Admins are ordinary users with users.admin = true (data-model.md). They sign in like everyone else, with a passkey or a magic link (auth.md), and share the 60-day session (auth.md § Sessions). There is no separate admin sign-in, password or sign-up.

Visitor /admin…
Signed out Magic-link page with redirect_to the admin page asked for; after sign-in they land there
Signed in, privacy policy or terms not current The consent page first (auth.md § Privacy consent)
Signed in, not an admin 404, so the admin area isn't revealed
Admin The page

Granting admin: no UI. The migration AddAdminToUsers made jakob.sommerhuber@gmail.com the first admin. On the server: bin/rails "users:make_admin[EMAIL]" and bin/rails "users:revoke_admin[EMAIL]". The account form doesn't accept admin.

Account menu: admins see an extra item "Admin" (lock icon) → /admin in the signed-in account menu (logbook.md § Signed-in navigation).

A sidebar on every admin page:

Item Target Counter
Logo + "Admin · " /admin
Dashboard /admin
Support requests /admin/support_requests Requests not archived
Beta applicants /admin/beta_applicants Pending applicants
Users /admin/accounts
Sign-in links /admin/sign_in_links
Registered devices /admin/devices All devices
Track segments /admin/track_segments Quarantined segments
Failed jobs /admin/failed_jobs Failed jobs
Changelog /admin/changelog
My logbook /my/logbook
Live website / in a new tab

Bottom: "Signed in as " and "Sign out" (the normal sign-out, auth.md). Counters show only when above zero.

Mobile web

At 900 px and narrower the sidebar is hidden. A dark bar at the top (sticky, 56 px) shows the logo with "Admin · " and two icons on the right, as in every phone header (README.md § Mobile web): Account opens the email address, "Account", "Subscription", "Admin" and "Sign out"; Menu opens the sidebar's items with their counters. Both popovers sit under the bar and scroll when taller than the window.

Dashboard

/admin. Head: "Dashboard", on the right "Last 30 days vs the 30 days before · sparklines: 12 weeks".

KPI tiles, 4 per row (2 below 900 px, 1 below 520 px). Every KPI counts rows by when they were created in the database (created_at):

Tile Value
Users accounts created
Devices devices rows created (registered watches, paired or not)
Tracks tracks created
Trips trips created
Crew memberships crew memberships created
Trip links share and crew links created
Hours sum of sailing_hours + engine_hours of the tracks created, one decimal, "h"
Miles sum of distance_nm of the tracks created, no decimals, "nm"

Each tile: label (mono, uppercase, muted), the value for the last 30 days (up to now), the change against the 30 days before that in whole percent ("▲ 12 %" green --good, "▼ 5 %" red --bad, "± 0 %", or "–" when the 30 days before had nothing), a sparkline and "Prev. 30 days: ". A window holds what was created after its start, up to and including its end. Numbers use a space as thousands separator.

Sparkline: 12 values, one per 7-day window ending now (oldest left), drawn as an SVG line in the signal colour (--signal, #ED6017 light / #FF7A33 dark) with a light fill, as wide as the tile, 36 px high, scaled to the highest week (0 is a flat line at the bottom). Its aria-label lists the weekly values.

Three columns (one column below 1200 px), each a card with a title, "View all →" and the 5 newest rows; a row links to the item:

Column Row Links to Empty
Support requests (archived included) State badge, name (max 24 characters), " · 12 Sep 08:40"; unread rows highlighted /admin/support_requests/:id "No support requests yet."
Beta sign-ups Status badge, name, " · 12 Sep 08:40" /admin/beta_applicants/:id "No beta sign-ups yet."
Failed jobs "Failed" badge, job class, " · 12 Sep 08:40" (time it failed) /admin/failed_jobs/:job_id "No failed jobs."

Support requests

/admin/support_requests.

Stats: Total · Last week · Unread · Avg. response (hours to the first reply, one decimal, or "N/A").

Filters: tabs Inbox / Archive; search field "Search name, email or message" with "Search" and "Clear". A topic parameter filters by topic (no UI control).

Table (newest first): State badge (Unread, Read, Replied, Archived), Date 12 Sep 08:40, Name (max 24 characters) with email, Topic, Message (first 80 characters), Replies count, actions "View" and, in the inbox, "Archive" (confirm) or, in the archive, "Delete" (confirm). Unread rows are highlighted. Under the table: "Showing N active|archived support requests".

Empty: "No active support requests" / "No archived support requests", with "New requests appear here." or "Try another search."

Request page (/admin/support_requests/:id)

Opening an unread request marks it read.

Area Content
Head Back link, "Request #", state badge
Request card Topic, date, Name, Email, message
Reply form Only when not archived. Subject (prefilled "#: Re: "), Content (prefilled "Best regards,\nJakob"), "Send reply"
Previous replies Subject, date and text of each reply, newest first
Actions Not archived: "Archive" (confirm). Archived: "Move to inbox" and "Delete permanently" (both confirm), note "Archived. Replies stay visible; new ones can't be sent."
Facts Created (time ago), State, First reply (time after the request)

Sending a reply saves it, marks a read request as replied, and emails it (emails.md). Messages: "Reply sent successfully.", "Reply saved but email delivery failed.", "Failed to send reply."

States: unread → read (on view) → replied (on reply); any state → archived; archived → read (move to inbox).

Beta applicants

/admin/beta_applicants.

Stats: Total · Pending · Invited. Tabs: All · Pending · Invited, each with its count.

Table (newest first): Date 12 Sep 2026 08:40, Name (links to the applicant page), Email, Watch (badge or "–"), What they sail (first 120 characters), Status badge (Pending orange, others green), and an action: "Invite" for pending applicants (confirm "Send the beta invitation to ?"), else "Invited

Applicant page (/admin/beta_applicants/:id)

The target of "Review and invite" in the sign-up notification. Head: back link "Beta applicants", the name, status badge. Card: Email, Watch (or "–"), Signed up and, once set, Invited (12 Sep 2026, 08:40 and time ago) and Account (the linked account's email); "What they sail" with the full text; for pending applicants an "Invite" button (same confirm). Inviting from here goes back to the list.

Invite does three things: 1. Finds or creates the user account for the applicant's email (first name = first word of the name, last name = the rest). 2. Sends the beta invitation with a magic link to /pair (emails.md). 3. Sets the applicant to invited with the time and links it to the account (user_id). The account has no privacy consent yet; the user agrees on the first sign-in (auth.md). Notice "Beta invitation sent successfully to !"

Applicant statuses in the data: pending, invited, active, rejected. Nothing in the UI sets active or rejected.

Users

/admin/accounts.

Stats: Total. Table (newest first): Email (with an "Admin" badge for admins), Created 19 Oct 2026, Devices (all devices ever paired to the account), Last seen (latest last_seen_at of its devices as time ago, or "–"), and "Delete" (danger button, not shown on the admin's own row, confirm "Delete the account of with all its trips, tracks and devices? This can't be undone."). Empty: "No users yet."

Delete revokes the account's active devices and queues AccountErasureJob, exactly like the user's own deletion (auth.md), including the confirmation email. Notice "Deleting the account of ." The admin's own account can't be deleted here ("You can't delete your own account here."); they delete it on /my/account.

For requests by email without the admin: bin/rails "users:erase[EMAIL]" on the server (bin/kamal console host, or bin/kamal app exec) revokes the devices and runs the job at once.

/admin/sign_in_links: the magic links sent (auth.md), for support when someone can't sign in.

Stats: Sent (24 h), Used (24 h). Search by email (part of the address, any case); "Clear" resets it. Table (newest first, at most 200; times in UTC): Account (email), Sent 27 Oct 2026 14:05, Link …/callback?token=•••••••• plus # and the first 8 characters of the token's SHA-256 hash (the secret itself is never stored, so it can't be shown), Leads to (redirect_to, else /my; /pair for a beta invitation), Status (badge "Used HH:MM", "Expired HH:MM" or "Open until HH:MM"), Used after (time from sending to use, e.g. "less than 5 seconds", or "–"). Empty: "No sign-in links found."

A link used within seconds of being sent was probably opened by a mail scanner before the user saw it; since links sign in only through the confirm page's button (auth.md), that no longer happens.

Devices

/admin/devices: every device that ever registered, paired or not.

Area Content
Stats Unique Devices · Version Registrations · Paired Watches · Pairing Conversion (% paired, one decimal)
Filters Platform tabs All Platforms / Garmin / Apple Watch; status tabs All / Paired / Unpaired
Chart "Registrations per Day", last 30 days: new devices (blue #0284c7) and version check-ins (orange #ED6017) as bars, date ticks every 5 days
Donut "Device Models": top 6 display names plus "Other Models", with percentages
Table Newest 100: Platform, Device Model (resolved name, raw model code under it if different), Identifier (UID, 16 characters), Last Registered Version, Versions Logged, First Seen, Last Seen (time ago), Status (Paired / Unpaired), Account email, "View"

Device page (/admin/devices/:id)

Stats Platform, Pairing Status, Last Registered Version, Recorded Tracks (of the latest 20). Device properties (hardware code, first registered, last check-in, user, paired at, revoked at in red). Version history table (version, registered at, hardware model).

Registration and pairing data come from the device API (device-api.md).

Quarantined track segments

/admin/track_segments: SLTB segments that failed the header, CRC or body check (newest 200). The raw payload is kept, so a segment can be reprocessed after a decoder fix; while quarantined, its track stays incomplete.

Table: Received, User email, Device (name or model), Track start (UTC) / seq, Size, Error (code), "Reprocess". Reprocess resets the segment to received and queues its ingest job: "Segment queued for reprocessing." Empty: "No quarantined segments."

Segment states and checks: track-sync.md, ingestion.md.

Failed jobs

/admin/failed_jobs: Solid Queue jobs that raised an error and are not retried any more (solid_queue_failed_executions), newest 200. Jobs are addressed by the job id, which stays the same when a job is retried.

Stats: Failed (all). Table (newest first): Failed 12 Sep 08:40, Job (class, #<job id> under it), Queue badge, Error (exception class, first 100 characters of the message under it), actions "View", Retry (refresh icon, confirm "Run again?") and Discard (danger, trash icon, confirm "Discard this failed ? It won't run again."). Under the table: "Showing the newest N of M failed jobs". Empty: "No failed jobs" with "Jobs that raise an error appear here."

Job page (/admin/failed_jobs/:id)

Area Content
Head Back link, job class, #<job id>; "Retry" and "Discard" (same confirms) while the job is failed
Job Status ("Failed" badge, else Finished or the Solid Queue state), Queue · priority, Active Job id (when set), Enqueued and Failed (24 Oct 2026 08:40:12 and time ago), Attempts (Active Job executions + 1, when known)
Arguments The job's arguments as pretty JSON
Error Exception class, message, backtrace (scrolls after 420 px)

Retry puts the job back into the queue (Solid Queue FailedExecution#retry): " queued again." Discard deletes the job: " discarded." Both go back to the list; a job that isn't failed any more stays on its page with "This job has not failed (any more)."

Changelog

/admin/changelog (calendar icon in the sidebar and the phone menu): what each deploy of the server brought. Replaces the changelog email after each deploy.

List (newest first): Deployed (26 Oct 2026 11:08, Vienna time), Version (the first 7 characters of the deployed commit, linked to it on GitHub), Changes (the entries' titles without their [type], joined with " · ", at most 160 characters, "N changes" under it; "No changelog entry" when the deploy brought none), action "Details". Under the table: "Times in Vienna. Each deploy records itself after it runs (bin/rails deploy:after); deploys before 25 Oct 2026 aren't listed." Empty: "No deploys yet".

Details (/admin/changelog/:id): back link, "Deploy " (linked to the commit), the time; one card per entry: its title, the pull request number linked to it, and its details (Markdown: paragraphs, lists, headings, bold, code; everything else as text).

Where it comes from: - config/changelog.yml holds one entry per server pull request, newest first: id (pr-<number>), pr, title, details. Every server pull request adds its entry before it is merged (apps/server/AGENTS.md). - After each deploy bin/rails deploy:after runs changelog:record: it stores a deploys row with KAMAL_VERSION (the commit Kamal deployed), the time and the ids of all entries no earlier deploy recorded. Rerunning it for the same version does nothing; without KAMAL_VERSION (outside a Kamal container) it records nothing. - Migration BackfillDeploys added the eight deploys of 25 and 26 Oct 2026 from the Server workflow's runs.

Known deviations

  • The devices platform tabs have Garmin and Apple Watch but no Wear OS, although Wear OS devices register.
  • Avg. response loads every answered request one by one; fine for now, slow with many requests.