Build a Live Operations Room

Build a real-time incident board with ASP.NET Core SignalR.

Introduction

30 Second Summary

During an incident, one person can post an update while everyone else is still staring at old information. Refreshing by hand leaves room for missed warnings and duplicated work.

In this project, you will build a local Live Operations Room with C#. ASP.NET Core SignalR will carry each structured incident update from the server to every browser tab in the selected room.

What You'll Build

Publish an incident in one browser tab to watch every tab in the same room show it immediately with a server-generated timestamp.

By the end of this project, you'll have:

  • Publish a structured incident update containing an operator, severity, message, room, and server timestamp.
  • Open multiple browser tabs to watch the same incident arrive in real time without refreshing.
  • Use room-specific channels so updates sent to one room stay separate from every other room.
  • Secret Mission: add automatic reconnect behavior that restores the selected room after the server connection returns.

Are there any prerequisites?

No networking experience is required. Basic familiarity with files, forms, and running a project in Visual Studio 2022 is helpful.

Before We Start

Before any hands-on work, lock in why your Live Operations Room needs shared communication. Each browser tab keeps its own memory, so a shared server must carry incident updates between tabs without a refresh.

Set Up a Compatible SignalR Project

The shared-server design from the last step needs a compatible ASP.NET Core SignalR development stack before any real-time call can run. A mismatched SDK can stop the app before the browser connects.

You will confirm your Visual Studio 2022 environment first. Then you will create a local Razor Pages project with a pinned browser client for .NET.

In this step, get ready to:
  • Check your Visual Studio version, web development workload, .NET SDK, and browser.
  • Create a local Razor Pages project named RealtimeOps.
  • Restore the pinned SignalR browser client and run the starter site.
Check the Windows development environment

Visual Studio supplies the project template and web development tools. The installed .NET SDK compiles the project for its target framework.

  • Press the Windows key to open Windows search.
  • Type Visual Studio 2022 and press Enter to open it.
  • Select Help in the top menu bar.
  • Select About Microsoft Visual Studio to check the installed version.

✔️ I see a compatible Visual Studio version

A version of 17.8 or later can target net8.0.

  • Close the About Microsoft Visual Studio window.

ⓧ I see an older Visual Studio version

An older build needs an update before it can reliably target net8.0.

  • Close the About Microsoft Visual Studio window.
  • Follow the official Visual Studio installation guide to update your Visual Studio 2022 installation.
  • Return to About Microsoft Visual Studio after the update.
  • Confirm the listed version is 17.8 or later.

ⓧ I cannot find Visual Studio

Visual Studio provides the Windows project workflow used throughout this project.

  • Follow the official Visual Studio installation guide to install Visual Studio 2022.
  • Select the ASP.NET and web development workload during installation.
  • Launch Visual Studio after the installation finishes.
  • Confirm the installed version is 17.8 or later from About Microsoft Visual Studio.

The ASP.NET and web development workload provides the Razor Pages template plus browser tooling. Visual Studio Installer manages this bundle.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Installer and press Enter to open it.
  • Select Modify beside your Visual Studio 2022 installation.
  • Select the Workloads tab.
  • Check whether ASP.NET and web development has a selected checkbox.

✔️ The web workload is selected

The Razor Pages template and LibMan tooling are available.

  • Select Launch to return to Visual Studio.

ⓧ The web workload is not selected

Adding this workload installs the project template and client-library tooling required by this step.

  • Select the ASP.NET and web development workload.
  • Keep Install while downloading selected.
  • Select Modify to apply the change.
  • Approve the Windows permission prompt if it appears.
  • Select Launch after the workload finishes installing.

PowerShell can list every .NET SDK installed on your computer. This confirms whether a compatible SDK is available before you create the project.

  • Press the Windows key to open Windows search.
  • Type PowerShell and press Enter to open it.
  • List the installed .NET SDKs by running this command:
dotnet --list-sdks

What does this command show?

The dotnet --list-sdks command prints one line for each installed SDK. An entry beginning with 8.0 confirms that this project can target net8.0.

✔️ I see a compatible SDK

An SDK entry beginning with 8.0 means the .NET toolchain is ready.

ⓧ I only see incompatible SDKs

The computer has .NET tooling but does not have the SDK required for this project.

  • Open the official .NET 8 download page.
  • Download the Windows x64 SDK 8.0.425 installer.
  • Run the downloaded installer.
  • Return to PowerShell after the installation completes.
  • Repeat the SDK inventory command shown above.

You should now see an SDK entry beginning with 8.0.

ⓧ PowerShell cannot find dotnet

The .NET command-line tools need to be installed before Visual Studio can build the project.

  • Open the official .NET 8 download page.
  • Download the Windows x64 SDK 8.0.425 installer.
  • Run the downloaded installer.
  • Close PowerShell after the installation completes.
  • Open a fresh PowerShell window.
  • Repeat the SDK inventory command shown above.

You should now see an SDK entry beginning with 8.0.

Microsoft Edge provides the browser used for the starter-site check. A quick Windows search confirms whether it is available.

  • Press the Windows key to open Windows search.
  • Type Microsoft Edge and check whether the browser appears in the results.

✔️ Microsoft Edge appears

The browser is ready for the local project check.

ⓧ Microsoft Edge is missing

Install the browser from Microsoft before creating the project.

Environment check stuck?

Use the outcome tab that matches what you see before continuing. For environment-specific help, help me check my Visual Studio, web workload, and .NET SDK setup.

Create the Razor Pages project

A Razor Pages template gives you a runnable browser app without adding another development tool. The project stays on your computer and uses no paid service.

  • Return to Visual Studio from the environment check.
  • Select Create a new project.
  • Choose ASP.NET Core Web App (Razor Pages) as the project template.
  • Continue to the project settings.
  • Name the project RealtimeOps.
  • Select .NET 8.0 (Long Term Support) as the framework.
  • Create the project with the remaining template settings unchanged.

That is the project shell in place. Solution Explorer now shows the RealtimeOps project with the generated Razor Pages files.

Pin the browser client and run the starter

