Dashboard

Trax.Dashboard is the operations control room: a web UI for inspecting registered trains, browsing execution history, managing scheduled manifests, and monitoring the network. It mounts as a Blazor Server app at /trax, similar to how Hangfire's dashboard works at /hangfire.

The dashboard needs the Trax Scheduler in the same host: AddMediator() and then AddScheduler() inside AddTrax(...). Its pages queue, run and cancel trains and change settings through the Scheduler's IOperationsService, the same service the GraphQL operations fields call, so UseTraxDashboard() refuses to start without it.

Quick Setup

Installation

dotnet add package Trax.Dashboard

Or in your .csproj:

<PackageReference Include="Trax.Dashboard" Version="1.16.0" />

Pin an exact version, the release these docs are checked against or a newer one, rather than a floating 1.*, which restores whatever was published last.

Required: RequiresAspNetWebAssets

Every host that calls UseTraxDashboard() must set this property in its csproj <PropertyGroup>:

<PropertyGroup>
  <RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>
</PropertyGroup>

Without it, the build omits _framework/blazor.web.js, the Blazor Server circuit never establishes, and the dashboard renders but every button click is a no-op. The page looks fine; nothing reacts.

Why isn't this automatic? The Trax.Dashboard package can't propagate the property transitively. NuGet auto-imports build/$PackageId.props, but the Razor SDK overwrites that file during pack with static-asset imports, wiping anything we put there. Putting it in a .targets file is too late - the SDK reads RequiresAspNetWebAssets during the props phase, before NuGet targets are imported. It has to live in the consumer's csproj.

Verifying it took effect. After building your host:

curl -I http://localhost:5000/trax/_framework/blazor.web.js
# HTTP/1.1 200 OK   ← good
# HTTP/1.1 404      ← property not set, dashboard will be dead

If you see a 404, your csproj is missing the property. Set it and rebuild.

Configuration

Two lines in Program.cs, and a decision about who may use it:

using Trax.Dashboard.Extensions;
using Trax.Effect.Data.Postgres.Extensions;
using Trax.Effect.Extensions;
using Trax.Mediator.Extensions;
using Trax.Scheduler.Extensions;
 
var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddTrax(trax => trax
    .AddEffects(effects => effects
        .UsePostgres(connectionString)
    )
    .AddMediator(typeof(Program).Assembly)
    .AddScheduler()
);
builder.Services.AddTraxDashboard(o => o.RequireRoles("Admin"));
 
var app = builder.Build();
 
app.UseTraxDashboard();
 
app.Run();

UseTraxDashboard() refuses to start until the options name a policy (RequirePolicy), roles (RequireRoles), or call AllowAnonymousDashboard(), because the dashboard can queue, run and cancel trains and change scheduler settings. See UseTraxDashboard for what each does.

The posture is not checked only when the page loads. An open dashboard keeps re-checking it against the host's AuthenticationStateProvider, when the provider reports a change and every minute, and checks every click or other message the browser sends against the latest verdict. Once the user no longer satisfies it, the dashboard closes the connection and the page reloads through the gated endpoint. The dashboard registers no AuthenticationStateProvider of its own, so it sees a revoked role or a sign-out only when the host's provider does. ASP.NET Core's default provider keeps the user the connection arrived with; register a revalidating provider (ASP.NET Identity's template does), or name a policy whose handlers read live state, to have a revocation take effect in an open dashboard. The in-dashboard check has no HttpContext, so a policy handler that reads it gets null.

AddTraxDashboard() requires AddTrax() to be called first. If it is missing, AddTraxDashboard() throws InvalidOperationException with a clear message directing you to add AddTrax(). Call it once: a second call throws rather than replacing the first call's options, posture included. UseTraxDashboard() throws InvalidOperationException naming AddScheduler() when the Scheduler is not registered.

Navigate to /trax/trains and you'll see every IServiceTrain registered in your application.

What It Shows

Trains Page

The dashboard scans your DI container for all services implementing IServiceTrain<TIn, TOut> and displays them in a sortable, filterable grid:

