Tutorial: Building an Admin Dashboard — Blazor
Combine IsabyteServerMonitor, IsabyteTerminalClient, and IsaThemeLoader into a production-grade admin app with authentication, role-based authorization, real-time metrics, and live theme switching.
Level: Advanced · Time: ~30 minutes
What you'll build
A multi-page admin shell scoped to authenticated
Administratorusers, with three primary surfaces: a server-health page (CPU/GPU/memory/network), a remote-shell page (in-browser SSH/terminal), and a theme page (switch between themes exported from Theme Studio). All three pages share the same layout, cascading auth state, and theme.
Architecture overview
Before wiring components, settle on the shape of the app. The recommended layout for an admin dashboard built on BPT looks like this:
┌──────────────────────────────────────────────────────────────────┐
│ Pages/ │
│ ├─ Admin/ │
│ │ ├─ Dashboard.razor → IsabyteServerMonitor (full-width grid) │
│ │ ├─ Terminal.razor → IsabyteTerminalClient (sandbox + ACL) │
│ │ ├─ Theme.razor → IsaThemeLoader (theme picker) │
│ │ └─ _Layout.razor → Admin chrome, nav, user pill │
│ └─ Account/Login.razor │
├──────────────────────────────────────────────────────────────────┤
│ Components/ │
│ ├─ AdminGuard.razor → <AuthorizeView Roles="Administrator">│
│ ├─ AuditLog.razor → Persists OnCommandExecuted events │
│ └─ UserPill.razor → Identity name + logout │
├──────────────────────────────────────────────────────────────────┤
│ Program.cs │
│ ├─ AddAuthentication / AddAuthorization │
│ ├─ AddIsabyteServerMonitor() │
│ ├─ MapIsabyteServerMonitorHub() │
│ ├─ MapIsabyteServerMonitorIngest(monitorKey) │
│ └─ MapIsabyteTerminalHub() │
└──────────────────────────────────────────────────────────────────┘
The two hub mappings matter whenever the components run in the browser
(WebAssembly, or Auto once it has switched): there IsabyteServerMonitor and
IsabyteTerminalClient reach the server through these SignalR hubs, and without
them they render but never connect. Under Interactive Server they collect metrics
and run the shell inside the circuit and do not use the hubs — map them anyway if
any page can render in WebAssembly. One exception: IsabyteServerMonitor always receives
remote hosts' metrics through its hub (see Multi-server monitoring in Step 3).
Step 1: Authentication and the Administrator role
Admin dashboards expose actions that must not be reachable by anonymous or low-privilege
users. The cleanest model in ASP.NET Core is role-based: introduce an Administrator
role, restrict the admin route group to it, and gate every admin page with
<AuthorizeView Roles="Administrator">.
In Program.cs:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireRole("Administrator"));
});
// One registration for the monitor routes (the ingest route refuses to start without it).
builder.Services.AddIsabyteServerMonitor();
// After app.UseAuthorization(): map each hub ONCE, and protect it at the
// SignalR level so a non-admin can't bypass page-level checks.
app.MapIsabyteServerMonitorHub().RequireAuthorization("AdminOnly");
app.MapIsabyteTerminalHub().RequireAuthorization("AdminOnly");
// The server-monitor service on your other hosts has no user login. It pushes to
// a separate, push-only route guarded by one shared monitor key (Step 3).
app.MapIsabyteServerMonitorIngest(builder.Configuration["ServerMonitor:MonitorKey"]!);
Hub authorization is your real perimeter
Page-level
AuthorizeViewhides the UI, but it does not prevent a determined user from opening a SignalR connection directly to the hub URL. Always applyRequireAuthorizationat the hub mapping — that's the security boundary that matters. The page guard is for UX, not safety.
Step 2: A protected admin layout
Create a dedicated layout used only by admin pages. It cascades auth state, renders shared admin
chrome, and gates everything inside an AuthorizeView so unauthorized users see a
friendly fallback instead of a half-rendered dashboard.
@inherits LayoutComponentBase
@using Microsoft.AspNetCore.Components.Authorization
<AuthorizeView Roles="Administrator">
<Authorized>
<div class="admin-shell">
<aside class="admin-sidebar">
<a href="/admin/dashboard">Dashboard</a>
<a href="/admin/terminal">Terminal</a>
<a href="/admin/theme">Theme</a>
</aside>
<header class="admin-header">
<UserPill />
</header>
<main class="admin-content">@Body</main>
</div>
</Authorized>
<NotAuthorized>
<div class="text-center mt-5">
<h3>Restricted area</h3>
<p>You need administrator access to view this page.</p>
<a href="/Account/Login" class="btn btn-primary">Sign in</a>
</div>
</NotAuthorized>
</AuthorizeView>
Apply this layout from each admin page with @layout AdminLayout. Combined with
the hub-level RequireAuthorization from Step 1, you have defense-in-depth: the page
renders nothing for non-admins, and the SignalR connections refuse to upgrade even if the page
somehow does render.
Step 3: The server-health page
IsabyteServerMonitor exposes seven independent metric panels. For a dashboard, render
the lot — toggling them at runtime lets the admin focus on what matters.
@page "/admin/dashboard"
@layout AdminLayout
@using Isabyte.PowerTools.Components.Tools
@using Isabyte.PowerTools.Blazor.ServerMonitor
<h2>Server health</h2>
<div class="d-flex gap-2 mb-3">
<IsabyteSwitch @bind-Value="_showProcs" Label="Process list" />
<IsabyteSwitch @bind-Value="_showLogs" Label="Server logs" />
<IsabyteDropdown Items="_intervals"
@bind-Value="_refresh" />
</div>
<IsabyteServerMonitor ShowCPUMetrics="true"
ShowGPUMetrics="true"
ShowMemoryMetrics="true"
ShowNetworkUsage="true"
ShowProcessList="@_showProcs"
ShowSystemInfo="true"
ShowServerLogs="@_showLogs"
RefreshInterval="@int.Parse(_refresh)"
Theme="ServerMonitorTheme.Dark"
OnMetricsReceived="OnMetrics" />
@code {
private bool _showProcs;
private bool _showLogs;
private string _refresh = "2000";
private List<string> _intervals = new() { "1000", "2000", "5000", "10000" };
private void OnMetrics(ServerMetricsCallbackData data)
{
// Hook for alerting: fire a notification if CPU sustained > 90%, etc.
}
}
Multi-server monitoring
Pass a Servers list and BPT renders the same metrics for each host, one section per host.
"localhost" is the web host itself. Every other host needs the server-monitor service
(Windows or Linux) installed on it: the service connects out to your app's ingest route
(its ServerMonitor:HubUrl setting) and pushes its metrics under ServerMonitor:ServerId, the
machine name by default. The entry's Domain must equal that id.
The service never connects to the dashboard hub. It pushes to the ingest route from Step 1
(/isabyte-servermonitor-ingest), which accepts nothing but metrics and log lines, and only from
a caller that presents the shared monitor key. The dashboard hub keeps your AdminOnly login and
refuses pushes. Set the key up once:
Generate one key for the whole fleet (on Windows,
[Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))in PowerShell does the same):openssl rand -base64 32Give it to the app as
ServerMonitor:MonitorKey: user secrets in development, the environment variableServerMonitor__MonitorKeyor your secrets store in production, never source control.MapIsabyteServerMonitorIngestrefuses to start with a key shorter than 32 characters.On every monitored host, put the same key in the service's
appsettings.jsonand pointHubUrlat the ingest route:{ "ServerMonitor": { "HubUrl": "https://your-app/isabyte-servermonitor-ingest", "MonitorKey": "<the same key>", "ServerId": "db-eu-01" } }
A missing or wrong key is refused with 401 Unauthorized, and the service logs which setting
to fix.
<IsabyteServerMonitor Servers="_fleet"
ShowCPUMetrics="true"
ShowGPUMetrics="true"
RefreshInterval="2000" />
@code {
private List<IsabyteServerMonitorConfig> _fleet = new()
{
new() { FriendlyName = "Web host", Domain = "localhost" },
new() { FriendlyName = "DB Primary (EU)", Domain = "db-eu-01" },
new() { FriendlyName = "GPU Worker", Domain = "gpu-worker-01" }
};
}
Alerting on sustained anomalies
OnMetricsReceived fires once per metric panel on every refresh: WidgetName says which
("CPU", "GPU", "Memory", "Network", "Processes", "SystemInfo") and Data carries that panel's
record (CpuMetrics for "CPU", from Isabyte.PowerTools.Blazor.ServerMonitor). Don't alert on a
single high reading — keep a small rolling window per metric and trigger when the average
crosses your threshold:
private readonly Queue<double> _cpuWindow = new();
private const int WindowSize = 30; // 30 samples × 2s = 60s
private const double CpuAlertThreshold = 90;
private void OnMetrics(ServerMetricsCallbackData data)
{
if (data.Data is not CpuMetrics cpu) return; // one callback per panel — keep the CPU one
_cpuWindow.Enqueue(cpu.TotalPercent);
while (_cpuWindow.Count > WindowSize) _cpuWindow.Dequeue();
if (_cpuWindow.Count == WindowSize
&& _cpuWindow.Average() > CpuAlertThreshold)
{
_ = _alertService.NotifyAsync("CPU sustained > 90% for 60s");
_cpuWindow.Clear(); // Avoid alert spam
}
}
Step 4: The remote-shell page
IsabyteTerminalClient embeds a full xterm-compatible terminal that streams over SignalR
to a shell process running on the server. For an admin app, four things matter beyond rendering it:
sandbox mode, blocked commands, session auditing, and identifying the user behind each session.
@page "/admin/terminal"
@layout AdminLayout
@using Isabyte.PowerTools.Components.Tools
@using Microsoft.AspNetCore.Components.Authorization
@inject AuditService Audit
@inject AuthenticationStateProvider Auth
<IsabyteTerminalClient Height="600px"
Shell="@_shell"
Sandbox="true"
BlockedCommands="_blocked"
Theme="IsabyteTerminalTheme.MateDesktop"
OnSessionCreated="OnSessionStart"
OnCommandExecuted="OnCommand"
OnSessionClosed="OnSessionEnd" />
@code {
private string _shell = OperatingSystem.IsWindows()
? "powershell.exe"
: "/bin/bash";
private string[] _blocked = new[]
{
"rm", "shutdown", "reboot", "dd", "mkfs"
};
private string? _currentUser;
private string? _sessionId;
protected override async Task OnInitializedAsync()
{
var state = await Auth.GetAuthenticationStateAsync();
_currentUser = state.User.Identity?.Name;
}
private async Task OnSessionStart(string sessionId)
{
_sessionId = sessionId;
await Audit.LogAsync(_currentUser, "terminal.session.start", sessionId);
}
private async Task OnCommand(string command)
{
await Audit.LogAsync(_currentUser, "terminal.command", command);
}
private async Task OnSessionEnd(string sessionId)
{
await Audit.LogAsync(_currentUser, "terminal.session.end", sessionId);
}
}
Sandbox vs. unrestricted mode
Sandbox="true" never starts a shell. The component runs a simulated terminal in
memory — a small demo home directory, a fixed set of read-only commands (ls, cat,
cd, pwd, …) and a refusal for anything that writes, installs or reaches the network —
so nothing touches the server's files or processes. Use it for demos and public pages;
it cannot administer anything.
Real administration needs Sandbox="false": a real shell running with the permissions of
your application's process identity. Pair it with role escalation (e.g. require a separate
SuperAdmin role), a least-privilege service account and aggressive auditing. The
BlockedCommands array is a last tripwire, not a boundary: a command that equals an entry,
or starts with an entry followed by a space, is cancelled before it reaches the shell.
Auditing is non-negotiable
Every command executed through a web terminal is a potential incident-response artifact.
OnCommandExecutedfires for each command line, before it is sent to the shell — write to a tamper-evident store (append-only log, signed entries, or external SIEM) and include the timestamp, user identity, session ID, and command text. Page-internal in-memory logs are not enough.
Step 5: Live theme customization
Themes are designed in Theme Studio on the Isabyte hub and
exported as a token file (CSS or JSON); the theme editor itself is not part of the package. In the
app, IsaThemeLoader applies the exported files. Give admins a picker on the theme page:
@page "/admin/theme"
@layout AdminLayout
<IsaThemeLoader Themes="_themes"
@bind-SelectedTheme="_currentTheme"
Placeholder="Choose a theme…"
OnThemeChanged="OnThemeApplied"
OnError="OnThemeError" />
@code {
private string _currentTheme = "/themes/light.json";
private Dictionary<string, string> _themes = new()
{
["/themes/light.json"] = "Light",
["/themes/dark.json"] = "Dark",
["/themes/acme-brand.json"] = "ACME Brand"
};
private async Task OnThemeApplied(string themeUrl)
{
// Persist the choice so the theme survives app restarts:
await _themeService.SaveAsync(themeUrl);
}
private void OnThemeError(string message)
{
_toaster.ShowError($"Failed to load theme: {message}");
}
}
Loading a saved theme on app startup
To make the chosen theme apply to all users (not just on the theme page), put a headless
IsaThemeLoader in your main layout and point it at the saved theme — Url for a file, or
Data plus Format for theme bytes you stored in the database:
<!-- MainLayout.razor -->
@inject ThemeService ThemeService
@if (_savedTheme is not null)
{
<IsaThemeLoader Data="_savedTheme.Bytes" Format="_savedTheme.Format" />
}
@code {
private SavedTheme? _savedTheme;
protected override async Task OnInitializedAsync()
=> _savedTheme = await ThemeService.LoadAsync();
}
Step 6: Composition — the unified shell
The three pages share the same layout, so cross-page state flows naturally. A common pattern: let the admin pin a "compact" status bar to the layout chrome that always shows critical metrics, regardless of which sub-page is active.
<!-- In AdminLayout.razor, above @Body -->
@using Isabyte.PowerTools.Components.Tools
<div class="admin-statusbar">
<IsabyteServerMonitor ShowCPUMetrics="true"
ShowMemoryMetrics="true"
ShowGPUMetrics="false"
ShowNetworkUsage="false"
ShowProcessList="false"
ShowSystemInfo="false"
ShowServerMonitorFrame="false"
Height="80px"
RefreshInterval="5000" />
</div>
<main class="admin-content">@Body</main>
Because ShowServerMonitorFrame="false" drops the panel chrome, the bar reads as a
compact status strip rather than another card. Each IsabyteServerMonitor collects on its
own schedule, so on /admin/dashboard the strip and the full monitor sample twice — keep the
strip's RefreshInterval slow (5000 ms above).
Step 7: Deployment considerations
SignalR sizing
The package does not change any SignalR option. MaximumReceiveMessageSize (default
32 KB) caps what the server receives — terminal keystrokes and pasted text, not the output
the shell streams back — and a message over the limit closes the connection, which looks like
a frozen terminal rather than an error. Raise it if your admins paste large blocks, and set
the timeouts to suit long-running sessions:
builder.Services.AddSignalR(opts =>
{
opts.MaximumReceiveMessageSize = 4 * 1024 * 1024; // 4 MB
opts.ClientTimeoutInterval = TimeSpan.FromMinutes(2);
opts.KeepAliveInterval = TimeSpan.FromSeconds(15);
});
Reverse-proxy WebSockets
If you sit behind Traefik, nginx, IIS or similar, ensure WebSocket upgrade is forwarded. SignalR falls back to long-polling otherwise — functional, but the terminal will feel sluggish (300–600 ms input lag instead of the usual sub-10 ms).
Hub authorization, again
It bears repeating: RequireAuthorization("AdminOnly") on the hub mapping is your
real security boundary. Test it by signing in as a non-admin and calling the hub URL directly
from curl or Postman — the connection should fail at upgrade.
The ingest route is the one exception: it has no user, so the monitor key is its credential. Treat the key as a secret. If it leaks, someone can post fake metrics but still cannot read the dashboard; replace it in the app and on every service.
Theme persistence across deploys
A theme stored only in memory is reset every time you redeploy. Save the exported theme file to your database (or a shared filesystem) and reload it during startup as shown in Step 5.
Graceful terminal session cleanup
Browser tabs close without warning. The server-side shell process keeps running unless your
hub gracefully reaps disconnected sessions. BPT does this automatically on
OnDisconnectedAsync, but verify by watching ps aux | grep bash after
rapidly opening and closing tabs — you should see process count return to baseline within a
minute or two.
Common gotchas
- Two layouts confuse cascading auth. If you use
@layout AdminLayouton a page but also list it inApp.razor's top-level routing, the layout may be applied twice. Symptom: nested admin chrome. Fix: layout assignment lives in one place only. - Theme tokens leak across pages.
IsaThemeLoadersets CSS custom properties at the root by default. If a theme should only restyle part of a page, passScope(a CSS selector such as.my-preview) so the loaded theme applies to that subtree only. - Terminal "frozen" after deploy. A restart ends every shell session on the server. Under Interactive Server the circuit is lost with it. In WebAssembly the terminal's hub connection reconnects on its own, but as a new connection that no longer owns a shell, and the component does not start one on reconnect, so the terminal stays on screen and silent. Either way, reload the page to start a fresh session; there is nothing stale in the browser cache to clear.
- Sandbox is a simulation, not a jail.
Sandbox="true"never reaches the server, so it is safe but useless for real work;Sandbox="false"is a real shell with your app's permissions and no isolation of its own. If admins need a shell but must not see the web host, run the application (or at least the terminal hub) under a least-privilege account or inside its own container.
Next
- Next: Custom Theme Development — Deep-dive into the BPT theming system, dark mode, multi-brand themes, and runtime switching.
- Live Demo — The server monitor, fully interactive; the terminal and theme loader demos are linked below.