The SignalR server library is already part of ASP.NET Core. The JavaScript client is restored separately so the browser can connect to a hub in the next step.

  • Right-click the RealtimeOps project in Solution Explorer.
  • Select Add.
  • Select Client-Side Library.
  • Choose unpkg in the Provider field.
  • Enter @microsoft/signalr@8.0.7 in the Library field.
  • Select only dist/browser/signalr.min.js from the file list.
  • Set Target Location to wwwroot/js/signalr/.
  • Select Install.

The restore is complete when Visual Studio adds libman.json and downloads the selected browser bundle.

  • Confirm libman.json appears under the RealtimeOps project in Solution Explorer.
  • Expand the nested wwwroot/js/signalr/dist/browser/ folders in Solution Explorer.
  • Confirm signalr.min.js appears inside the browser folder.
  • Choose Microsoft Edge in Visual Studio's run-target control.

Before you run the project, do you expect the generated template to build cleanly and open in the browser?

  • Start the project without debugging by pressing Ctrl+F5.

Microsoft Edge opens the starter Razor Pages site without a build error. You have cleared the full setup path from compatible tools to a running local web app.

Starter site not opening?

A build failure usually points back to the SDK or web workload checks. Confirm those results before running the project again.

  • Repair the local HTTPS development certificate in PowerShell by running these commands if the browser reports a certificate problem:
dotnet dev-certs https --clean
dotnet dev-certs https --trust

The first command removes existing development certificates. The second command creates a trusted HTTPS development certificate.

  • Approve the Windows trust prompt if it appears.
  • Restart Microsoft Edge after the certificate commands finish.
  • Press Ctrl+F5 in Visual Studio again.
  • For another setup problem, help me troubleshoot my RealtimeOps starter project.

✔️ Awesome, I've got everything!

Your local RealtimeOps project runs in Microsoft Edge with the pinned SignalR browser client restored.

ⓧ I'd like to double check the full code

These are the generated project artifacts for this step. Compare them with the files in your RealtimeOps project.

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

</Project>

What does the project file confirm?

The TargetFramework value keeps the project on net8.0. The template also enables nullable checks and implicit using directives.

{
  "version": "3.0",
  "defaultProvider": "unpkg",
  "libraries": [
    {
      "library": "@microsoft/signalr@8.0.7",
      "destination": "wwwroot/js/signalr/",
      "files": [
        "dist/browser/signalr.min.js"
      ]
    }
  ]
}

What does the LibMan manifest confirm?

  • The manifest uses version 3.0 with unpkg as its default provider.
  • The library identifier pins the browser client to @microsoft/signalr@8.0.7.
  • The destination places the selected file under wwwroot/js/signalr/.

LibMan restores the third-party browser bundle from @microsoft/signalr@8.0.7. You can find it at wwwroot/js/signalr/dist/browser/signalr.min.js.

Your compatible local project is running with its browser dependency in place. Next, you will turn the starter page into an incident board and expose the limitation of browser-only state.

Build a Local-Only Incident Board

Your Razor Pages starter site now runs with the browser client files ready. The next goal is a Live Operations Room that can publish structured incident updates.

A local JavaScript event handler can update the browser's DOM immediately.

The key question is whether another browser tab shares that incident feed. You will test that question after the board works in one tab.

In this step, get ready to:
  • Replace the starter page with the operations-room interface.
  • Render structured incident objects in the browser.
  • Test whether two browser tabs share their incident updates.
Replace the starter page with the operations UI

The page needs stable element IDs so JavaScript can read each form field. It also needs an empty incident feed where updates can appear.

  • Switch back to Visual Studio.
  • Stop the running project with the stop control in the top toolbar.
  • In Solution Explorer, expand the Pages folder.
  • Double-click Index.cshtml.
  • Select all existing content in Index.cshtml.
  • Replace the selected content with the code below:
@page
@{
    ViewData["Title"] = "Live Operations Room";
}

<section class="ops-shell">
    <div class="ops-header">
        <div>
            <p>SignalR protocol lab</p>
            <h1>Live Operations Room</h1>
            <p>Publish structured incident updates to everyone in the same room.</p>
        </div>
        <span id="connectionStatus" class="status offline">Local-only mode</span>
    </div>
    <form id="joinForm" class="panel form-grid">
        <label>Room <input id="roomInput" value="alpha" maxlength="30" required /></label>
        <button id="joinButton" type="submit">Join room</button>
    </form>
    <form id="incidentForm" class="panel form-grid">
        <label>Operator <input id="operatorInput" value="Operator 1" maxlength="40" required /></label>
        <label>Severity <select id="severityInput"><option value="info">Info</option><option value="warning">Warning</option><option value="critical">Critical</option></select></label>
        <label>Incident update <input id="messageInput" maxlength="160" required /></label>
        <button id="publishButton" type="submit">Publish update</button>
    </form>
    <div class="panel"><h2>Live feed</h2><ul id="incidentList"></ul></div>
</section>
<script src="~/js/ops.js"></script>

What Does This Page Define?

  • The connectionStatus element shows whether the board is using local behavior.
  • The joinForm captures the room name used by each incident.
  • The incidentForm captures the operator name.
  • The same form captures the severity.
  • The same form captures the incident message.
  • The incidentList element provides an empty destination for published updates.
  • The final script element loads the behavior from ops.js.
  • Save Pages/Index.cshtml.
  • Run the updated app by pressing Ctrl+F5.

Microsoft Edge opens a page headed Live Operations Room. You can also see the room controls and the incident form.

Still Seeing the Starter Page?

  • Confirm that you edited Pages/Index.cshtml inside the RealtimeOps project.
  • Save the file before running the project again.
  • Refresh Microsoft Edge if it still shows the previous page.
  • Help me find why my Razor Pages app still shows the starter page.
Create the local message contract

A local incident object gives every update the same five fields. The textContent property renders that object as text without treating its message as markup.

  • Switch back to Visual Studio.
  • In Solution Explorer, expand the wwwroot folder.
  • Expand the js folder.
  • Right-click the js folder.
  • Use the new-item option to create a JavaScript file named ops.js.
  • Replace any generated content with the code below:
"use strict";