ColumnDescription
TrainThe service interface name (e.g., ICreateUserTrain)
ImplementationThe concrete class (e.g., CreateUserTrain)
Input TypeThe TIn generic argument
Output TypeThe TOut generic argument
LifetimeDI lifetime (Transient, Scoped, or Singleton)

This is the same information the TrainRegistry uses internally, but surfaced in a UI instead of buried in reflection.

Data Pages

The dashboard exposes pages for browsing what the data provider persists:

PageDescription
MetadataTrain execution history: start/end times, success/failure, inputs/outputs. Includes a "Current Junction" column for InProgress trains, and a cancel button on every Pending or InProgress row.
LogsApplication log entries captured during train execution
ManifestsScheduled job definitions. An interval schedule reads as hours, minutes and seconds with nothing rounded, so 90 seconds is Every 1m 30s.
Manifest GroupsManifest group settings and aggregate execution stats. A group's detail page has a "Cancel All Running" button.
Dead LettersFailed jobs that exhausted their retry budget
Work QueueEntries waiting for dispatch. The Subject column shows the entry's subject key when the train sets one. The Confirmed column shows Yes for a confirmed entry, and a Staged badge on a queued entry that is not yet confirmed, which the dispatcher will not claim (an unconfirmed entry that is no longer queued, such as one the stale sweep cancelled, shows a dash). The subject key is computed by your train, so it may carry record identifiers. An entry's detail page shows Waiting On when the entry cannot be dispatched yet because of its subject: "Entry N, which is running for the same subject" when another entry's run holds the subject, or "Entry N, which is ahead of it for the same subject" when a queued sibling comes first, since dispatch offers only the first queued entry per subject each cycle. The sibling named is one dispatch would actually take first: it applies the same filters as dispatch, so a sibling whose scheduledAt has not arrived, or whose manifest group is disabled, is not named however high its priority. The detail page shows the entry's input with its [TraxSensitive] members written as {"_redacted": true}, by the rule the API applies to the same read (see Train inputs and the operations gate): the stored input keeps the real values because the run starts from it, so the page reads it back as the input type of the train registered on this host and writes it masked. When no registered train takes that type, or the input does not read as it, the whole input is shown as {"_redacted": true}.

These pages are accessible from the Data section in the sidebar navigation.

The list grids read only the columns they show, so paging through runs, work queue entries or manifests never loads an input, output, stack trace or properties document; the detail page loads those. The total under the pager is counted again when the filter changes, when the page shows it is too small, and otherwise at most every 30 seconds, so while the filter is unchanged it can lag the table by up to 30 seconds. A short last page gives the exact total.

Batch actions

The Metadata, Work Queue, Dead Letters, Manifests and Manifest Groups grids let you tick rows and act on them together. A selection holds the rows' ids, so rows ticked on one page stay selected after you move to another.

PageActionCalls
MetadataCancel SelectedIOperationsService.CancelExecutionsAsync
Work QueueCancel SelectedIOperationsService.CancelWorkQueueEntriesAsync
ManifestsEnable Selected, Disable SelectedIOperationsService.SetManifestsEnabledAsync
Manifest GroupsEnable Selected, Disable SelectedIOperationsService.SetManifestGroupsEnabledAsync
Manifest GroupsEnable All, Disable AllIOperationsService.SetAllManifestGroupsEnabledAsync

The API's batch mutations make the same calls (see IOperationsService: Batch actions), so the dashboard and the API change the same rows. The page shows the service's message, and its count includes only the rows that were cancelled or changed: a run already finished, or a manifest already in the requested state, is not counted. A count of zero is shown as a warning. The service takes at most 1,000 ids at once; a larger selection, or an empty one, is refused with the service's message, and the selection is kept so you can narrow it and try again.

