NetScan Pro - a WPF + Blazor Hybrid LAN scanner built on Radzen.Blazor

NetScan Pro — an unmetered Windows LAN scanner built with Blazor Hybrid + Radzen.Blazor

Hi all,

I wanted to share a small desktop tool I built and open-sourced: NetScan Pro, a Windows LAN scanner and monitor. No cloud round-trip, no daily scan quota, no account — everything runs and stays on your machine, and Radzen.Blazor components do all the heavy lifting on the UI side.

Repo: https://github.com/benjaminsqlserver/NetScanPro

What it does

NetScan Pro discovers every device on your local network and keeps watching it. Instead of relying on a single ping sweep (which misses anything with ICMP blocked), it runs three discovery layers on every pass:

  • Parallel ICMP echo — catches most hosts, fast

  • TCP connect probes on common ports (445 / 135 / 80 / 443 / 22 / 3389) — catches hosts that block ping but still have a service listening

  • A read of the Windows ARP cache (GetIpNetTable) — catches anything still on the wire that answered neither of the above

The ARP read happens after the sweep on purpose — the ping attempts themselves force ARP resolution, so the cache is warm by the time it's read.

Turn on continuous mode and it rescans on an interval indefinitely, logging every device join/drop as an event. Turn it off for a single one-shot pass. Either way, you can export the full table (online and offline hosts) to CSV.

Tech stack

  • .NET 10, WPF host + Blazor Hybrid (BlazorWebView) for the UI

  • Radzen.Blazor 7.x for every interactive control in the app

  • P/Invoke against iphlpapi for ARP table reads

  • No admin rights required — ICMP, ARP, and TCP connect all work as a standard user

  • Single project, single solution — no backend, no database, nothing to deploy

How Radzen Blazor is used

The entire UI — settings sidebar, live grid, and controls — is one Scanner.razor component, and it leans on Radzen almost exclusively rather than mixing in raw HTML form controls:

  • RadzenDropDown to pick the network adapter, populated from the machine's live adapters, with the one that has a default gateway pre-selected

  • RadzenTextBox for the scan range (from/to), and RadzenNumeric for ping timeout, parallel probe count, and rescan interval — all with Min/Max/Step bounds so you can't type in a nonsense value

  • RadzenCheckBox + RadzenLabel pairs for the scan-depth toggles (continuous mode, hostname resolution, TCP fallback, port probing)

  • RadzenButton with icons for Start/Stop, switching label and style depending on whether continuous mode is on

  • RadzenProgressBar to show live scan progress on each pass

  • RadzenDataGrid, the centerpiece — a strongly-typed TItem="DeviceInfo" grid with sortable columns for name, MAC, vendor, latency, discovery method, open ports, and first/last-seen timestamps (with FormatString for the dates)

  • RadzenBadge inline inside the grid's name column, tagging the router as "gateway" and the host machine as "this PC" so they stand out at a glance

  • RadzenStack for laying out the toolbar responsively (horizontal, wrapping, centered)

  • NotificationService (injected) drives toast notifications for scan start/stop/errors

Because Radzen ships its own Material/Fluent/Standard themes as plain CSS files loaded in wwwroot/index.html, swapping the whole app from dark to light theme — or to a completely different look — is a one-line change with zero component code to touch.

Try it / build it

powershell

dotnet restore NetScanPro.sln
dotnet run --project NetScanPro

Or open the .sln in Visual Studio 2026 (17.14+, for .NET 10 support) and hit F5. There's also a single-file publish command in the README for shipping it as one .exe.

Repo again: https://github.com/benjaminsqlserver/NetScanPro

Happy to answer questions about the ARP/TCP discovery approach or the Radzen setup — and feedback on the grid/theme choices is very welcome.

— Benjamin

Nice project. I didn’t fully understand one part though. You mention that the ARP cache is read after the sweep because the ping attempts warm the cache. Does that mean the ARP cache can also show devices that do not reply to ICMP or have all the tested TCP ports closed, as long as they responded to ARP resolution?

Yes ; that's exactly right, and it's the main reason the ARP layer is in there at all.

ARP sits below ICMP and TCP. A host can run a firewall that silently drops echo requests and refuses every port I probe, but it cannot refuse to answer ARP without making itself unreachable on the LAN ; the ARP reply is what tells my machine which MAC address to put on the frame. So the ordering in ScanOnceAsync is deliberate:

  1. The parallel ICMP sweep goes out first. For every target, the Windows stack has to resolve the MAC before it can put the echo request on the wire, so an ARP request is sent whether or not the ping itself is ever answered.
  2. Anything that answered that ARP request now sits in the local ARP cache.
  3. ArpTable.Read() then pulls the whole table in a single GetIpNetTable call, and every entry falling inside the configured host range gets alive.TryAdd(key, "ARP").

TryAdd is the detail that matters: it never overwrites a discovery method already recorded by ICMP or the TCP fallback, so anything showing "ARP" in the Discovered By column is precisely a host that both of the other two layers missed. Hardened Windows boxes, network printers and a lot of IoT gear land in that bucket.

Three caveats, because the layer isn't magic:

  • Same broadcast domain only. ARP does not cross a router ; anything off-subnet resolves to the gateway's MAC instead, so this layer only adds value while you're scanning your own subnet. That's what the app is built for, but worth knowing if you widen the range.
  • It's a cache, not a live probe. Windows keeps entries for a while after a device goes quiet, so an ARP-only host means "was on this wire very recently" rather than "is answering right now". In continuous mode you can occasionally see a device linger for one pass after it has actually left.
  • No round-trip time. Those rows have RoundTripMs set to null and the latency column stays blank, since nothing replied to a ping and there is nothing to measure. If reverse DNS also comes back empty, DisplayName falls back to the OUI vendor name.

Entries that never resolved are filtered on the way in ; dwType == 2 (invalid) is skipped, as are the all-zero and broadcast MACs ; so a failed resolution against a dead IP doesn't turn into a phantom device in the grid.

One last thing worth mentioning: a device can already be in the cache before a scan even runs, purely from normal traffic on your machine. The sweep doesn't create the mechanism, it just makes the coverage even across the whole range instead of only the addresses your PC happened to talk to.