Building an Admin Dashboard
Combine BptServerMonitor, BptTerminalClient, and BptThemeBuilder into a production-grade admin app with authentication, role-based authorization, real-time metrics, and live theme switching.
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");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.
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 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.
BptThemeBuildersets 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 withcontain: 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.