Skip to content

Admin dashboard

Mirko Da Corte edited this page Aug 25, 2026 · 21 revisions

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.

Install and register

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.

Options

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 control

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 AuthFilters to 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.

What it shows

  • 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.

Starting a migration: the lock lease duration

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.

Model schemas: what still needs a migration

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.

Deprecated schema id elements: bringing documents onto the current name

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.

Document structures: how a document looks

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.

Missing origin references: repairing dangling references

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.

Clone this wiki locally