Trigger Selected (Manifests and Manifest Groups) and Cancel Running (Manifest Groups) have no batch call yet, so the page calls the scheduler once per selected item. Each item is tried in turn and a failure does not stop the rest. The page reports what happened, such as "3 queued, 1 already queued, 1 failed" for manifests (a manifest that already had a queued entry gets nothing more queued; that entry runs as the trigger), or the manifests queued and runs cancelled across the groups. Each failure is listed by name, and the selection is cleared, so a retry does not send the items that succeeded again.

Dead Letter Detail Page

Clicking the visibility icon on a dead letter row opens a detail page with:

  • Dead Letter Details: Status badge, dead-lettered timestamp, retry count, reason, resolution info
  • Manifest Details: Linked manifest name, schedule, max retries, timeout, and properties JSON, masked by the same rule as a work queue entry's input
  • Most Recent Failure: The latest failed execution's failure junction, exception, reason, stack trace, and input
  • Failed Execution History: A grid of the failed metadata runs for the manifest, read from the database a page at a time, each linking to the metadata detail page

Two action buttons appear when the dead letter is in AwaitingIntervention status:

  • Re-queue: Creates a new WorkQueue entry from the manifest, marks the dead letter as Retried, and navigates to the new work queue entry
  • Acknowledge: Prompts for a resolution note, marks the dead letter as Acknowledged, and reloads the page

Metadata Detail Page

Clicking a metadata row opens a detail page with train state, timing, input/output, and exception details.

When a detail page (metadata, dead letter, work queue entry, manifest or manifest group) cannot load its row, because the database is unreachable for example, it says it could not load the row and why, rather than that the row does not exist, and keeps retrying on the polling interval until a load succeeds. A failed load never ends the operator's connection.

State Transition Timeline. A visual horizontal stepper at the top of the detail page shows the train's state progression: Pending -> InProgress -> Completed/Failed/Cancelled. Each state is color-coded and displays the timestamp when that state was reached, along with the duration between transitions (wait time, execution time). Past states are filled, the current state pulses, and future states are dimmed.

Exception Viewer. When a train has failed, the failure details card shows a Failure Class field with the run's failure classification (Unclassified unless a registered classifier assigned one), and a collapsible stack trace viewer with:

  • Syntax highlighting for C# stack traces (method names, file paths, and line numbers each in distinct colors)
  • A Copy button for copying the raw stack trace to the clipboard
  • Auto-collapse for long stack traces (expanded by default for short ones)
  • Inner exception separator formatting

When AddJunctionProgress() is registered and the train is InProgress, a Junction Progress card appears showing:

  • Currently Running - the name of the junction currently executing
  • Junction Started - when the junction began (HH:mm:ss)

A Cancel button appears for Pending and InProgress trains. It calls IOperationsService.CancelExecutionsAsync, the call behind the GraphQL cancelExecution mutation, which sets cancel_requested = true in the database and also cancels the run at once through ICancellationRegistry when it is running on this host. A Pending run is recorded TrainState.Cancelled and never runs; an InProgress run stops at its next junction boundary when the host uses the junction progress provider. Cancelling a run that is missing or has already finished fails with "Execution {id} is not cancellable (missing or already terminal).", as the mutation does.

Run Train with Custom Inputs

