Building an Admin Dashboard

~30 min Advanced

Combine BptServerMonitor, BptTerminalClient, and BptThemeBuilder into a production-grade admin app with authentication, role-based authorization, real-time metrics, and live theme switching.

Home / Learning / Building an Admin Dashboard
What you'll build A multi-page admin shell scoped to authenticated Administrator users, with three primary surfaces: a server-health page (CPU/GPU/memory/network), a remote-shell page (in-browser SSH/terminal), and a theme-customization page (live preview + export). 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 → BptServerMonitor (full-width grid) │ │ │ ├─ Terminal.razor → BptTerminalClient (sandbox + ACL) │ │ │ ├─ Theme.razor → BptThemeBuilder (live preview) │ │ │ └─ _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 │ │ ├─ MapBptServerMonitorHub() │ │ └─ MapBptTerminalHub() │ └──────────────────────────────────────────────────────────────────┘

The two hub mappings are critical — both BptServerMonitor and BptTerminalClient rely on SignalR for streaming. Without the hubs mapped, the components render but report "disconnected" forever.


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")); }); // After app.UseAuthorization(): app.MapBptServerMonitorHub(); app.MapBptTerminalHub(); // Optional but recommended: protect the hubs themselves at the // SignalR level so a non-admin can't bypass page-level checks. app.MapBptServerMonitorHub().RequireAuthorization("AdminOnly"); app.MapBptTerminalHub().RequireAuthorization("AdminOnly");
Hub authorization is your real perimeter Page-level AuthorizeView hides the UI, but it does not prevent a determined user from opening a SignalR connection directly to the hub URL. Always apply RequireAuthorization at 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

BptServerMonitor 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 Bpt.Components.Tools <h2>Server health</h2> <div class="d-flex gap-2 mb-3"> <BptSwitch @bind-Value="_showProcs" Label="Process list" /> <BptSwitch @bind-Value="_showLogs" Label="Server logs" /> <BptDropdown Items="_intervals" @bind-Value="_refresh" /> </div> <BptServerMonitor 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 in a tabbed view. Each entry needs a Domain reachable from the application server — the metrics agent runs there, not in the browser.

private List<BptServerMonitorConfig> _fleet = new() { new() { FriendlyName = "Web (US-East)", Domain = "web-east.internal" }, new() { FriendlyName = "DB Primary (EU)", Domain = "db-eu.internal" }, new() { FriendlyName = "GPU Worker", Domain = "gpu.internal" } }; <BptServerMonitor Servers="_fleet" ShowCPUMetrics="true" ShowGPUMetrics="true" RefreshInterval="2000" />

Alerting on sustained anomalies

OnMetricsReceived fires every refresh interval. 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) { _cpuWindow.Enqueue(data.CpuTotalPercent); 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

BptTerminalClient 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 @inject AuditService Audit @inject AuthenticationStateProvider Auth <BptTerminalClient Height="600px" Shell="@_shell" Sandbox="true" BlockedCommands="_blocked" Theme="BptTerminalTheme.MateDesktop" OnSessionCreated="OnSessionStart" OnCommandExecuted="OnCommand" OnSessionClosed="OnSessionEnd" /> @code { private string _shell = OperatingSystem.IsWindows() ? "powershell.exe" : "/bin/bash"; private string[] _blocked = new[] { "rm -rf /", "shutdown", "reboot", "dd if=", ":(){:|:&};:" }; 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" drops the shell into a chroot-like environment with a heavily restricted PATH and read-only filesystem (the implementation varies by host OS). It's the right default for support staff who shouldn't need destructive privileges.

For full sysadmin access, pass Sandbox="false" — but pair it with role escalation (e.g. require a separate SuperAdmin role) and aggressive auditing. The BlockedCommands array provides a final tripwire: any line beginning with a substring from the list is rejected before execution.

Auditing is non-negotiable Every command executed through a web terminal is a potential incident-response artifact. OnCommandExecuted fires after each line — 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

BptThemeBuilder renders a complete theme editor: a sidebar with predefined themes, live previews of every BPT component, and an export pipeline that produces a downloadable theme bundle. Use it both for "let admins rebrand the app" and "let our designers iterate on the dark mode palette".

@page "/admin/theme" @layout AdminLayout <BptThemeBuilder Height="calc(100vh - 200px)" ThemeSidebar="true" DefaultTheme="Light" EnableImport="true" OnThemeExportedRich="OnExported" OnImportFailed="OnImportError" /> @code { private async Task OnExported(BptThemeExport export) { // export.Name, export.Format, export.Content // Persist to the database so the theme survives app restarts: await _themeService.SaveAsync(export); _toaster.Show($"Theme '{export.Name}' saved."); } private void OnImportError((string Source, string Message) failure) { _toaster.ShowError($"Failed to import {failure.Source}: {failure.Message}"); } }

Loading a saved theme on app startup

To make the customized theme apply to all users (not just inside the editor), persist the export and load it during app bootstrap. BptThemeBuilder accepts ThemeBytes and ThemeBytesFormat for in-memory hydration:

// In Program.cs, after the app builds but before app.Run(): var savedTheme = await app.Services.GetRequiredService<ThemeService>() .LoadAsync(); if (savedTheme is not null) { BptThemeBuilder.SetGlobalDefault(savedTheme.Bytes, savedTheme.Format); }

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 --> <div class="admin-statusbar"> <BptServerMonitor 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. The same hub powers both this strip and the full /admin/dashboard page — there's no double cost.

Step 7: Deployment considerations

SignalR sizing

The terminal can throughput megabytes per second on commands that spew (find /, cat /var/log/syslog). The default SignalR receive limit (32 KB) is far too small. BPT bumps it for you, but verify in production load tests — the symptom is "the terminal hangs" rather than an error message.

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.

Theme persistence across deploys

A theme stored only in memory is reset every time you redeploy. Save the BptThemeExport blob 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 AdminLayout on a page but also list it in App.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. BptThemeBuilder sets CSS custom properties at the root. If you embed the builder mid-page, the custom theme briefly applies to the surrounding chrome during preview. Render it on a dedicated page or inside a scoped container with contain: style.
  • Terminal "frozen" after deploy. Hot-reload during development can leave stale SignalR connections in the browser cache. Hard-refresh (Ctrl+Shift+R) after deploys to force a fresh hub negotiation.
  • Sandbox doesn't sandbox everything. On Linux, Sandbox="true" chroots into a restricted FS and unsets dangerous env vars — but it doesn't isolate network access. If you need full container-level isolation, run the shell inside a real Docker container and have BPT exec into it.

An unhandled error has occurred. Reload 🗙

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please reload the page.