Admin panel¶
A server-rendered management panel mounted under /admin, Django-admin
style. Operators sign in with a row from your own database — there is no
separate password store — and every registered model becomes browsable:
dashboard, list view with search/filters/sorting, and create/edit/delete forms
derived from the columns.
What you get:
- 🔐 Signed-cookie session login, with CSRF on every write.
- 📊 Dashboard with a row count per model plus a CPU/memory panel.
- 🔎 List view with search, per-column-type filters and sortable columns.
- ✍️ CRUD with widgets derived from the column type and per-field errors.
- 📱 Responsive, with no framework JavaScript and no external asset.
- 🎨 Typed theming (
AdminTheme) — color, logo, favicon, font, dark mode.
No new dependency
The panel runs on what the SDK already ships: tempest-db-js for the
models, PasswordUtils for the password, node:crypto to sign the session.
The HTML and the CSS are strings in this package, so there is no template
engine to install and no static file to serve.
Want the headless API instead of ready-made screens? See Headless admin (JSON API).
1. The user model¶
Extend BaseUserModel to get the columns the panel's auth backend expects
(email, hashedPassword, isAdmin, lastLoginAt) on top of BaseModel:
import { BaseUserModel, column, tableNameFor } from "tempest-express-sdk";
export class UserModel extends BaseUserModel {
static override tablename = tableNameFor("UserModel"); // "user"
name = column.varchar(120).notNull();
}
Only rows that are isActive === true and isAdmin === true get in. Seed
the first admin from a script — the same session lifecycle your repositories
already use:
import {
BaseRepository,
PasswordUtils,
createEngine,
} from "tempest-express-sdk";
import { UserModel } from "./db/models";
async function main(): Promise<void> {
const engine = createEngine(process.env.DATABASE_URL ?? "sqlite:///app.db");
const passwords = new PasswordUtils();
const users = new BaseRepository(UserModel, engine.session());
await users.create({
email: "root@example.com",
hashedPassword: await passwords.hash("hunter2hunter2"),
isAdmin: true,
name: "Root",
lastLoginAt: null,
});
await engine.close();
}
void main();
2. Register your models¶
AdminModel is a plain typed configuration instance — the options object is the
contract, with no metaclass magic. The defaults already work; pass the fields
you want to enrich the list view:
import { AdminModel, AdminSite } from "tempest-express-sdk";
import { OrderModel, UserModel } from "./db/models";
export const site = new AdminSite({
title: "MyApp Admin",
brand: "myapp-admin", // centered header text (defaults to title)
indexSubtitle: "Site administration",
siteUrl: "https://myapp.com", // optional outbound "View site" link
});
site.register(
new AdminModel({
model: UserModel,
listDisplay: ["email", "name", "isAdmin", "isActive", "lastLoginAt"],
listFilter: ["isActive", "isAdmin"],
searchFields: ["email", "name"],
ordering: "-createdAt",
pageSize: 25,
}),
);
The slug comes from the model's tablename, so URLs and tables stay in sync.
register also takes the options directly (site.register({ model: OrderModel }))
and throws when two models claim the same slug.
Filters derived from the column type
Every field in listFilter becomes the right control for its column type:
boolean → Yes/No dropdown; enum → dropdown of the members;
date/datetime → two date inputs (from/to); anything else → a text input
(equality). All of it preserves search, sorting and pagination in the URL.
Shortcut: register everything at once (automap)¶
Point automap at your models barrel and every concrete model is registered
with the defaults. Abstract bases (BaseModel, BaseUserModel — no
tablename) are skipped automatically:
Mix the two styles: register the models that need their own configuration by
hand, then let automap fill in the rest — by default it skips slugs that are
already registered:
site.register(new AdminModel({ model: UserModel, searchFields: ["email"] }));
site.automap(models, { exclude: ["audit_log"], pageSize: 50 });
automap accepts an array of classes (site.automap([UserModel, OrderModel])),
exclude (class or table name), skipRegistered: false to turn a collision
into an error, and any AdminModel option applied uniformly.
3. Mount the router¶
import {
UserModelAuthBackend,
createApp,
createEngine,
makeAdminRouter,
} from "tempest-express-sdk";
import { site } from "./admin/site";
import { settings } from "./core/settings";
import { UserModel } from "./db/models";
const engine = createEngine(settings.DATABASE_URL);
const app = await createApp({
configure: (a) => {
a.use(
makeAdminRouter(site, {
engine,
authBackend: new UserModelAuthBackend(UserModel),
secretKey: settings.JWT_SECRET, // at least 32 characters
prefix: "/admin",
cookieSecure: !settings.DEBUG, // true on production HTTPS
}),
);
},
});
makeAdminRouter mounts:
| Route | What it does |
|---|---|
GET /admin/login · POST /admin/login |
Sign-in flow |
GET /admin/mfa · POST /admin/mfa |
TOTP challenge (backends with MFA) |
POST /admin/logout |
Drop the session |
GET /admin/ |
Dashboard: row counts + CPU/memory |
GET /admin/m/{slug} |
List view: search, filters, sorting, pagination |
POST /admin/m/{slug}/bulk |
Bulk actions on the checked rows |
GET /admin/m/{slug}/export.csv · .json |
Export of the current result set |
GET/POST /admin/m/{slug}/new |
Create (when canCreate) |
GET /admin/m/{slug}/{id} |
Detail, with Edit/Delete |
GET/POST /admin/m/{slug}/{id}/edit |
Edit (when canEdit) |
POST /admin/m/{slug}/{id}/delete |
Delete (when canDelete) |
GET /admin/static/admin.css |
The bundled stylesheet |
secretKey is what separates an operator from an attacker
The session is stateless: principal id, display name, CSRF token and expiry travel in the cookie, signed with HMAC-SHA256 over this key. It must be at least 32 characters (the constructor refuses less), come from the environment, and never reach the repository. Rotating the key logs everyone out — exactly what you want if it ever leaks.
Writes (CRUD) and permissions
Create/edit/delete are gated by canCreate / canEdit / canDelete on the
AdminModel (all true by default; a disabled view answers 404 and
disappears from the UI). Every POST carries the session's CSRF token,
validated server-side (403 on mismatch). Widgets are derived from the
column type — text / textarea (long strings) / number / checkbox /
datetime-local / date / time / select for enums / JSON textarea — with
required-field validation and per-field errors re-rendered in the form. A
write the database refuses (unique, FK, NOT NULL) comes back the same
way: 400 with the message at the top of the form, never 500.
4. Second factor (optional)¶
A principal who enrolled TOTP goes through /admin/mfa after the password — the
panel never becomes the weaker door into an MFA-protected account. Turn it on by
passing a verifier to the backend:
import { MfaService, TOTPHelper, UserModelAuthBackend } from "tempest-express-sdk";
const mfaService = new MfaService({ store: mfaStore, totp: new TOTPHelper() });
const authBackend = new UserModelAuthBackend(UserModel, {
mfa: {
isEnabled: (userId) => mfaStore.isConfirmed(userId),
verify: (userId, code) => mfaService.verify(userId, code),
},
});
Without mfa, the backend declares that it has no second factor and every
correct password completes the login.
5. Theming¶
Every knob is a typed field, injected as a CSS custom property:
const site = new AdminSite({
title: "MyApp Admin",
theme: {
accent: "#7c3aed",
headerBg: "#1e1b4b",
logoUrl: "/static/logo.svg",
faviconUrl: "/static/favicon.ico",
fontFamily: "'Inter', sans-serif",
radius: "10px",
footerText: "MyApp | 2026",
darkMode: true,
},
});
For anything the fields do not cover, point customCssUrl at your own
stylesheet — it is linked last and overrides everything.
Theme values are validated
<, >, {, } and " are rejected at construction: they would break the
injected <style> block. That is a configuration defect, so it fails loudly
instead of producing corrupted markup.
Sidebar + burger navigation
Every authenticated page has a sidebar: Dashboard plus one link per registered model, with the current item highlighted. On desktop it is always visible; on mobile (≤768px) it goes off-canvas, opened by the burger icon and closed by tapping the scrim — pure CSS, no JS.
6. Bulk actions¶
The list view shows a checkbox per row, a select-all, and an action bar that
operates on the checked rows. Three actions ship built in — Activate,
Deactivate (when canEdit and the model has an isActive column) and
Delete (when canDelete) — and each comes back with a banner saying how
many rows changed.
Custom actions¶
Anything domain-specific — "send welcome email", "mark as shipped",
"recalculate totals" — is a custom action: a handler built with
adminAction and passed to AdminModel({ actions: [...] }).
import { AdminModel, adminAction } from "tempest-express-sdk";
import { site } from "./site";
import { OrderModel } from "../db/models";
import { mailer } from "../core/email";
const markPaid = adminAction({ label: "Mark as paid" }, async (ctx) => {
const changed = await ctx.repository.update(
{ id: { in: ctx.ids } },
{ status: "paid" },
);
return { message: `${changed} order(s) marked as paid.` };
});
const notifyCustomers = adminAction(
{ label: "Notify customers", dangerous: false },
async (ctx) => {
const orders = await ctx.repository.list({ id: { in: ctx.ids } });
for (const order of orders) await mailer.send(order.email, "Your order");
return { message: `${orders.length} emails sent.` };
},
);
site.register(
new AdminModel({ model: OrderModel, actions: [markPaid, notifyCustomers] }),
);
The handler receives a context with:
| Field | What it is |
|---|---|
ids |
Identities of the checked rows. |
repository |
The model's BaseRepository, on the request's session. |
dbSession |
The DB session, for work beyond the repository. |
request |
The inbound request. |
session |
The authenticated admin session. |
principal |
The operator row that triggered the action. |
Return { message, category } to show a banner (category accepts
"success" — the default —, "error" and "warning"), or null to show
nothing. dangerous: true marks the action as destructive in the dropdown.
The handler stays an ordinary function
The Python SDK uses an @admin_action decorator; here adminAction
returns the descriptor. The handler stays directly callable and testable
(markPaid.handler(ctx)), with no decorator syntax for a consumer to enable
in their build.
The name comes from the label
The submitted identifier is a slug of the label ("Mark as paid" →
mark-as-paid), namespaced as custom:<name> so it can never collide with a
built-in action. Pass name to pin it — the value ships in the HTML, so
changing it changes the surface. Two identical names on one model throw at
construction.
A throwing handler becomes a banner, not a 500
An action that throws is logged and comes back as an error banner on the list view carrying the exception's message. The operator sees what failed instead of a generic error page — but that message reaches the browser, so keep secrets out of exception text.
7. CSV / JSON export¶
The Export CSV and Export JSON buttons download the current result
set — same search, same filters, same ordering, same listDisplay columns:
GET {prefix}/m/{slug}/export.csv?q=...&filter_status=paid&sort=createdAt&dir=desc
GET {prefix}/m/{slug}/export.json?...
The CSV follows RFC 4180 (doubled quotes, a field quoted when it carries a
comma, quote or newline) and the JSON is an array of column→value objects.
Date becomes ISO, bigint a decimal string, binary base64.
The cap is there for a reason
An export is a full table scan streamed to a browser.
makeAdminRouter(site, { exportMaxRows: 5000 }) bounds how many rows leave
(default 5000) — it is what keeps a curious click on a large table from
becoming an outage. Raise it deliberately.
8. Foreign-key select¶
A foreign-key column whose target model is registered on the same site
becomes a <select> of related rows, both in the form and in the list filter,
instead of a raw UUID field. The option label comes from the referenced admin's
first searchFields; failing that from name / title / email / label /
reference; and last from the identity.
class OrderModel extends BaseModel {
static override tablename = "sales_order";
userId = column.uuid().references("user.id");
}
site.register(new AdminModel({ model: UserModel, searchFields: ["name"] }));
site.register(
new AdminModel({ model: OrderModel, listFilter: ["userId"] }),
);
A foreign key to an unregistered table stays a text input — an empty dropdown would be worse than the raw field. Options are capped at 1000 rows; past that the target table wants a search box, not a list.
9. Role-based access control¶
By default every operator who signs in (isAdmin) can do everything the
AdminModel flags allow. To narrow a principal to a subset of models or
actions, pass an accessPolicy:
import { AdminPermission, makeAdminRouter } from "tempest-express-sdk";
makeAdminRouter(site, {
engine,
authBackend,
secretKey: settings.JWT_SECRET,
accessPolicy: (user, admin, action) => {
const principal = user as { role: string };
if (principal.role === "superadmin") return true;
if (principal.role === "support") return action === AdminPermission.VIEW;
return admin.slug() === "article";
},
});
The policy composes with canCreate / canEdit / canDelete: both have to
allow. And the panel never shows a door that answers an error — a model without
VIEW disappears from the sidebar and the dashboard, an action without
permission drops out of the bulk dropdown, and the + New, Edit and
Delete buttons appear only when the policy allows them.
404 and 403 mean different things
A disabled flag answers 404 — that view does not exist in this panel. A policy refusal answers 403 — the view exists and this operator may not use it. Collapsing the two would hide misconfiguration behind "no permission".
10. Audit trail¶
A model carrying createdBy / updatedBy (via createdByColumn() /
updatedByColumn()) is stamped with the operator's id automatically, on create
and on edit through the panel. The detail view grows an Audit panel with the
timestamps and the actors already resolved to names through the auth backend.
To see what changed, not just who and when, pass an auditModel — the same
BaseAuditLogModel table your services already write:
import { BaseAuditLogModel } from "tempest-express-sdk";
class AuditLogModel extends BaseAuditLogModel {
static override tablename = "audit_log";
}
site.register(new AdminModel({ model: OrderModel, auditModel: AuditLogModel }));
The detail view then shows a per-record timeline (the 50 most recent entries):
action, actor, when, the field-by-field diff table and the context the writer
recorded. Each entry is a collapsed <details>, so a long history stays
scannable — and needs no JavaScript.
The panel reads the trail, it does not write it
auditModel only feeds the screen. Your service writes the rows, with
snapshot / diffSnapshots. Registering the model without writing anything
leaves the timeline empty — the panel does not invent history.
11. Dashboard metric cards¶
Beyond the CPU/memory panel, the dashboard takes business cards computed from your own database on load:
import { metricCard } from "tempest-express-sdk";
const site = new AdminSite({
title: "Shop",
dashboardCards: [
metricCard(
"Orders today",
async (session) => ({
kind: "value",
value: await new BaseRepository(OrderModel, session).count({ ... }),
unit: "orders",
}),
"Since midnight",
),
metricCard("Week over week", async (session) => ({
kind: "trend",
value: 18,
previous: 12,
})),
metricCard("By status", async (session) => ({
kind: "partition",
segments: [
{ label: "Paid", value: 8 },
{ label: "Pending", value: 6 },
],
})),
],
});
Three shapes: value (a headline number), trend (▲/▼ with the percentage
change against the previous period) and partition (a bar per segment).
trendPercent returns null when the previous period is zero — a percentage
against zero is undefined, not infinite.
A broken card does not take the dashboard down
A card whose compute throws is logged and renders as "Could not compute
this metric." — one bad query should not cost the operator every other
number on the page.
12. Lenses (saved list presets)¶
A lens bundles filters and an ordering under a label, and renders as a tab above the table:
import { adminLens } from "tempest-express-sdk";
site.register(
new AdminModel({
model: TicketModel,
lenses: [
adminLens({
name: "Open triage",
filters: { status: "open", priority: { gte: 3 } },
orderBy: "-createdAt",
}),
adminLens({ name: "Closed", filters: { status: "closed" } }),
],
}),
);
Clicking a tab applies ?lens=<slug>. A lens's filters are ANDed with
whatever the operator typed, so search and filters keep working on top of it;
the lens ordering holds until the operator clicks a column header. The All
tab returns to the unfiltered list, and lens travels through the pagination,
sorting and export links.
13. File and image uploads¶
A String column holding a file path/key can render as an upload input. List
it in uploadFields and pass an uploadStorage — the backends the SDK already
ships will do:
import { AdminModel, LocalUploadStorage } from "tempest-express-sdk";
site.register(
new AdminModel({
model: DocumentModel,
uploadFields: ["attachment"],
uploadStorage: new LocalUploadStorage({ root: "media/", baseUrl: "/media" }),
}),
);
- The form becomes
multipart/form-dataon its own when an upload field exists. - Create: a file is required only when the column is
NOT NULLwith no default. - Edit: no new file keeps the current one (the form shows
Current: …); a new file replaces it. - The column stores the storage key (
<slug>/<field>/<uuid>.<ext>); use theuploadStorageto serve or download it later.
busboy is an optional peer
Express does not parse multipart. The panel uses busboy — an optional
peer, needed only by projects that configure uploadFields or canImport,
with an error naming the install command. npm install busboy. A wire-format
parser has a long tail of correctness (boundaries, transfer encodings,
filename escaping): the case for depending rather than reimplementing.
Upload cap
makeAdminRouter(site, { maxUploadBytes }) bounds what is accepted (default
10 MB). A file over the cap comes back as a form error with nothing written.
Files are buffered in memory — which is what a panel needs (an operator
attaching a document), not a streaming ingest path.
uploadFields requires uploadStorage
Registering one without the other throws when the AdminModel is built:
with no storage there is nowhere to write, and failing at boot beats failing
on the first production upload.
14. CSV import¶
canImport: true (alongside canCreate) exposes an import page that bulk-creates
rows from an uploaded file:
The file is UTF-8 with a header row; unknown columns are ignored and the recognised ones are the same the form edits. Every row goes through the same coercion and validation as the form, and the ones that fail come back in a table with the spreadsheet row number (starting at 2, since row 1 is the header) and the reason. The ones that pass are created — a partial import is a normal outcome, not an error.
The parser follows RFC 4180
A quoted field may contain commas, newlines and doubled quotes, and the BOM Excel writes is stripped — without that the first column name would never match. An import that mangles exactly the rows someone took the trouble to quote is worse than no import.
15. Foreign-key autocomplete¶
A foreign key whose target table is too large for a <select> becomes a search
box:
The field then searches GET {prefix}/m/{slug}/autocomplete/{field}?q=, which
queries the referenced admin's searchFields and returns up to 20 options.
On edit the box opens showing the current row's label, not its UUID.
No CDN
The Python SDK reaches for HTMX from a CDN here. This panel uses ~30 lines of plain DOM instead, because a third-party script on an operator console is an external request an air-gapped deployment cannot make and a strict CSP has to whitelist — and what is needed is one fetch and one list.
16. Inlines (children on the parent's screen)¶
An inline shows, on the parent's screen, the child rows that point back at it — an order's line items, a user's API keys — with no trip to another screen:
import { AdminModel, adminInline } from "tempest-express-sdk";
site.register(
new AdminModel({
model: OrderModel,
inlines: [
adminInline({
model: OrderItemModel,
fkField: "orderId",
label: "Order items",
editable: true,
canDelete: true,
}),
adminInline({ model: OrderNoteModel, fkField: "orderId" }),
],
}),
);
Without editable the block is a read-only table linking into the child's
own admin, with an Add button. With editable it becomes an in-place
formset: one input row per child plus a blank row to add another, saved in a
single submit that creates, edits and deletes at once.
- An editable formset needs the child model registered on the same site with
canEdit;canDelete(on the inline and the child admin) adds the per-row delete checkbox. - A blank add row with every field empty is skipped — submitting the formset without filling the extra row does not create an empty child.
- A row that fails validation comes back with what the operator typed plus the per-field error, while the other rows in the same submit are saved.
The parent foreign key stays out of the formset
The column pointing at the parent is not offered as a field: a row's parent is the page it is on. Offering it would let an operator move a child to another parent by typing a UUID into a table cell.
Stronger than that: every submitted row is checked as belonging to this parent before it is edited or deleted. Row keys arrive from the browser, so a crafted submission could name some other parent's child — the ownership check is what keeps the page from becoming an edit surface for the whole table.
Row cap
The block renders at most 50 children and says how many exist in total. A parent with more than that wants the child's own list view with a filter, not a giant formset on the parent's page.
17. Logs page¶
Pass the same directory you gave configureFileLogging and the panel gains
a logs page:
It reads the structured JSON files, filters by source
(all/debug/info/warning/error/500) and by search term, and paginates
— newest first, with a colour-coded level badge. A record carrying a traceback
becomes a <details>: the message is the trigger, and the trace plus the
correlation fields (requestId, path, method, status_code) sit inside,
collapsed.
Search matches the message, the logger and the traceback — someone hunting a 500 usually has a fragment of the trace, not of the message.
GET {prefix}/logs/export?format=md|json downloads the filtered selection,
at most 500 records:
md— each traceback in a fenced block, so it survives a paste into an issue with its indentation intact. The header declares the source, the search, how many records were exported and how many matched in total — a partial export never reads as a complete one.json— the records verbatim, with every field the application logged.
Opt-in for a reason
Without logDir the page does not exist (404). The payload exposes
tracebacks and request metadata: only enable it behind the admin login, and
remember the export inherits exactly the same gate.
18. SQL console¶
The console is off by default. Turn it on by passing sqlConsole:
import { SqlCapability } from "tempest-express-sdk";
makeAdminRouter(site, {
engine,
authBackend,
secretKey,
sqlConsole: {
dialect: "postgresql",
policy: {
capabilities: [SqlCapability.READ],
allowTables: ["orders", "customers", "invoices"],
maxRows: 200,
},
onAudit: (entry) => auditLogger.info("admin sql", { ...entry }),
},
});
Read this before enabling it
A SQL filter in the application is defence in depth, not a security
boundary. The analyser here actually parses (via node-sql-parser)
rather than matching strings, and that stops the ordinary mistake: a DROP
typed by someone who only meant to SELECT, an UPDATE with no WHERE, a
query against a table holding card data. It will not stop a determined
operator with time: SQL has CTEs, subqueries, functions, dialect extensions
and comment tricks, and any parser-based allowlist is a game of coverage.
The boundary that actually holds is the database user. A role granted
only SELECT on three tables drops nothing, whatever reaches it:
CREATE ROLE admin_console LOGIN PASSWORD '…';
GRANT CONNECT ON DATABASE app TO admin_console;
GRANT SELECT ON orders, customers, invoices TO admin_console;
Point the console's run at that connection and use the policy to narrow
further and to produce a readable refusal instead of a database error. Used
that way the two layers complement each other. Used alone, the policy is a
speed bump.
Capabilities split risk the way an operator thinks about it, not the way SQL
groups keywords: read, insert, update, delete, ddl, drop (DROP and
TRUNCATE — the one nobody undoes) and admin (GRANT/REVOKE/SET). A
statement the parser cannot understand lands in admin on purpose: a
construct nobody anticipated needs the most privileged capability, not the least.
Other guards: requireWhereOnWrites (default true) refuses an UPDATE/DELETE
with no WHERE; allowTables/denyTables restrict the tables — with an allow
list, a statement naming no table at all is refused too, so allowTables fails
closed; maxRows (default 200) bounds what comes back to the browser.
Every attempt — allowed or refused — reaches onAudit with the SQL, the
operator, the verdict, the reason, the duration and the row count. A hook that
throws is logged and swallowed: an audit trail that can break the thing it audits
gets turned off.
19. Tasks (declared + history)¶
The panel shows both halves of background work — what this process declares and what its workers recorded:
import { BaseJobModel, JobStore, TaskManager } from "tempest-express-sdk";
export class JobModel extends BaseJobModel {
static override tablename = "job";
}
const tasks = new TaskManager();
tasks.register("nightly-export", exportOrders, {
description: "Ships yesterday's orders to the warehouse",
schedule: "0 3 * * *",
});
makeAdminRouter(site, {
engine,
authBackend,
secretKey,
tasks: {
manager: tasks,
jobs: (session) => new JobStore(JobModel, session),
},
});
Declared comes from the TaskManager registry: the name, schedule and
description you passed to register. The schedule is recorded and
displayed, never interpreted — the manager consumes a queue, it does not
schedule.
Runs comes from the JobStore: one row per unit of long work, written by
the worker itself.
const jobs = new JobStore(JobModel, session);
const job = await jobs.enqueue("nightly-export", { day });
try {
await jobs.start(String(job.id));
const rows = await exportOrders(day);
await jobs.succeed(String(job.id), { rows });
} catch (error) {
await jobs.fail(String(job.id), error);
}
Every transition is a method rather than a raw update, so "finished" always
moves the same columns together — a status without a finishedAt is the kind of
half-written row that makes a history screen lie.
The page lists runs newest first, filters by status and by name, and opens each
one with its payload, result, error and attempts. A run that has not finished
gets a Cancel button; JobStore.cancel refuses to touch a run already in
a terminal state and returns false, so an operator clicking cancel on a run
that just finished sees the truth instead of a row rewritten under them.
Either half may be missing: with only manager the page shows the declared side,
with only jobs the history. A section with no source is omitted, not
rendered empty — an empty table implies there is nothing to see.
Queue depth is not shown
The page answers "what is declared" and "what was persisted", never "how many messages are sitting in the broker right now". Brokers do not expose that, and a number that looked like that answer would be worse than none.
The store is not wired into the manager
Not every enqueued message deserves a durable row, and a worker that writes
one usually wants to record domain fields the envelope never carried. So
JobStore is called by the worker, not by TaskManager.
Recap¶
BaseUserModelplus one seededisAdminrow gives you the login.AdminSite+AdminModel(orautomap) says what is manageable.makeAdminRouter(site, { engine, authBackend, secretKey })mounts the panel.- Widgets, filters and validation come from the model's columns — there is no duplicated schema to keep in sync.
actions: [...]brings day-to-day operations into the panel; export and FK-select fall out of what the model already declares.accessPolicynarrows who does what;auditModelanswers who changed what;dashboardCardsandlensesbring the numbers and the queries your team repeats every day into the panel.