const statusElement = document.getElementById("connectionStatus");
const joinForm = document.getElementById("joinForm");
const incidentForm = document.getElementById("incidentForm");
const roomInput = document.getElementById("roomInput");
const operatorInput = document.getElementById("operatorInput");
const severityInput = document.getElementById("severityInput");
const messageInput = document.getElementById("messageInput");
const joinButton = document.getElementById("joinButton");
const publishButton = document.getElementById("publishButton");
const incidentList = document.getElementById("incidentList");

joinForm.addEventListener("submit", event => {
    event.preventDefault();
    statusElement.textContent = `Local room: ${roomInput.value.trim().toLowerCase()}`;
});

How Does the Local Room Work?

  • The constants store references to the page elements by their IDs.
  • The submit listener intercepts the room form before the browser reloads the page.
  • The room value is trimmed to remove surrounding spaces.
  • The room value is converted to lowercase so names such as Alpha and alpha produce the same local label.
  • Save wwwroot/js/ops.js.
  • Refresh Microsoft Edge.
  • Enter alpha in the Room field.
  • Click Join room.

The status changes from Local-only mode to Local room: alpha.

Room Status Not Changing?

  • Confirm that ops.js is inside wwwroot/js.
  • Check that the page script path is ~/js/ops.js.
  • Check that joinForm matches the form ID in Index.cshtml.
  • Help me debug why the local room status does not change.

The first chunk proves that the page can call code in ops.js. The incident handler can now build a structured object from the form values.

  • In ops.js, place your cursor after the joinForm submit handler.
  • Add the following code:
incidentForm.addEventListener("submit", event => {
    event.preventDefault();
    const incident = {
        room: roomInput.value.trim().toLowerCase(),
        operatorName: operatorInput.value.trim(),
        severity: severityInput.value,
        message: messageInput.value.trim(),
        sentAt: new Date().toISOString()
    };
    const item = document.createElement("li");
    item.className = `incident ${incident.severity}`;
    item.textContent = `${incident.severity.toUpperCase()} | ${incident.room} | ${incident.message} | ${incident.operatorName}`;
    incidentList.prepend(item);
    messageInput.value = "";
});

What Does the Incident Handler Do?

  • The incident object stores the room.
  • The same object stores the operator name.
  • The same object stores the severity.
  • The same object stores the message.
  • The same object stores an ISO timestamp in sentAt.
  • A new li element displays the incident through textContent.
  • The prepend call places the newest incident at the top of the feed.
  • Save wwwroot/js/ops.js.
  • Refresh Microsoft Edge.
  • Enter Database latency is rising in the Incident update field.
  • Select Warning in the Severity field.
  • Click Publish update.

The feed shows WARNING | alpha | Database latency is rising | Operator 1.

Incident Missing from the Feed?

  • Confirm that the message field contains text before you publish.
  • Check that incidentList matches the list ID in Index.cshtml.
  • Check that the incident handler ends with });.
  • Help me debug why my local incident does not appear.
Prove the tabs do not synchronize

The board now works in one tab. The next test checks whether another open tab receives the same incident without refreshing.

Before you publish, do you expect the second tab's feed to change at the same moment?

  • Select the current address in Microsoft Edge.
  • Copy the local URL.
  • Open a new browser tab.
  • Paste the local URL into the address bar.
  • Press Enter to load the board.
  • Return to the first tab.
  • Enter API error rate is climbing in the Incident update field.
  • Click Publish update.
  • Switch to the second tab.

The first tab shows the new incident. The second tab's feed remains empty.

That test exposed the exact gap this version was built to reveal. Each tab updates only its own page memory.

Why Does the Second Tab Stay Empty?

The submit handler creates the incident inside the first tab. It inserts the new list item into that tab's DOM.

No shared server operation carries the incident to the other tab. The second tab has no event to render.

✔️ Awesome, I've got everything!

  • Save Pages/Index.cshtml.
  • Save wwwroot/js/ops.js.

ⓧ I'd like to double check the full code

This checkpoint shows the complete local-only project files. The existing project configuration remains unchanged.

@page
@{
    ViewData["Title"] = "Live Operations Room";
}

<section class="ops-shell">
    <div class="ops-header">
        <div>
            <p>SignalR protocol lab</p>
            <h1>Live Operations Room</h1>
            <p>Publish structured incident updates to everyone in the same room.</p>
        </div>
        <span id="connectionStatus" class="status offline">Local-only mode</span>
    </div>
    <form id="joinForm" class="panel form-grid">
        <label>Room <input id="roomInput" value="alpha" maxlength="30" required /></label>
        <button id="joinButton" type="submit">Join room</button>
    </form>
    <form id="incidentForm" class="panel form-grid">
        <label>Operator <input id="operatorInput" value="Operator 1" maxlength="40" required /></label>
        <label>Severity <select id="severityInput"><option value="info">Info</option><option value="warning">Warning</option><option value="critical">Critical</option></select></label>
        <label>Incident update <input id="messageInput" maxlength="160" required /></label>
        <button id="publishButton" type="submit">Publish update</button>
    </form>
    <div class="panel"><h2>Live feed</h2><ul id="incidentList"></ul></div>
</section>
<script src="~/js/ops.js"></script>
"use strict";

const statusElement = document.getElementById("connectionStatus");
const joinForm = document.getElementById("joinForm");
const incidentForm = document.getElementById("incidentForm");
const roomInput = document.getElementById("roomInput");
const operatorInput = document.getElementById("operatorInput");
const severityInput = document.getElementById("severityInput");
const messageInput = document.getElementById("messageInput");
const joinButton = document.getElementById("joinButton");
const publishButton = document.getElementById("publishButton");
const incidentList = document.getElementById("incidentList");

joinForm.addEventListener("submit", event => {
    event.preventDefault();
    statusElement.textContent = `Local room: ${roomInput.value.trim().toLowerCase()}`;
});

incidentForm.addEventListener("submit", event => {
    event.preventDefault();
    const incident = {
        room: roomInput.value.trim().toLowerCase(),
        operatorName: operatorInput.value.trim(),
        severity: severityInput.value,
        message: messageInput.value.trim(),
        sentAt: new Date().toISOString()
    };
    const item = document.createElement("li");
    item.className = `incident ${incident.severity}`;
    item.textContent = `${incident.severity.toUpperCase()} | ${incident.room} | ${incident.message} | ${incident.operatorName}`;
    incidentList.prepend(item);
    messageInput.value = "";
});
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

