howdy/admin
A development admin area for a Howdy app. Mount it from your dev/
entry point, and it appears at /_howdy:
import howdy/admin
import howdy/dev
pub fn main() {
let db = my_app.open_database()
let identity = my_app.identity(db)
let assert Ok(_) =
dev.start(fn() {
my_app.app(db, identity)
|> admin.mount(admin.new() |> admin.database(db) |> admin.auth(identity))
})
process.sleep_forever()
}
Gleam has no way to find out which packages an app uses at runtime, and no registry of the values it built, so the app hands the admin what it has. What is registered decides what the admin shows:
database: the tables of the Gloo Repo, on PostgreSQL or SQLite, in a grid that refreshes as the data changes, with forms to insert, edit and delete rows.auth: users and groups, creating and suspending users, moving them between groups, and signing in to the app as any user. Registering auth registers its database too.authorization: roles and their permissions, and which users hold them, in every scope.mail: the messages ahowdy/mail/outboxkeeps, as they arrive.mail_previews: your email templates rendered from sample data, and sent on demand.telemetry: the traces and log lines ahowdy/telemetry/recorderholds, as they happen: each request as a timeline of its queries, remote calls and emails, with repeated and slow queries pointed out.flags: the feature flags of ahowdy/flags: their kill switches, who they are allowed or blocked for, their history with undo, and the groups rules can name. Rollouts are shown, not set.jobs: the background jobs of ahowdy/jobsrunner: each queue’s jobs by state, pausing queues, the schedules it keeps, and each job with its errors, to retry, cancel or delete.
Some things need no registering, because mount can see them in the
app:
- The OpenAPI documents
howdy/openapiserves: every endpoint, with a form to call it and see the response. Calls go straight through the app, anonymously or as any user whenauthis registered.
Development only
There is no login: anyone who can reach the pages can read and change
every row and sign in as anyone. Keep howdy_admin a dev dependency,
mount it only from dev/, and serve it from howdy/dev, which listens
on loopback. The pages also refuse a request whose Host is not
localhost, 127.0.0.1 or [::1] unless allow_hosts says otherwise,
so a browser on another machine, or a DNS-rebinding page, gets 403.
Writes must also come from the admin’s own origin: a POST whose
Sec-Fetch-Site or Origin header says another site sent it gets
403 too, so a page you visit while the admin runs cannot post to it.
Types
Values
pub fn allow_hosts(admin: Admin, hosts: List(String)) -> Admin
Exact request hostnames (without port) the pages answer, replacing the loopback names. Anyone who can reach an allowed host owns your data.
pub fn auth(admin: Admin, identity: auth.Auth) -> Admin
Manage the users and groups of this auth configuration, and sign in as
them. Its Repo is browsed too, unless database was given another.
pub fn authorization(
admin: Admin,
access: authorization.Authorization,
) -> Admin
Manage roles, their permissions and who holds them. Needs auth too,
since roles are assigned to users.
pub fn controllers(admin: Admin) -> List(controller.Builder(Nil))
The admin’s controllers, for mounting them yourself. The API pages need
the app, so only mount adds them.
pub fn flags(admin: Admin, features: flags.Flags) -> Admin
Manage these feature flags: turn them off at once, allow or block them
for users, organizations and groups, and undo any change. Rollouts and
ramps are shown but set from the app. Changes are recorded as made by
howdy_admin.
pub fn has_authorization(admin: Admin) -> Bool
Whether authorization was registered.
pub fn has_telemetry(admin: Admin) -> Bool
Whether a telemetry recorder was registered.
pub fn jobs(admin: Admin, runner: jobs.Runner) -> Admin
Show and manage the jobs of this runner, in the database it uses: each queue’s jobs by state, pausing queues, the runner’s schedules, and each job with its errors, to retry, cancel or delete. The runner need not be running in this process.
pub fn mail(admin: Admin, box: outbox.Outbox) -> Admin
Show the messages this outbox keeps, as they arrive: the rendered HTML, the text, the headers and the raw source, with links that open.
pub fn mail_previews(
admin: Admin,
previews: List(preview.Preview),
send_with mailer: mail.Mailer,
) -> Admin
Render these email previews, each built from its sample data every time
it is shown, and send one on demand through mailer. Previews from
several calls are shown together; the last mailer given is used.
The mailer’s default sender applies, and its adapter really sends: point it at the outbox, or at a local SMTP server such as Mailpit, not at production.
pub fn mount(app: howdy.App, admin: Admin) -> howdy.App
Add the admin’s routes to an app. Mount it last: the admin looks at the
app as it is here. If the app serves OpenAPI documents with
howdy/openapi, the admin finds them and adds pages to read them and
call the endpoints.
pub fn named(admin: Admin, name: String) -> Admin
The name shown in the sidebar. Defaults to “Howdy admin”.
pub fn notify_via(admin: Admin, config: pog.Config) -> Admin
How the grid’s listening connection reaches PostgreSQL, so it hears
about changes through NOTIFY rather than polling. Give the same
settings the app’s pool uses, such as pog.url_config(name, url); the
pool name in them is not used. Only needed for a pool the app did not
open with howdy/database/postgres, whose settings
postgres.listen cannot find: without them the grid polls. Does
nothing on SQLite.
pub fn preview_count(admin: Admin) -> Int
How many email previews were registered.
pub fn remove_notify_triggers(
repo: repo.Repo,
) -> Result(Nil, service.Error)
Drop the howdy_admin_notify function and triggers the grid installs on
PostgreSQL to hear about changes. They are harmless to leave, and
howdy/migration ignores them, but this puts a database back exactly as
it was. Does nothing on SQLite.
pub fn telemetry(
admin: Admin,
recorder: recorder.Recorder,
) -> Admin
Show the traces and log lines recorder holds. Start telemetry with
telemetry.record(recorder) so it has something to hold:
let recorder = recorder.new(keep: 200)
let assert Ok(Nil) =
telemetry.new("my-app") |> telemetry.record(recorder) |> telemetry.start
admin.new() |> admin.telemetry(recorder)