-
Notifications
You must be signed in to change notification settings - Fork 4
Admin dashboard
Scrinium ships an optional web dashboard to monitor your database contexts and run
migrations from a UI — no need to build your own. It's a self-contained Razor Pages
area in the Etherna.Scrinium.AspNetCore.UI package.
PM> Install-Package Etherna.Scrinium.AspNetCore.UI
Add AddScriniumAdminDashboard() in service registration, and make sure ASP.NET Core's authorization
middleware is in the pipeline (the dashboard is guarded by an authorization policy):
using Etherna.Scrinium.AspNetCore.UI;
using Etherna.Scrinium.Extensions;
builder.Services.AddRazorPages(); // the dashboard is a Razor Pages area
builder.Services.AddScriniumWithHangfire()
.AddDbContext<ISampleDbContext, SampleDbContext>();
builder.Services.AddScriniumAdminDashboard();
var app = builder.Build();
app.UseStaticFiles(); // serves the dashboard's own script and stylesheet
app.UseRouting();
app.UseAuthorization(); // required by the dashboard
app.MapRazorPages();The dashboard is then reachable at its base path — by default /Scrinium.
Pass a DashboardOptions to AddScriniumAdminDashboard:
| Property | Default | Meaning |
|---|---|---|
BasePath |
"Scrinium" |
Path where the dashboard is served (/Scrinium, /admin/db, …). Leading, trailing and repeated / are normalized away; empty mounts it on the application root — what an application dedicated to the dashboard wants, and a collision with the pages of any other. |
AuthFilters |
[LocalRequestsOnlyAuthFilter] |
Filters that must all pass to access the dashboard. |
AppPath |
"/" |
Target of the back link to your app shown in the UI: a relative path, or an absolute http/https URL. Any other URL scheme, and any control character, is refused at registration with an ArgumentException, since the value renders as the href of the link. Set null to hide the link. |
builder.Services.AddScriniumAdminDashboard(new DashboardOptions
{
BasePath = "admin/scrinium",
AuthFilters = [new AdminOnlyAuthFilter()]
});Access is governed by IDashboardAuthFilters — every configured filter must authorize the
request, and the first one denying it refuses the access:
public interface IDashboardAuthFilter
{
Task<bool> AuthorizeAsync(HttpContext? context);
}The default LocalRequestsOnlyAuthFilter authorizes a request only when it reaches the application
directly from the host running it: a loopback or local-address connection — the IPv4-mapped IPv6
forms included — carrying no forwarding header. A request bringing Forwarded, X-Real-IP, or the
For/Host/Prefix/Proto members of the X-Forwarded-* and X-Original-* families is denied
whatever its connection address, because a proxy makes that address its own: without this an
application behind a reverse proxy on the same host would authorize every client the proxy serves.
That also means the default filter cannot authorize a proxied deployment, not even a legitimate one: identifying the client is beyond what an address can do there. Add your own filter for real deployments:
public sealed class AdminOnlyAuthFilter : IDashboardAuthFilter
{
public Task<bool> AuthorizeAsync(HttpContext? context) =>
Task.FromResult(context?.User.IsInRole("Admin") == true);
}An empty filter list leaves the dashboard unrestricted: an application whose access is already decided elsewhere — by the endpoint hosting it, or by its own authentication — declares it emptying the list, instead of configuring a filter allowing everyone.
Warning. The dashboard can start migrations and exposes internal state. The default filter only allows direct local requests; if you change
AuthFiltersto expose it, gate it behind real authentication. A proxy that forwards without adding any header is indistinguishable from a direct client, so on that setup the default filter authorizes what the proxy serves: configure a filter of your own.
State-changing requests are also antiforgery-protected: the migration start and the missing origin references removal handlers validate the ASP.NET Core antiforgery token — sent by the dashboard's own script as a request header — so a cross-site page can't drive them through an authorized browser.
Every dashboard response denies storing and content type sniffing (Cache-Control: no-store,
X-Content-Type-Options: nosniff), and the page also denies framing and any foreign content source
(X-Frame-Options: DENY and a default-src 'none' content security policy), which it can afford
being entirely first party.
Throttling is yours. The dashboard endpoints run at the rate your pipeline allows: a status poll queries every db context, a migration start — dry run included — scans every migrated collection, and a missing origin references scan reads every referenced id of a collection. A library can't register the rate limiter, an application-wide middleware; add yours if the dashboard is reachable beyond a trusted operator. Migration starts are already serialized per db context: one won't start while another owner holds the db context lock.
- Each registered database context, listed as its own card.
- The ability to start a migration for a context — for real or as a dry run, with a Stop at first error option applying to both — and see migration status (the status/start endpoints are page handlers polled by the dashboard's own script). Operations carry a Dry run and a Stop at first error badge for the options they were started with, and their document migration logs list the failing documents with id and error.
- An Advanced section per card, collapsed by default, carrying the Lock lease duration of the start — see below.
- A Model schemas, a Deprecated schema id elements, a Document structures and a Missing origin references section per context — each detailed below.
- A read-only context renders with a Read-only badge and without migration controls or status polling; its counting and scanning sections work, since counting and scanning are reads and the structures come from the maps — only the writes they offer, the documents migration and the references removal, aren't available there.
Static assets are bundled in the package — no external client libraries or CDNs.
Every start claims the db context lock, and the Advanced section chooses the
lease duration of that claim, in minutes — defaulted to ResourceLock.DefaultLeaseDuration
(10 minutes), accepted up to 24 hours (IndexModel.MaxLockLeaseDurationMinutes).
What the duration covers — the death of this instance and the queue pickup window, never the migration itself — is explained on Db context lock.
The start handler validates the value server side, so a request bypassing the control gains nothing: a missing, non-positive or over-maximum duration is rejected with an error the card renders in its feedback line, and nothing starts.
The start controls — including this one — are disabled while a migration is in progress, or while an exclusive access is running in this process. "In progress" is not the operation status alone: the dashboard reports a migration as running only when an open operation is paired with a live db context lock lease. An operation left open by a dead instance therefore keeps the controls enabled, which matters because starting a migration is what closes those orphaned operations (as cancelled or failed, per Migrations). Cancelled operations get their own badge in the history, marked cancelled before executing — they have no completion instant to show.
Each collection lists the active and secondary schemas of the concrete model types it stores — a document carries the active schema id of its own concrete type. Counting the documents adds a row for every schema id found on documents that no map declares, and one for documents carrying no schema id:
| Row | Meaning |
|---|---|
| current | The active schema of a model type: what writes produce today. |
| deprecated | A registered secondary schema: documents still read fine, and keep their old shape on disk. |
| unrecognized | A schema id no map declares — read through the fallback, when you register one. |
| no schema id | Documents written without the schema id element. |
Every non-zero count outside the current rows is what a migration rewrites.
Expanding the section sizes each collection from its metadata, at constant cost, and shows the size next to the collection name. Counting by schema id is a separate button, one collection at a time.
Warning. MongoDB serves the per-schema grouping with a full collection scan — an index on the schema id element does not change that — so its cost grows with the collection. The collection size shown next to the button tells you what a count is about to read.
The same two counts are available in code on every repository, outside the dashboard: see Repositories.
A document written before the schema id element took its _s name carries it in the deprecated _m
element, still recognized while reading it — see Versioned schemas. Each collection can be
counted for those documents, on request, one collection at a time; the count matches the element at
the root of a document, which tells the whole document: a root carrying _s was written whole by
a version writing it, its sub-documents included.
On writable repositories, Migrate documents rewrites what a fresh count selects — the migration
never trusts the rendered number. Renaming the root element wouldn't be enough, since a document
nests a schema id as deep as its sub-documents and its
summaries go: each document is deserialized and written back whole
with its current active schema, which stamps _s at every level. It is a migration
restricted to those documents and behaves like one — the documents failing it are skipped and
rendered with their error, keeping the content they have — and the page recounts right after,
showing what is left. The handler rejects read-only repositories also when the request doesn't come
from the page.
Warning. The count scans the collection, and the migration reads and rewrites every document it selects. Like the schema ids count, both belong to on-demand maintenance.
The same count and migration are available in code on every repository, outside the dashboard: see Repositories.
Each collection renders the shape of its documents: one structure per registered model map schema of the concrete model types it stores — the current one, and every deprecated one still shaping the documents written while it was active. A structure opens with the schema id element carrying its own id, and lists the elements the schema writes, in the order it writes them, expanding every sub-document:
_s: 3a1f…order Order current
_id: String
Owner: User reference
_s: 3f2c…user-summary User current
_id: String
DisplayName: String
You read at once what MongoDB holds: element names, nesting, and the denormalized data each document carries.
| Mark | Meaning |
|---|---|
| reference | The element carries the summary of a referenced document: the elements below it are denormalized copies, rewritten in every document carrying them when the referenced document changes them. |
| external | No repository of this context handles the referenced model, so its documents are saved on the context owning them: dependency updates refresh this summary only when the change is applied by an application hosting both contexts. |
| not propagated | An unknown document key sits in the element's path — a dictionary written with its keys as element names — which the dependency-update task can't address with a filter: it skips it. |
| cycle | The structure repeats one already expanded above it, naming the schema it repeats: documents nest it as deep as their data goes. |
The containers of an element render one symbol each: [] an array (a list of references reads
Members[]: User), {} a dictionary written with its keys as element names. The extra elements bag
isn't listed: it carries no element of its own, it collects the ones no member maps.
A sub-document is written by the concrete type its element receives, whatever the declared one, so an element declaring a type with derived ones renders a structure for each of them — every serialized model type has a model map, so the alternatives are all there. Documents also carry their type discriminator when their concrete type differs from the declared one.
The whole section reads the maps, and nothing else: no query runs to render it, on any context.
Each collection can be scanned for references whose origin document doesn't exist anymore — deleted, or never created by a flow that persisted the referencing document alone. The scan runs on request, one collection at a time, and renders one row per reference element path:
| Column | Meaning |
|---|---|
| Reference path | The element path of the reference, as the documents nest it (Owner, Blog.LastPost, …). |
| Origin collection | The collection the referenced ids were verified against — resolved from the reference serializer, sources on a child db context included. |
| Missing origins | How many distinct referenced ids have no origin document. Expanding the count lists them; the listing is capped (100 ids per path), the count is always complete. |
| Referencing documents | How many documents carry at least one of the listed missing origin ids — a lower bound (≥) when the listing is capped. |
Null references are never reported: they address no origin document. Reference paths the scan
can't verify are listed apart, tagged unverifiable: a dictionary written with its keys as
element names, a fixed array position in the path (an ArrayOfArrays dictionary value), or an origin
repository that doesn't resolve on the current scope. Their references are not scanned, and a removal
leaves them untouched.
On writable repositories, Remove references repairs what a fresh scan verifies — the removal never trusts the rendered listing: a reference hosted as an array item is pulled out of its array, any other one is set to null, reading as a null reference from then on. No document is deleted. The handler rejects read-only repositories also when the request doesn't come from the page, and the page rescans right after the removal, showing the repaired state.
Warning. The scan reads every referenced id of the collection — one aggregation per reference path, plus the existence checks on the origin collections — so its cost grows with the collection and its references. Like the schema ids count, it belongs to on-demand diagnostics.
The same scan and removal are available in code on every repository, outside the dashboard: see Repositories.
Next: Migrations for what the dashboard runs, or Startup and configuration for the rest of the setup.
Scrinium — source · issues (SCR) · GNU LGPL-3.0 · info@etherna.io
Getting started
Core concepts
Working with data
Serialization & mapping
Operations & maintenance
Advanced & reference