</Project>
{
  "version": "3.0",
  "defaultProvider": "unpkg",
  "libraries": [
    {
      "library": "@microsoft/signalr@8.0.7",
      "destination": "wwwroot/js/signalr/",
      "files": ["dist/browser/signalr.min.js"]
    }
  ]
}

Your board now publishes structured incidents inside one browser tab. Next, ASP.NET Core SignalR becomes the shared server path that carries each update to every connected tab.

Broadcast Updates Through a SignalR Hub

Your operations board now proves that browser memory stays inside one tab. A second tab cannot discover an incident created by the first tab.

This step adds an ASP.NET Core SignalR hub as the shared server pipeline. One tab sends a structured incident to the hub. The hub immediately pushes that incident to every connected tab.

In this step, get ready to:
  • Define the structured C# incident protocol.
  • Register the SignalR hub endpoint.
  • Connect every browser tab to the hub.
Define the C# side of the protocol

A hub receives calls from connected browser clients. A strongly typed client interface also defines the operation that the server can send back.

The incident record gives every update the same structure. It carries the room name, operator name, message, severity, plus a timestamp generated by the server.

  • In the Solution Explorer panel, right-click the RealtimeOps project.
  • Select Add.
  • Select New Folder.
  • Name the folder Hubs.

The Hubs folder now gives your server-side real-time code a clear home.

  • Right-click the Hubs folder.
  • Select Add.
  • Select Class.
  • Enter OpsHub.cs in the Name field.
  • Click Add.
  • Replace the generated content in Hubs/OpsHub.cs with the code below:
using Microsoft.AspNetCore.SignalR;

namespace RealtimeOps.Hubs;

public interface IOpsClient
{
    Task IncidentReceived(IncidentUpdate incident);
}

public sealed record IncidentUpdate(string Room, string OperatorName, string Message, string Severity, DateTimeOffset SentAt);

public sealed class OpsHub : Hub<IOpsClient>
{
    public async Task PublishIncident(string room, string operatorName, string message, string severity)
    {
        if (string.IsNullOrWhiteSpace(room) || string.IsNullOrWhiteSpace(operatorName) || string.IsNullOrWhiteSpace(message))
        {
            throw new HubException("Room, operator, and message are required.");
        }
        var incident = new IncidentUpdate(room.Trim().ToLowerInvariant(), operatorName.Trim(), message.Trim(), severity.Trim().ToLowerInvariant(), DateTimeOffset.UtcNow);
        await Clients.All.IncidentReceived(incident);
    }
}

What Defines the Protocol?

  • The IOpsClient interface names IncidentReceived as the server-to-browser operation.
  • The IncidentUpdate record packages the incident fields into one structured message.
  • The PublishIncident method rejects blank required fields before creating the message.
  • The Clients.All.IncidentReceived call broadcasts the same incident to every connected client.
  • Save Hubs/OpsHub.cs.
  • Confirm that OpsHub.cs appears inside the Hubs folder in Solution Explorer.

Seeing Errors in OpsHub.cs?

  • Check that the first line imports Microsoft.AspNetCore.SignalR.
  • Check that the namespace is RealtimeOps.Hubs.
  • Make sure OpsHub inherits from Hub<IOpsClient>.
  • Help me troubleshoot my OpsHub.cs errors.
Register and map the hub

The hub class defines what the server can do. ASP.NET Core also needs to register the required SignalR services. An endpoint then gives browser clients a URL for reaching the hub.

  • Return to Program.cs in the RealtimeOps project.
  • Replace the entire contents of Program.cs with the code below:
using RealtimeOps.Hubs;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
builder.Services.AddSignalR();

var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapRazorPages();
app.MapHub<OpsHub>("/opsHub");
app.Run();

How Does the Hub Become Reachable?

  • The using RealtimeOps.Hubs directive makes OpsHub available in this file.
  • The builder.Services.AddSignalR() call registers the services required by SignalR hubs.
  • The app.MapHub<OpsHub>("/opsHub") call maps the hub to the browser endpoint used by this project.
  • The existing Razor Pages pipeline continues serving the operations-room page.
  • Save Program.cs.
  • Press Ctrl+F5 in Visual Studio to compile the project.

Microsoft Edge opens the operations board without a build error. This proves that ASP.NET Core accepted the hub registration plus the mapped endpoint.

  • Close the browser tab.
  • Close the app console window that Visual Studio opened.

Does the Project Fail to Build?

  • Check that using RealtimeOps.Hubs appears at the top of Program.cs.
  • Check that the mapped type is spelled OpsHub.
  • Confirm that Hubs/OpsHub.cs uses the RealtimeOps.Hubs namespace.
  • Help me fix my SignalR registration error.
Connect the browser clients

The page must load the pinned SignalR browser bundle before it loads your client code. The controls also start disabled so an incident cannot be published before the server connection succeeds.

  • Return to Pages/Index.cshtml in Solution Explorer.
  • Replace the entire contents of Pages/Index.cshtml with the code below:
@page
@{
    ViewData["Title"] = "Live Operations Room";
}

<section class="ops-shell">
    <div class="ops-header"><div><p>SignalR protocol lab</p><h1>Live Operations Room</h1><p>Publish structured incident updates to everyone in the same room.</p></div><span id="connectionStatus" class="status offline">Connecting</span></div>
    <form id="joinForm" class="panel form-grid"><label>Room <input id="roomInput" value="alpha" maxlength="30" required /></label><button id="joinButton" type="submit" disabled>Join room</button></form>
    <form id="incidentForm" class="panel form-grid"><label>Operator <input id="operatorInput" value="Operator 1" maxlength="40" required /></label><label>Severity <select id="severityInput"><option value="info">Info</option><option value="warning">Warning</option><option value="critical">Critical</option></select></label><label>Incident update <input id="messageInput" maxlength="160" required /></label><button id="publishButton" type="submit" disabled>Publish update</button></form>
    <div class="panel"><h2>Live feed</h2><ul id="incidentList"></ul></div>