The dashboard supports running any registered train with custom inputs, a capability that differentiates Trax from Hangfire, which can only requeue jobs with their original inputs.

  • From the Trains page: Click the Queue button next to any train to open a dialog with a form builder (auto-generated from the input type's properties) or a raw JSON editor.
  • From the Trains page: Click the Run button beside Queue to open the same form builder and JSON editor, and run the train now instead of queueing it.
  • From the Metadata Detail page: Click the Re-queue button to re-run a train with its original input. The button calls IOperationsService.RequeueExecutionAsync, the method behind the API's requeueExecution. When the original recorded decisions, or was itself a re-queue that replays them, the new run replays them, so it takes the tracks the original took. It is refused, with the message the API's requeueExecution gives, when the run has no saved input (inputs are saved only with SaveTrainParameters()), when what was saved is a placeholder rather than the input (_truncated for one over MaxParameterBytes, _unserializable or _disposed), or when the saved input has [TraxSensitive] members masked. Each of those would read back as default values, and the train would run with values it never had. It is also refused when the run's train is no longer registered, or when its saved input no longer reads as the train's input type; the message names the run, as the API's does.

The queue dialog and Re-queue go through IOperationsService.QueueTrainAsync, which enqueues through ITrainExecutionService.QueueAsync, the same path as the GraphQL queueTrain mutation, so the train's OnQueue hook fires, its subject key is stamped and the input size cap applies. They enqueue inside a trusted scope ("dashboard"), so per-train [TraxAuthorize] requirements do not apply: the dashboard is the admin surface, gated as a whole by its host, and anyone who can reach it can queue any train. The scope also covers the train's OnQueue hook and QueueSubjectKey, and anything they run or enqueue through ITrainExecutionService (including work started with Task.Run), so those skip their own [TraxAuthorize] requirements too. Protect the dashboard route accordingly. Run is different: it calls IOperationsService.RunTrainAsync inside the same trusted scope, the call behind the GraphQL runTrain mutation, then opens the run's detail page. The service reads the input, runs the train's OnQueue hook on it before the run's row is written (a hook that throws refuses the run), applies the input size cap, writes the run's row and submits it to the job submitter the train is routed to. No work queue entry is written, so the run skips dispatch priority, group MaxActiveJobs and subject serialization. Use Queue when a run must wait its turn. Dead-letter Re-queue and manifest triggers re-run what a manifest fixed and are likewise governed by access to the dashboard itself. See Authorization: The Operations Surface.

Because Run writes no work queue entry, QueueSubjectKey does not apply to it. That makes it a documented bypass of subject serialization: a run started from it can execute concurrently with queued or in-flight work for the same subject. RunTrainAsync runs a train that overrides QueueSubjectKey only for a trusted caller such as the dashboard, and refuses any other caller, telling it to queue the train instead. For such a train the Run dialog shows a warning saying so and pointing at Queue, which waits for the subject to be free. A train that does not override QueueSubjectKey has nothing to bypass, and its dialog shows no warning. Trax.Docs/adr/0019 records why Run bypasses serialization rather than waiting or refusing.

How the input is read. Both dialogs offer a Form tab, with one field per public property of the input type, and a JSON tab. JSON is passed to the service as typed, and the service reads it the way it reads JSON from any caller: property names in any case, and a property given twice, in the same or another casing, refused. The form reads each field the same way whatever culture or time zone the server runs in:

FieldRead as
NumberInvariant culture: . for the decimal point, no thousands separators. 1.5 is one and a half on every server.
Date and time (DateTime, DateTimeOffset)UTC when the text gives no offset, as every timestamp in the dashboard is.
EnumA drop-down of the enum's names.
Other structured valueJSON for that type.
Blank, for a nullable propertynull.
Blank, for a non-nullable stringAn empty string.
Blank, for any other non-nullable propertyRefused: "A value is required."

A field whose text does not read as its type is refused by name, saying what was expected, and nothing is sent. The JSON tab takes up to 1,048,576 characters. The browser sends a long input to the server in small pieces, so pasting one does not trip the SignalR hub's 32 KB message limit or require raising it.

Home Page

The home page (/trax) reads its numbers through IOperationsService.GetDashboardMetricsAsync, the same data the GraphQL operations.metrics.dashboard query returns, and refreshes on each polling cycle. It leaves out administration trains when Hide Administration Trains is on. Its panels, each of which can be hidden from the User Settings page:

  • Server Health: CPU, working set, GC heap and uptime of the process hosting the dashboard (not the workers).
  • Summary Cards: executions started today (UTC, any state), success rate, currently running, and dead letters awaiting intervention. The success rate is completed / (completed + failed), so cancelled runs do not count against it; cancellation is an operator action, not a failure.
  • Executions Chart: completed, failed and cancelled executions per hour over the last 24 hours, or per minute over the last 60.
  • Avg Execution Duration (7d): the trains with the longest average completed run time.
  • Failures: Top Failing Trains (7d), and Throughput (7d), completed runs per train for the top 3 trains plus "Other".

Effects Page

The Effects page (/trax/settings/effects) shows all registered effect and junction effect provider factories. From this page you can:

  • Enable/disable toggleable effects at runtime (changes apply to the next train execution scope). The GraphQL API does the same with operations.setEffectEnabled
  • Configure effects that expose runtime settings. Click the gear icon to open a dynamic form dialog

Save applies only the toggles you changed since the page loaded, then reads every effect's state again. A toggle you left alone is not applied, so an effect another operator or the setEffectEnabled mutation turned off in the meantime stays off. Discard Changes drops your unsaved toggles and shows the effects' current state.

Configurable effects (those whose factory implements IConfigurableEffectProviderFactory<TConfiguration>) show a settings button in the grid. Clicking it opens a form generated from the configuration type's public read-write properties. For example, the Parameter Effect exposes SaveInputs and SaveOutputs toggles. A boolean is a switch, an enum a drop-down, and a number, string, date, time, duration, Guid or char a text field read by the same rules as the Run and Queue forms. Any other property, such as a predicate delegate, is listed as set in code (or not set) and is never written, because its text form cannot be read back. A blank field is null for a property that accepts null and refused for one that does not, and a value must pass the property's own ValidationAttributes.

The form edits the effect's live, process-wide configuration all or nothing, and writes only the fields you changed, so a value saved from elsewhere while the dialog was open is not reverted. Save converts and validates every changed field before applying any; if a setter throws part way, the fields already written are put back. Closing the dialog without saving writes nothing. Nothing is persisted, so a restart restores the configured values.

The Effects page was previously a section within Server Settings and has been moved to its own dedicated page under Settings > Effects in the sidebar.

Server Settings

The Server Settings page (/trax/settings/server) edits the scheduler's runtime settings and the host's log levels. The scheduler settings are grouped as Administrative Trains (the ManifestManager and JobDispatcher switches), Polling & Queue (polling interval, MaxActiveJobs, and the local worker count when local workers are registered), Retry Settings (DefaultMaxRetries, Failure Count Window, retry delay, backoff multiplier and maximum delay), Job Settings (DefaultJobTimeout, StalePendingTimeout, startup recovery), Dead Letter Settings, and Metadata Cleanup when AddMetadataCleanup is registered.

Save sends only the fields you changed since the page loaded, through IOperationsService.UpdateSchedulerConfigAsync, the call behind the GraphQL updateScheduler mutation, so a save stores only those settings and does not put back a value another operator or the mutation changed in the meantime. The form then reloads the settings in force. A saved setting reaches every scheduler host and replaces the value the host configured in code (see AddScheduler).

Discard Changes drops your unsaved edits and reloads the settings in force. There is no button that returns a setting to the value configured in code: once a setting is saved, the stored value is what every scheduler host runs with.

Each duration is held to the range the service accepts for that setting (see Value Ranges). A duration outside it shows a message under the field and disables Save until it is corrected.

Manifest Groups

Every manifest belongs to a ManifestGroup, a first-class entity with per-group dispatch controls. The Manifest Groups page shows one row per group with its settings and aggregate stats: manifest count, total executions, completed, failed, in progress, and last run time. The counts come from IOperationsService.GetManifestGroupExecutionStatsAsync, the call the API's stats query for manifest groups makes, so the two always agree; a manifest's detail page reads its counts from GetManifestExecutionStatsAsync the same way.

Clicking a group opens a detail page with two sections:

  • Group Settings: configurable MaxActiveJobs (per-group concurrency limit), Priority (0-31 dispatch ordering), and IsEnabled (disable all manifests in the group). Changes take effect on the next polling cycle. Unsaved edits are kept while the page polls, until you save or reset them. Save sends only the settings you changed, so it does not put back a value someone else changed since. Navigating to another group drops the edits, and every action on the page applies to the group in the address bar.
  • Group Data: lists every manifest in the group along with their recent executions.

Per-group MaxActiveJobs prevents starvation: when a high-priority group hits its concurrency cap, lower-priority groups can still dispatch. This is configured from the dashboard, not from code.

Persisted Operations (optional)

When the host registers the persisted-operations service, the dashboard exposes a Persisted Operations entry under Data. Either registration does it: UsePersistedOperations on the Trax GraphQL builder, or AddPersistedOperationStore(...) on a host that serves no GraphQL. The page lists the rows in trax.persisted_operation a page at a time, filtered by tenant (all, the default, or a named one), status and id prefix, and supports Upload, Edit, Deactivate, and Restore. A row is addressed by its tenant and its id, so two rows may share an id: each opens its own detail page (/trax/data/persisted-operations/{id}?tenant={key}, no tenant for the default) and changes only itself. The pages read and write through IPersistedOperationsService, the service the GraphQL operations.persistedOperations fields call, so they filter, page and refuse input exactly as the API does, with the API's messages. The editor renders parse, schema-validation, and shape-diff errors inline, and takes a document of up to 1,048,576 characters. A database error while loading, deactivating or restoring is logged and shown on the page. Each write re-checks the dashboard's authorization posture before it runs.

When the dashboard runs in a process of its own, register the store there with the broker's connection string, AddPersistedOperationStore(databaseConnectionString, rabbitConnectionString), so a change made from the dashboard reaches the GraphQL nodes' caches. See Persisted Operations.

The sidebar entry is hidden when neither registration was made; direct navigation to the list or to an operation's detail page renders a "not enabled on this server" panel.

How Discovery Works

Train discovery is handled by ITrainDiscoveryService in Trax.Mediator. When you call AddMediator(), it registers the discovery service, captures the IServiceCollection, and makes it available to both the dashboard and the REST/GraphQL API.

At request time, the discovery service scans the registered ServiceDescriptor entries for anything that implements IServiceTrain<,>, extracts the generic type arguments, and deduplicates by input type (preferring interface registrations over concrete types).

If you register trains with AddMediator (which calls AddScopedTraxRoute under the hood), they show up automatically. Trains registered manually via AddScoped<IMyTrain, MyTrain>() will also appear as long as their interface extends IServiceTrain<TIn, TOut>.

Options

builder.Services.AddTraxDashboard(options =>
{
    options.Title = "My App";  // Header text (default: "Trax")
    options.RequirePolicy("TraxAdmin");
});

The dashboard is served at /trax and the path is not configurable: every page carries a compile-time @page "/trax/..." route. The routePrefix argument to UseTraxDashboard only builds the sidebar navigation links, so passing anything else leaves the pages in place and breaks the links:

app.UseTraxDashboard();  // served at /trax

Layout

The dashboard uses Radzen Blazor v11 components with a sidebar navigation layout. A theme toggle in the header switches between light and dark mode, with the preference persisted in localStorage.

When a page's refresh fails, the header shows Last refresh failed (the error as its tooltip) until a refresh succeeds; the rows on screen are then from the last refresh that worked. A grid that loads its rows page by page from the server clears them and shows the error when a load fails, rather than keeping the previous filter's or page's rows. A page's first load and the reload after navigating to another row are refreshes too: a failure is shown and retried on the next tick, and never ends the operator's connection. A custom IDashboardSettingsService receives failures through NotifyPollFailed and exposes the latest as LastPollError; both have default implementations.

User Settings

The User Settings page (/trax/settings/user) lets each user customize their dashboard experience. Settings are stored in browser localStorage and only affect the current session.

SettingDefaultDescription
Polling Interval5 secondsHow often dashboard pages re-query for fresh data. Range: 1–300 seconds.
Hide Administration TrainstrueExclude scheduler internals (ManifestManager, JobDispatcher, JobRunner, MetadataCleanup, DeadLetterCleanup) from statistics, charts, the Trains page and the Metadata and Manifests lists. A train is hidden only when its name is one of theirs exactly, as the API's hideAdminTrains filter matches, so a train of your own whose name merely ends the same way stays visible.
Dashboard ComponentsAll visibleToggle visibility of the home page panels: Server Health, Summary Cards, Executions Chart, Failures (which also holds the throughput chart) and Avg Execution Duration.

Integration with Existing Blazor Apps

If your application already uses Blazor Server, the dashboard's AddTraxDashboard() call is safe to use alongside your existing AddRazorComponents(). The dashboard pages use their own layout, so they won't interfere with your app's UI.

If your application is a minimal API or MVC app that doesn't use Blazor, the dashboard adds the necessary Blazor Server infrastructure automatically.

Troubleshooting

"Page doesn't load" or blank screen

UseTraxDashboard() needs to be called after builder.Build() and before app.Run(). If it's missing or misordered, the Blazor endpoints won't be mapped.

var app = builder.Build();
 
app.UseTraxDashboard();  // After Build(), before Run()
 
app.Run();

"UseTraxDashboard() requires the Trax Scheduler"

The host registers no Scheduler. The dashboard's pages work through the Scheduler's IOperationsService, so add AddScheduler() after AddMediator(...) inside AddTrax(...). Without it the pages would fail on their first request, so UseTraxDashboard() refuses to map them.

"AddTraxDashboard() has already been called on this host"

Two calls to AddTraxDashboard() reached the same service collection, often one in a shared bootstrap and one in the host. Keep one, with every dashboard option in it: a second call would have replaced the first one's authorization posture.

"UseTraxDashboard() needs to know who may use the dashboard"

No authorization posture was chosen. Add one to the AddTraxDashboard options: RequirePolicy("<name>"), RequireRoles("<role>"), or AllowAnonymousDashboard() when a fallback policy or an ingress rule in front of /trax is the gate.

"No trains listed"

The dashboard discovers trains by scanning ServiceDescriptor entries in your DI container. If the grid is empty:

Causes:

  • The assembly containing your trains wasn't passed to AddMediator
  • AddTraxDashboard() was called before the trains were registered, so the captured IServiceCollection snapshot doesn't include them yet

Fix: Make sure AddTrax() is called before AddTraxDashboard(), and that AddTraxDashboard() is called after the trains are registered:

builder.Services.AddTrax(trax => trax
    .AddEffects(effects => effects
        .UsePostgres(connectionString)
    )
    .AddMediator(typeof(Program).Assembly)
    .AddScheduler()
);
builder.Services.AddTraxDashboard(o => o.RequireRoles("Admin"));  // After AddTrax() and trains are registered

If AddTrax() is missing entirely, AddTraxDashboard() throws InvalidOperationException.

Blazor static assets returning 404

If styles are missing or _content/ paths return 404, check that UseStaticFiles() is in your middleware pipeline. UseTraxDashboard() calls it internally, but if something earlier in the pipeline is short-circuiting requests, the static file middleware might not run.

Duplicate train entries in the grid

AddScopedTraxRoute<IMyTrain, MyTrain>() registers two DI descriptors: one for the concrete type and one for the interface. The discovery service attempts to deduplicate these, but in some cases both registrations appear in the grid. The entries will have the same input/output types; one will show the interface name and the other the concrete class name.

This is cosmetic and doesn't affect train execution. If it bothers you, it's a known limitation of how the discovery service groups factory-based descriptors.

Architecture

The dashboard sits near the bottom of the dependency tree, below the Scheduler and the API's persisted-operations package:

Trax.Effect ── Trax.Effect.Data
    └── Trax.Mediator
            └── Trax.Scheduler
                    └── Trax.Api.GraphQL.PersistedOperations
                            └── Trax.Dashboard (UI)

It references Trax.Effect, Trax.Effect.Data, Trax.Mediator, Trax.Scheduler and Trax.Api.GraphQL.PersistedOperations, and no database provider: the host picks one. At runtime it needs the Scheduler registered (UseTraxDashboard() refuses to start without it), and it shows the persisted-operations pages only when the host registers that service.

SDK Reference

AddTraxDashboard | UseTraxDashboard | AddScheduler | AddJunctionProgress | AddLifecycleHook | ITrainDiscoveryService