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

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:

  1. Generate one key for the whole fleet (on Windows, [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32)) in PowerShell does the same):

    openssl rand -base64 32
    
  2. Give it to the app as ServerMonitor:MonitorKey: user secrets in development, the environment variable ServerMonitor__MonitorKey or your secrets store in production, never source control. MapIsabyteServerMonitorIngest refuses to start with a key shorter than 32 characters.

  3. On every monitored host, put the same key in the service's appsettings.json and point HubUrl at 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. OnCommandExecuted fires 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 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. IsaThemeLoader sets CSS custom properties at the root by default. If a theme should only restyle part of a page, pass Scope (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.

See also

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.