</section>
<script src="~/js/signalr/dist/browser/signalr.min.js"></script>
<script src="~/js/ops.js"></script>

What Changed on the Page?

  • The status begins with Connecting while the browser contacts the hub.
  • The join button starts disabled until the connection succeeds.
  • The publish button stays disabled until the browser has selected a room.
  • The SignalR browser bundle loads before ops.js so the signalR object exists when your code runs.
  • Save Pages/Index.cshtml.
  • Press Ctrl+F5 to run the current page.

The banner reads Connecting. Both buttons remain disabled. This confirms that the page is waiting for client code to establish the real connection.

  • Close the browser tab.
  • Close the app console window.

Does the Page Look Incomplete?

  • Confirm that the SignalR script appears before ops.js at the bottom of Pages/Index.cshtml.
  • Confirm that the bundle path ends with signalr.min.js.
  • Help me check the SignalR script reference.

The browser client now needs a connection object plus a handler for server-pushed incidents. SignalR carries the structured C# record through its default JSON Hub Protocol. The JavaScript client receives camel-case properties that it can render safely with textContent.

  • Return to wwwroot/js/ops.js from the previous step.
  • Replace the local-only handler with the code below:
"use strict";

const statusElement = document.getElementById("connectionStatus");
const joinForm = document.getElementById("joinForm");
const incidentForm = document.getElementById("incidentForm");
const roomInput = document.getElementById("roomInput");
const operatorInput = document.getElementById("operatorInput");
const severityInput = document.getElementById("severityInput");
const messageInput = document.getElementById("messageInput");
const joinButton = document.getElementById("joinButton");
const publishButton = document.getElementById("publishButton");
const incidentList = document.getElementById("incidentList");
let joinedRoom = "";
const connection = new signalR.HubConnectionBuilder().withUrl("/opsHub").configureLogging(signalR.LogLevel.Information).build();
connection.on("IncidentReceived", incident => {
    const item = document.createElement("li");
    item.className = `incident ${incident.severity}`;
    item.textContent = `${incident.severity.toUpperCase()} | ${incident.room} | ${incident.message} | ${incident.operatorName} at ${new Date(incident.sentAt).toLocaleTimeString()}`;
    incidentList.prepend(item);
});
async function start() { try { await connection.start(); setStatus("Connected. Join a room.", "connected"); joinButton.disabled = false; } catch (error) { console.error(error); setStatus("Connection failed. Refresh to retry.", "offline"); } }
joinForm.addEventListener("submit", event => { event.preventDefault(); joinedRoom = roomInput.value.trim().toLowerCase(); roomInput.disabled = true; joinButton.disabled = true; publishButton.disabled = false; setStatus(`Joined: ${joinedRoom}`, "connected"); });
incidentForm.addEventListener("submit", async event => { event.preventDefault(); try { await connection.invoke("PublishIncident", joinedRoom, operatorInput.value, messageInput.value, severityInput.value); messageInput.value = ""; } catch (error) { console.error(error); } });
function setStatus(message, state) { statusElement.textContent = message; statusElement.className = `status ${state}`; }
start();

How Does the Browser Protocol Work?

  • The HubConnectionBuilder creates a connection to the mapped /opsHub endpoint.
  • The connection.on handler registers IncidentReceived before the connection starts.
  • The connection.start call enables the join button after the server connection succeeds.
  • The connection.invoke call sends the incident fields to PublishIncident on the hub.
  • Save wwwroot/js/ops.js.

Before you publish, consider whether one server call causes both connected tabs to display the same incident.

  • Press Ctrl+F5 in Visual Studio.

The status changes to Connected. Join a room. after the hub connection succeeds. The join button becomes available.

  • Open the same local URL in a second Microsoft Edge tab.
  • Confirm that the second tab also shows Connected. Join a room..
  • Click Join room in the first tab.
  • Enter Database latency rising in the Incident update field.
  • Click Publish update.

Both tabs immediately show the same structured incident with a time beside the operator name. That time comes from the server-generated SentAt value.

You have the shared pipeline working. One browser action now produces the same incident in every connected tab.

Does Only One Tab Update?

  • Confirm that both tabs show Connected. Join a room. before publishing.
  • Confirm that connection.on("IncidentReceived", incident => { appears before start() in wwwroot/js/ops.js.
  • Check that Program.cs maps OpsHub to the same /opsHub path used by the browser.
  • Help me debug my multi-tab SignalR broadcast.

The reference below captures the complete project files at this checkpoint.

✔️ Awesome, I've got everything!

Your hub, endpoint, page, plus browser client now form one working broadcast pipeline.

ⓧ I'd like to double check the full code

Hubs/OpsHub.cs
using Microsoft.AspNetCore.SignalR;

namespace RealtimeOps.Hubs;

public interface IOpsClient
{
    Task IncidentReceived(IncidentUpdate incident);
}

public sealed record IncidentUpdate(string Room, string OperatorName, string Message, string Severity, DateTimeOffset SentAt);

public sealed class OpsHub : Hub<IOpsClient>
{
    public async Task PublishIncident(string room, string operatorName, string message, string severity)
    {
        if (string.IsNullOrWhiteSpace(room) || string.IsNullOrWhiteSpace(operatorName) || string.IsNullOrWhiteSpace(message))
        {
            throw new HubException("Room, operator, and message are required.");
        }
        var incident = new IncidentUpdate(room.Trim().ToLowerInvariant(), operatorName.Trim(), message.Trim(), severity.Trim().ToLowerInvariant(), DateTimeOffset.UtcNow);
        await Clients.All.IncidentReceived(incident);
    }
}

Hub File Check

This file defines the client contract, structured incident record, plus the all-client broadcast method.

Pages/Index.cshtml
@page
@{
    ViewData["Title"] = "Live Operations Room";
}

<section class="ops-shell">
    <div class="ops-header"><div><p>SignalR protocol lab</p><h1>Live Operations Room</h1><p>Publish structured incident updates to everyone in the same room.</p></div><span id="connectionStatus" class="status offline">Connecting</span></div>
    <form id="joinForm" class="panel form-grid"><label>Room <input id="roomInput" value="alpha" maxlength="30" required /></label><button id="joinButton" type="submit" disabled>Join room</button></form>
    <form id="incidentForm" class="panel form-grid"><label>Operator <input id="operatorInput" value="Operator 1" maxlength="40" required /></label><label>Severity <select id="severityInput"><option value="info">Info</option><option value="warning">Warning</option><option value="critical">Critical</option></select></label><label>Incident update <input id="messageInput" maxlength="160" required /></label><button id="publishButton" type="submit" disabled>Publish update</button></form>
    <div class="panel"><h2>Live feed</h2><ul id="incidentList"></ul></div>
</section>
<script src="~/js/signalr/dist/browser/signalr.min.js"></script>
<script src="~/js/ops.js"></script>

Page File Check

This page starts in a connecting state. It loads the SignalR browser bundle before the operations client.

Program.cs
using RealtimeOps.Hubs;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
builder.Services.AddSignalR();

var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapRazorPages();
app.MapHub<OpsHub>("/opsHub");
app.Run();

Program File Check

This file registers SignalR services. It exposes OpsHub through /opsHub.

RealtimeOps.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

</Project>

Project File Check

The project continues targeting the .NET 8 web SDK with nullable reference types enabled.

libman.json
{
  "version": "3.0",
  "defaultProvider": "unpkg",
  "libraries": [{"library": "@microsoft/signalr@8.0.7", "destination": "wwwroot/js/signalr/", "files": ["dist/browser/signalr.min.js"]}]
}

LibMan File Check

This manifest keeps the pinned SignalR JavaScript browser bundle in the path referenced by the page.

wwwroot/js/ops.js
"use strict";

const statusElement = document.getElementById("connectionStatus");
const joinForm = document.getElementById("joinForm");
const incidentForm = document.getElementById("incidentForm");
const roomInput = document.getElementById("roomInput");
const operatorInput = document.getElementById("operatorInput");
const severityInput = document.getElementById("severityInput");
const messageInput = document.getElementById("messageInput");
const joinButton = document.getElementById("joinButton");
const publishButton = document.getElementById("publishButton");
const incidentList = document.getElementById("incidentList");
let joinedRoom = "";
const connection = new signalR.HubConnectionBuilder().withUrl("/opsHub").configureLogging(signalR.LogLevel.Information).build();
connection.on("IncidentReceived", incident => {
    const item = document.createElement("li");
    item.className = `incident ${incident.severity}`;
    item.textContent = `${incident.severity.toUpperCase()} | ${incident.room} | ${incident.message} | ${incident.operatorName} at ${new Date(incident.sentAt).toLocaleTimeString()}`;
    incidentList.prepend(item);
});
async function start() { try { await connection.start(); setStatus("Connected. Join a room.", "connected"); joinButton.disabled = false; } catch (error) { console.error(error); setStatus("Connection failed. Refresh to retry.", "offline"); } }
joinForm.addEventListener("submit", event => { event.preventDefault(); joinedRoom = roomInput.value.trim().toLowerCase(); roomInput.disabled = true; joinButton.disabled = true; publishButton.disabled = false; setStatus(`Joined: ${joinedRoom}`, "connected"); });
incidentForm.addEventListener("submit", async event => { event.preventDefault(); try { await connection.invoke("PublishIncident", joinedRoom, operatorInput.value, messageInput.value, severityInput.value); messageInput.value = ""; } catch (error) { console.error(error); } });
function setStatus(message, state) { statusElement.textContent = message; statusElement.className = `status ${state}`; }
start();

Browser Client Check

This file connects to the hub, receives structured incidents, plus invokes the server publishing operation.

Every connected tab now receives the same broadcast. Next, you will turn that global broadcast into a room protocol so unrelated operations stay separated.

Route Incidents to SignalR Rooms

Your ASP.NET Core SignalR hub now broadcasts each structured incident to every connected browser. That shared pipeline also mixes updates from unrelated rooms.

A room-based application protocol gives each incident a routing boundary. SignalR groups let the server deliver it only to browsers that joined the same room.

In this step, get ready to:
  • Add each connection to a normalized SignalR room.
  • Route validated incidents only to the matching group.
  • Update the browser to join a room before publishing.
Add room membership to the hub

A SignalR group is a server-managed collection of active connections. The hub adds the current connection to the group named by the selected room.

  • Return to Visual Studio.
  • Stop the running app from the top toolbar.
  • Expand the Hubs folder in Solution Explorer.
  • Select OpsHub.cs.
  • Find the public async Task PublishIncident(string room, string operatorName, string message, string severity) method.
  • Add the following method immediately above PublishIncident:
    public async Task JoinRoom(string room)
    {
        if (string.IsNullOrWhiteSpace(room)) throw new HubException("Room is required.");
        await Groups.AddToGroupAsync(Context.ConnectionId, Normalize(room));
    }

How Does Room Membership Work?

  • The public JoinRoom method becomes an operation that the browser can invoke.
  • The validation rejects an empty room before creating membership.
  • Groups.AddToGroupAsync associates Context.ConnectionId with the normalized room name.
  • Save Hubs/OpsHub.cs.
  • Run the updated app by pressing Ctrl+F5.

Microsoft Edge opens the board without a build error. The status changes to Connected. Join a room..

Seeing a Build Error?

  • Confirm that JoinRoom sits inside the OpsHub class.
  • Confirm that every opening brace in the new method has a matching closing brace.
  • Ask for help with the method structure: help me check why my JoinRoom method does not build
Route each incident to one group

Room membership creates the destination. The publish operation now needs to validate the message before targeting that destination.

  • Return to Visual Studio.
  • Stop the running app from the top toolbar.
  • Select the existing PublishIncident method in Hubs/OpsHub.cs.
  • Replace the selected method with this room-routing version:
    public async Task PublishIncident(string room, string operatorName, string message, string severity)
    {
        if (string.IsNullOrWhiteSpace(room) || string.IsNullOrWhiteSpace(operatorName) || string.IsNullOrWhiteSpace(message)) throw new HubException("Room, operator, and message are required.");
        var normalizedSeverity = severity.Trim().ToLowerInvariant();
        if (normalizedSeverity is not ("info" or "warning" or "critical")) throw new HubException("Severity must be info, warning, or critical.");
        var incident = new IncidentUpdate(Normalize(room), operatorName.Trim(), message.Trim(), normalizedSeverity, DateTimeOffset.UtcNow);
        await Clients.Group(incident.Room).IncidentReceived(incident);
    }

    private static string Normalize(string room) => room.Trim().ToLowerInvariant();

What Changes in the Hub?

  • The validation requires a room plus the operator and message values.
  • The normalizedSeverity check limits incidents to the three values supported by the board.
  • Normalize gives room membership and incident routing the same lowercase room name.
  • Clients.Group(incident.Room) sends IncidentReceived only to connections in that room.
  • Save Hubs/OpsHub.cs.

Before you test this version, do you think a browser that has not told the server its room can receive a group update?

  • Run the app by pressing Ctrl+F5.
  • Click Join room in the browser.
  • Enter Cooling check in the Incident update field.
  • Click Publish update.

You see no new item in the feed. This is the intended integration gap.

The server now targets a group. The browser has not invoked JoinRoom yet.

Does the Incident Still Appear?

  • Confirm that the final line of PublishIncident uses Clients.Group(incident.Room).
  • Stop the previous app process before running the updated code again.
  • Ask for help comparing the routing line: help me find why OpsHub still broadcasts to every tab

✔️ Awesome, I've got everything!

Your hub now records room membership and targets each incident to its normalized room.

ⓧ I'd like to double check the full code

Compare your Hubs/OpsHub.cs file with this complete room-routing version.

using Microsoft.AspNetCore.SignalR;

namespace RealtimeOps.Hubs;

public interface IOpsClient
{
    Task IncidentReceived(IncidentUpdate incident);
}

public sealed record IncidentUpdate(string Room, string OperatorName, string Message, string Severity, DateTimeOffset SentAt);

public sealed class OpsHub : Hub<IOpsClient>
{
    public async Task JoinRoom(string room)
    {
        if (string.IsNullOrWhiteSpace(room)) throw new HubException("Room is required.");
        await Groups.AddToGroupAsync(Context.ConnectionId, Normalize(room));
    }

    public async Task PublishIncident(string room, string operatorName, string message, string severity)
    {
        if (string.IsNullOrWhiteSpace(room) || string.IsNullOrWhiteSpace(operatorName) || string.IsNullOrWhiteSpace(message)) throw new HubException("Room, operator, and message are required.");
        var normalizedSeverity = severity.Trim().ToLowerInvariant();
        if (normalizedSeverity is not ("info" or "warning" or "critical")) throw new HubException("Severity must be info, warning, or critical.");
        var incident = new IncidentUpdate(Normalize(room), operatorName.Trim(), message.Trim(), normalizedSeverity, DateTimeOffset.UtcNow);
        await Clients.Group(incident.Room).IncidentReceived(incident);
    }

    private static string Normalize(string room) => room.Trim().ToLowerInvariant();
}

Hub Checkpoint

This complete file contains the room join operation, message validation, shared normalization rule, and group-targeted client call.

Complete the browser protocol

The browser's room button currently changes local state only. It must invoke the server's JoinRoom operation before enabling incident publishing.

  • Return to Visual Studio.
  • Stop the running app from the top toolbar.
  • Expand wwwroot in Solution Explorer.
  • Expand the js folder.
  • Select ops.js.
  • Replace the full contents of wwwroot/js/ops.js with this browser protocol:
"use strict";
const statusElement=document.getElementById("connectionStatus"),joinForm=document.getElementById("joinForm"),incidentForm=document.getElementById("incidentForm"),roomInput=document.getElementById("roomInput"),operatorInput=document.getElementById("operatorInput"),severityInput=document.getElementById("severityInput"),messageInput=document.getElementById("messageInput"),joinButton=document.getElementById("joinButton"),publishButton=document.getElementById("publishButton"),incidentList=document.getElementById("incidentList");
let joinedRoom="";
const connection=new signalR.HubConnectionBuilder().withUrl("/opsHub").configureLogging(signalR.LogLevel.Information).build();
connection.on("IncidentReceived",incident=>{const item=document.createElement("li");item.className=`incident ${incident.severity}`;const heading=document.createElement("strong");heading.textContent=`${incident.severity.toUpperCase()} | ${incident.room}`;const message=document.createElement("p");message.textContent=incident.message;const details=document.createElement("small");details.textContent=`${incident.operatorName} at ${new Date(incident.sentAt).toLocaleTimeString()}`;item.append(heading,message,details);incidentList.prepend(item);});
async function start(){try{await connection.start();setStatus("Connected. Join a room.","connected");joinButton.disabled=false;}catch(error){console.error(error);setStatus("Connection failed. Refresh to retry.","offline");}}
joinForm.addEventListener("submit",async event=>{event.preventDefault();const room=roomInput.value.trim().toLowerCase();if(!room)return;try{await connection.invoke("JoinRoom",room);joinedRoom=room;roomInput.disabled=true;joinButton.disabled=true;publishButton.disabled=false;setStatus(`Joined: ${joinedRoom}`,"connected");}catch(error){console.error(error);setStatus("Could not join the room.","warning");}});
incidentForm.addEventListener("submit",async event=>{event.preventDefault();if(!joinedRoom)return;try{await connection.invoke("PublishIncident",joinedRoom,operatorInput.value,messageInput.value,severityInput.value);messageInput.value="";messageInput.focus();}catch(error){console.error(error);setStatus("The update could not be published.","warning");}});
function setStatus(message,state){statusElement.textContent=message;statusElement.className=`status ${state}`;}
start();

How Does the Browser Protocol Work?

  • The IncidentReceived handler renders only messages pushed back by the hub.
  • The join handler awaits JoinRoom before saving joinedRoom and enabling publishing.
  • The publish handler sends joinedRoom with the current operator, message, and severity values.
  • The DOM nodes use textContent to display each structured incident as text.
  • Save wwwroot/js/ops.js.

Before you run the finished protocol, which tabs do you expect to receive an alpha incident?

  • Run the app by pressing Ctrl+F5.
  • Open the same URL in two additional Microsoft Edge tabs.
  • Click Join room in the first tab with alpha selected.
  • Click Join room in the second tab with alpha selected.
  • Replace alpha with beta in the third tab.
  • Click Join room in the third tab.
  • Enter Cooling check in the first tab's Incident update field.
  • Click Publish update in the first tab.

You see the incident in both alpha tabs. The beta tab stays empty.

  • Enter Database check in the beta tab's Incident update field.
  • Click Publish update in the beta tab.

You see the second incident only in the beta tab. The two alpha feeds remain unchanged.

Are the Rooms Mixing Updates?

  • Confirm that each tab shows either Joined: alpha or Joined: beta before publishing.
  • Confirm that OpsHub.cs uses Clients.Group(incident.Room) instead of Clients.All.
  • Ask for help tracing the room value: help me debug why my SignalR rooms are mixing updates

That's the routing boundary working: your operations room now keeps unrelated incident feeds separate.

✔️ Awesome, I've got everything!

Your browser now joins a server-managed room before it can publish an incident.

ⓧ I'd like to double check the full code

Compare your wwwroot/js/ops.js file with this complete room-aware browser protocol.

"use strict";
const statusElement=document.getElementById("connectionStatus"),joinForm=document.getElementById("joinForm"),incidentForm=document.getElementById("incidentForm"),roomInput=document.getElementById("roomInput"),operatorInput=document.getElementById("operatorInput"),severityInput=document.getElementById("severityInput"),messageInput=document.getElementById("messageInput"),joinButton=document.getElementById("joinButton"),publishButton=document.getElementById("publishButton"),incidentList=document.getElementById("incidentList");
let joinedRoom="";
const connection=new signalR.HubConnectionBuilder().withUrl("/opsHub").configureLogging(signalR.LogLevel.Information).build();
connection.on("IncidentReceived",incident=>{const item=document.createElement("li");item.className=`incident ${incident.severity}`;const heading=document.createElement("strong");heading.textContent=`${incident.severity.toUpperCase()} | ${incident.room}`;const message=document.createElement("p");message.textContent=incident.message;const details=document.createElement("small");details.textContent=`${incident.operatorName} at ${new Date(incident.sentAt).toLocaleTimeString()}`;item.append(heading,message,details);incidentList.prepend(item);});
async function start(){try{await connection.start();setStatus("Connected. Join a room.","connected");joinButton.disabled=false;}catch(error){console.error(error);setStatus("Connection failed. Refresh to retry.","offline");}}
joinForm.addEventListener("submit",async event=>{event.preventDefault();const room=roomInput.value.trim().toLowerCase();if(!room)return;try{await connection.invoke("JoinRoom",room);joinedRoom=room;roomInput.disabled=true;joinButton.disabled=true;publishButton.disabled=false;setStatus(`Joined: ${joinedRoom}`,"connected");}catch(error){console.error(error);setStatus("Could not join the room.","warning");}});
incidentForm.addEventListener("submit",async event=>{event.preventDefault();if(!joinedRoom)return;try{await connection.invoke("PublishIncident",joinedRoom,operatorInput.value,messageInput.value,severityInput.value);messageInput.value="";messageInput.focus();}catch(error){console.error(error);setStatus("The update could not be published.","warning");}});
function setStatus(message,state){statusElement.textContent=message;statusElement.className=`status ${state}`;}
start();

Browser Checkpoint

This complete file joins the selected room, locks the room field, publishes through the joined room, and renders only server-pushed incidents.

Secret mission

Recover and Rejoin After a Disconnect

A dropped server connection can leave an incident board showing stale information. Add automatic reconnect behavior that reports each connection state and restores the selected room when the server returns.

Clean Up Your Resources

Clean Up Your Resources

The RealtimeOps project runs entirely on your computer. There are no cloud resources or ongoing costs.

Choose a cleanup option below for the local app.

Resources you used:

  • Your locally running ASP.NET Core SignalR development server for RealtimeOps.
  • Your RealtimeOps project folder.

Keep everything running

No action is needed. Choose this if you want to keep testing room broadcasts or reconnect behavior.

  • Keep the local server running while you continue testing the Live Operations Room.
  • Keep the RealtimeOps folder so every project file remains available for later changes.

Pause - I'll come back to this later

Stop the running process to free up memory while keeping every project file. You can continue from the same code later.

  • Close each browser tab displaying the Live Operations Room.
  • Close the console window hosting the RealtimeOps app.
  • Keep the RealtimeOps folder in the location where you saved it.
  • Return to the RealtimeOps solution in Visual Studio when you are ready.
  • Start the app again by pressing Ctrl+F5.

Delete - I don't want to use this again

Remove all local project files when you want a clean slate. This also removes the restored SignalR browser client.

Deleting a working project can feel drastic. A copy of the folder preserves the complete app.

  • Close each browser tab displaying the Live Operations Room.
  • Close the console window hosting the RealtimeOps app.
  • Use File Explorer to locate the parent folder where you saved RealtimeOps.
  • Create a backup by copying RealtimeOps to a separate location if you may want the code later.
  • Delete the original RealtimeOps folder through File Explorer.
  • Remove RealtimeOps from your computer's deleted-items area to make the deletion permanent.

The RealtimeOps folder should no longer appear in its original location. No local server window should remain open.

Nice Work!

Nice Work!

You did it! Your Live Operations Room now synchronizes structured incident updates across browser tabs through ASP.NET Core SignalR.

You've learned how to:

  • Demonstrated the gap between local browser state and server-mediated updates by watching one tab fall behind before the hub connected every client.
  • Defined an application-level protocol with JoinRoom, PublishIncident, and IncidentReceived. Used a strongly typed client contract plus the default JSON Hub Protocol to move structured C# records into JavaScript.
  • Used SignalR groups to route each incident only to browser tabs in the selected room.
  • Secret Mission: Added automatic reconnect behavior that reports connection changes. Restored the selected room membership before publishing resumes.

Ready to quiz yourself?