Skip to content

Latest commit

 

History

41 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PingBoard

An always-on ping monitor for Windows 11. Watches a list of hosts, shows what is up and what is down, and keeps enough history to tell you how something is failing rather than just that it is.

ICMP, TCP and HTTP(S) probes. Per-host history and latency graphs, jitter, and rolling 24h/7d/30d availability. A traceroute captured automatically at the moment something goes down, so the evidence exists before you go looking. Hosts grouped into tabs that can be muted or disabled independently, maintenance windows that suppress expected outages, and webhook or email alerts for the ones that aren't. Lives in the tray and can start with Windows.

The board

A live board: healthy hosts, one target past its latency threshold, one that is not answering, and certificate expiry for the HTTPS targets. Every figure is real — this is eight public hosts probed for a few minutes, not a mock-up.

Expanding a row opens its latency graph, and the failure trace if there is one:

The latency graph

There is also a terminal theme, for those who want one:

The Matrix theme


Install

Download the installer from the latest release.

It installs per user into %LocalAppData%\Programs, so there is no UAC prompt to install or to update, and it carries its own runtime — there is no .NET to install first. From then on the app checks GitHub for new versions on startup and can update itself in place.

The installer is unsigned, so SmartScreen will warn on first run: More infoRun anyway. That is a property of the certificate, not of the packaging; no installer format avoids it.

Your targets, history and settings live in %AppData%\PingBoard and survive updates and uninstalls.


Build and run

Built on WinUI 3 (Windows App SDK 2.3.1) and .NET 10, deployed unpackaged and self-contained.

Requires the .NET 10 SDK (winget install Microsoft.DotNet.SDK.10). No Visual Studio needed.

dotnet run --project src/PingBoard.App

Release build you can copy anywhere:

dotnet publish src/PingBoard.App -c Release -r win-x64 -o dist

Point it at a specific board:

dist/PingBoard.App.exe --config C:\path\to\board.ini

--config also keys the single-instance guard, so two different boards can run side by side while relaunching the same one just surfaces the window already open. --minimized starts in the tray with no window, which is what the autostart entry uses.

Building the installer

dist/ runs as-is if you just copy it. For something to hand to someone else, there is an Inno Setup script:

dotnet publish src/PingBoard.App -c Release -r win-x64 -o dist
"%LOCALAPPDATA%\Programs\Inno Setup 6\ISCC.exe" installer\PingBoard.iss

The installer's optional "start when I sign in" checkbox writes the same HKCU value the in-app toggle does — one setting, not two that can disagree. Re-running it over an existing install detects the installed version and skips the questions already answered, rather than presenting a routine update as a fresh install.

The headless engine

All the networking lives in PingBoard.Core, which references no UI type at all. That is enforced structurally — it is a separate project — so the part that has to be correct can be driven and stress-tested without XAML in the way.

dotnet run --project src/PingBoard.Harness -- --selftest        # 240 assertions
dotnet run --project src/PingBoard.Harness -- board.ini --seconds 300

The harness prints the same columns as the UI, plus a forced-GC memory line every 60s.


Configuration

A plain .ini you can hand-edit and copy between machines.

[Settings]
IntervalMs=2000
TimeoutMs=2000
RollingWindow=300          ; probes kept per target for Loss %, avg/min/max and History
FailuresBeforeDown=3       ; consecutive failures before alerting
MaxConcurrent=32
PreferIPv4=true
DnsCacheSeconds=300
LogEnabled=true
LogPath=pingboard-events.csv

[Target:gateway]
Address=10.1.10.1

[Target:homeassistant]
Address=10.1.10.12
Probe=tcp
Port=8123
IntervalMs=5000            ; any [Settings] key can be overridden per target

[Target:old-nas]
Address=nas.local
Enabled=false              ; paused

Well-known hosts

⚙ → Add well-known hosts… populates a board in one click. Each group lands in its own tab:

Local network — this machine's own gateway and resolvers, discovered rather than guessed. The most useful group by far, because the first question during an outage is whether the fault is local or everything past it. Windows' placeholder fec0::/10 IPv6 resolvers are filtered out; importing them would mean three permanently red rows on a healthy machine.

Public DNS · Large websites · Social media · Cloud platforms · Developer services · Streaming and gaming · Email providers.

Resolvers are probed with ICMP — they answer it, and it is the lightest check. Websites are probed over HTTPS, because a ping to a name like google.com lands on whichever anycast edge is nearest: it proves the internet works and says nothing about the service, and many of them drop ICMP outright.

The lists are deliberately short. A category with forty entries is not a starting point, it is a chore to prune — and every target is real traffic on every interval, forever. Hosts already on the board are skipped.

Tabs

Targets can be grouped into tabs. Membership lives on the target; the section carries the tab's own state, so renaming a tab does not mean editing every member.

[Tab:LAN]
Enabled=true
Order=0

[Tab:WAN]
Enabled=false              ; every target in this tab stops being probed
Order=1

[Target:gateway]
Address=10.1.10.1
Tab=LAN

A tab is a view, not a scheduler. Targets are probed regardless of which tab is on screen — the tabs you are not watching are exactly where an outage goes unnoticed. Enabled=false is the separate, explicit way to stop probing a group, and it reuses Paused, which is already excluded from the rolling loss figures. Disabling a tab overnight therefore does not corrupt its statistics the way counting the silence would.

Each tab shows a live tally (WAN · 2 down) so a problem cannot hide behind an unselected tab, and the strip is hidden entirely while there is only one group. A board that never used tabs round-trips unchanged.

Right-click a tab to mute or disable it, and note that these are different things. Muting (Muted=true) silences that group's alerts while it carries on being probed — for hosts you want on the board but not in your face. Disabling stops the probes and loses the record of what those hosts did. A muted tab carries a permanent mark, because a silenced group you have forgotten about is worse than one that never alerted: you are still trusting it. Unmuting a host that is still down alerts then, exactly as leaving a maintenance window does.

Muting a tab suppresses webhook and email too, unlike the global mute button which is deliberately desktop-only. Muting a tab is a statement about those hosts; muting the app is a statement about this machine, and something meant to reach you elsewhere should survive the latter.

The same right-click menu can rename or delete a tab. Renaming repoints every member target and renames the section in place, keeping its position in the strip. Deleting moves its targets to General rather than taking them with it — a tab is a grouping, and losing the grouping must never be a surprise way to stop monitoring something. General itself refuses both: it is where an ungrouped target lands by definition, so renaming or deleting it would not make it go away, only leave an empty one to reappear the next time something needs the default.

Column choices (⚙ → Columns) are per tab. A tab of ICMP hosts has no use for Cert days; a tab of public HTTPS sites wants it in view. The first time you hide or show a column on a given tab it starts keeping its own set, independent of every other tab; until then it shares the one set every board already had. Column order, zoom and auto-fit stay a single setting across the whole board — those are about screen space, not about what a particular group of hosts needs shown.

Counters and probe history live in a sidecar (board.state.ini), so the file you edit stays free of churning numbers. Deleting the sidecar resets all statistics; there is a menu item for the same thing.

History is persisted as compact status:rtt pairs, so the sparkline and the latency graph come back populated after a restart rather than spending the next five minutes filling in. Only status and round-trip time are kept, because only those are read back — the timestamps are dropped deliberately, since a monotonic tick from a process that has exited means nothing and both charts plot by index anyway.

Probe=tcp exists because plenty of hosts and most corporate firewalls drop ICMP silently, which would otherwise read as a permanently dead target. A completed TCP handshake also proves more than an echo reply does, and a refused connection is reported separately from a timeout — it means the host is up and the port is closed.

Sites

A physical location — Connaught, Northcliffe — kept deliberately separate from a tab. A tab is a functional grouping chosen for the board; a site is a place. A tab can span several sites and a site can span several tabs, so the two are orthogonal rather than one being a kind of the other.

[Site:Connaught]
Abbreviation=Conn

[Target:core-switch]
Address=10.1.10.1
Site=Connaught

Set from the target dialog: pick an existing site from the dropdown and its abbreviation fills in from the registry; type a new one and it starts blank. The abbreviation lives once per site, not once per target — editing it on any one target at a site updates every other target there immediately, on disk and on the board, rather than drifting into Conn on one and Connaught on another depending on who typed what.

Two columns carry it, Site and Site Abbreviation, hidden by default like the certificate columns. Both read for a target with no site — never blank-as-in-empty-string, and never no: a target without one is not a target whose site is known to be nothing, and answering a question that was never asked is how a column ends up read as more certain than it is.

Unlike a tab, a target with no site is a normal, common state rather than falling back to a default group — most targets are not tied to a physical location worth naming, and there is nothing for them to fall back to.

HTTP probes

Probe=http and Probe=https go one layer further and judge the status code:

[Target:intranet]
Address=intranet.example.com
Probe=https
Path=/health
ExpectStatus=200          ; optional; blank accepts any 2xx or 3xx

This is the difference between the socket opens and the service works. A TCP connect to 443 succeeds against a wedged application server that returns 500 to every request, and the board would show it green indefinitely — which is precisely the failure a monitor exists to catch. A bad status reports as HTTP ERR, kept distinct from a timeout because everything below the application layer worked and calling it a network fault sends you to look in the wrong place.

Ports default to 80 and 443 by scheme. Redirects are not followed — a 301 is a real answer about this URL, and chasing it would silently measure a different endpoint than the one configured. The response body is never read, so a target serving a large file costs nothing. The configured hostname is used for the request even though the address is resolved separately, because connecting by IP alone sends no SNI and the wrong Host header, and a virtual host would answer for the wrong site.

Maintenance windows

Scheduled quiet hours, per target:

[Target:nas]
Address=10.1.10.5
Maintenance=Sat 22:00-02:00, Sun 03:00-05:00

Days are optional (02:00-04:00 means every day), ranges work (Mon-Fri), and a window whose end precedes its start runs through midnight.

Probing carries on; only the alert is held back. A maintenance window says "I already know about this", not "stop watching" — the board still shows the outage and the history still records it, so afterwards you can see whether the host came back when it was supposed to.

The part that matters: a host still down when the window closes alerts then. If the window simply swallowed the transition, a host that died during maintenance and never returned would be marked as already-announced and you would never be told. Quiet while you expected the outage; loud the moment you did not.

Availability

24h %, 7d % and 30d % columns, from hourly buckets kept for thirty days. The rolling window is the last few hundred probes — right for "is something wrong now", useless for "was this link reliable this week" — and lifetime Uptime is the opposite failure, dragged down forever by one outage three days ago. These fill the gap.

A period with no data reads rather than 100%. A target added this morning genuinely has no thirty-day figure, and showing a perfect one would be a lie of exactly the kind that ends up in reports. Paused, suspended and maintenance samples are excluded, so a laptop that sleeps nightly does not accumulate fake downtime.

The outage log

⚙ → Outage log…: every drop, when it started, how long it lasted and what caused it, newest first, surviving restarts.

This is the question the availability columns provoke and could not answer. 99.2% over seven days invites exactly one follow-up — which outages made up the 0.8% — and until now nothing could say. Transitions were recorded in two places, both of which lost them: a CSV nobody opens, and an in-memory journal that surfaced once as a line of banner text and was discarded at the next launch.

Stored beside the board's config — config.iniconfig.outages.csv, where the counters and history sidecars already live, so two boards running side by side keep separate records. Appended a line at a time so a power cut costs the last transition rather than the file, and compacted when it outgrows the journal. A down and the recovery that closed it are stored as two rows and read as one event, because an outage that has not ended yet cannot be stored as a pair.

An outage still in progress is never discarded. The journal is a fixed ring like every other buffer here, and a 96-minute soak showed why that is not sufficient on its own: one flapping target produced 1,900 transitions, swept the ring clean, and took with it the "went down" of a host that was still down. The log ended up showing nothing but the flapper — having dropped the one outage nobody had fixed yet, which is exactly when you would open it. Open outages are now held outside the ring, in memory and in the file, until the recovery that closes them arrives. Closed outages still age out normally: they are history, and history is what a bounded buffer is allowed to forget.

Kept separate from the events CSV on purpose. That one is written for a person — it rotates, it prints UNREACHABLE rather than an enum, and its path is configurable so it can live on a share and be opened in a spreadsheet next year. Parsing it back would make a human-facing format load-bearing for program behaviour, which is the reliable way to guarantee that improving the wording breaks the feature.

Degraded

Status used to be binary: up or down. A link sitting at 900 ms with 30% loss was green — and real networks degrade well before they fail, which is the part worth catching early.

Set DegradedLatencyMs or DegradedLossPercent and a target that is still replying, but past either, reads DEGRADED instead of OK. It is an up state everywhere it matters: counters, availability figures and recovery from a real outage all behave exactly as before. Only the display and the log differ.

Assessed over the last 20 probes rather than the full rolling window, which is ten minutes and far too slow for something you watch, and never on fewer than five samples — one slow reply after a restart is not evidence.

Both default to off, and should. Any global default is a guess about someone else's network: 60 ms is a broken LAN and an excellent path to Frankfurt, and plenty of routers deprioritise ICMP while forwarding traffic perfectly. Set them per target, where the number means something:

[Target:office-wan]
Address=203.0.113.9
DegradedLatencyMs=120
DegradedLossPercent=2

Notifications for it are opt-in separately, and off by default — a slow link is something to notice, not to be woken for.

Certificate expiry

HTTPS targets have their TLS certificate read every few hours, on its own connection rather than on every probe: a certificate changes a handful of times a year, and re-reading it every two seconds to watch a date not move is pure cost. Crossing CertWarnDays raises one alert.

Two columns carry it, alongside the target's tooltip and the board export:

Column Shows
Expiring yes inside the warning window or already past it, no otherwise
Cert days whole days until expiry, negative once past

Both read for a target with no certificate — an ICMP host, or one whose read failed. A target without a certificate is not a target whose certificate is fine, and printing no there would read as a clean bill of health for something that was never checked.

They are hidden on a fresh install, since they say nothing for a board of pings, and appear automatically on an existing board — the saved column state names what was hidden when it was written, and cannot mention a column that did not exist yet. ⚙ → Columns toggles them either way.

The read deliberately records the trust verdict rather than acting on it, which is what lets it report an expired or self-signed certificate at all — validating normally would abort the handshake and yield "TLS failed", telling you the one thing you already suspected and none of the detail you need.

Export

⚙ → Export…, in three shapes, because they answer different questions and merging them would give a spreadsheet that cannot usefully be sorted by anything:

Board one row per target — status, latency, jitter, loss, 24h/7d/30d availability, certificate
Outages one row per outage, with start, end and duration
History one row per retained probe sample

The point is usually not analysis, it is evidence: an ISP or a supplier disputing that anything was ever wrong, answered with a file listing every drop and its duration rather than a screenshot of a board showing the present moment.

Alerting

A tray balloon only reaches you while you are sitting in front of the machine — which is exactly when you would have noticed the board turn red anyway. An optional [Alerts] section sends transitions somewhere that reaches you when you are not.

[Alerts]
WebhookEnabled=true
WebhookUrl=https://hooks.example.com/abc      ; POSTs JSON; ntfy, Home Assistant, Discord, Slack
MinIntervalSeconds=60                          ; suppress repeat alerts per target; 0 disables
NotifyOnRecovery=true

EmailEnabled=false
SmtpHost=smtp.example.com
SmtpPort=587
SmtpUser=me@example.com
SmtpPassword=                                  ; DPAPI-encrypted on the next save
EmailFrom=me@example.com
EmailTo=me@example.com

Delivery happens on a background worker behind a bounded queue that drops oldest. That is the whole point: an unreachable SMTP server blocks for its full TCP timeout, and sending inline from the probe path would mean an outage on the alerting side degrading the thing raising the alerts.

SmtpPassword and WebhookAuthorization are encrypted with DPAPI under the current Windows user, so a config that ends up in a sync folder, a backup or a repo carries no usable credential. The flip side is deliberate: copy the file to another machine and the secret must be re-entered there. A password typed in as plaintext by hand still works, and is encrypted the next time the board saves.

A sink that fails is reported in the status bar. An alerting path that breaks quietly is the worst state this app can be in — the board looks healthy and you believe you will be told when it is not.


What the columns mean

Status · IP · Hostname · Last OK · Last NOK · OK/NOK are shown by default, along with RTT, Loss % and History. Right-click the Columns button for avg/min/max, consecutive failures, uptime and probe type.

Jitter is mean absolute successive difference across consecutive replies — a better feel for link quality than standard deviation, and the number that moves first when a connection starts to degrade while loss is still zero.

Loss % is the one worth reading day to day. It is a rolling window over the last N probes; the lifetime OK/NOK count is dragged down forever by an outage three days ago and stops describing the present.

History is a sparkline of recent probes — bar height is RTT, failures are full-height blocks. It is what turns a number into a diagnosis: "Loss 4%" tells you something is wrong, but four evenly spaced drops means periodic, and one solid block means a single outage. Different problems.

Status is never conveyed by colour alone — every row carries a glyph, a text label and a colour. Hovering shows the raw IPStatus, which distinguishes "nothing answered" from "a router actively said it could not deliver".

Closing the window hides to the tray; Exit is on the tray menu. Notifications fire only on transitions (down / recovered with duration), never on individual failed probes, and each one replaces the last rather than stacking up a transcript of how you got here.

Start with Windows is in the ⚙ menu — a per-user HKCU\...\Run entry, so no elevation and no scheduled task. It launches with --minimized, straight to the tray. A monitor you have to remember to start is a monitor that is not running on the morning something breaks.

The Theme submenu carries Follow Windows / Light / Dark, plus Matrix — green phosphor on a black plate, monospaced throughout. Failure states stay chromatically distinct there rather than collapsing into shades of green, because a board you cannot read at a glance has lost the one thing it is for.

While you were away. Restore the window after it has been in the tray or minimised for a couple of minutes and, if anything transitioned, the banner says what: which targets dropped, for how long, and which are still down. Nothing is shown when nothing happened — silence already means all was well, and a message confirming it would spend attention and return none.

This exists for the case nothing else catches: a target that dropped for four minutes while the window was in the tray and then recovered leaves no trace anyone will ever see. The notification came and went, the CSV goes unread, and the board only ever shows the present. An intermittent fault is the hard kind to catch, and it is the one this reports.

Losing focus does not count as being away — alt-tabbing for ten seconds is not absence, and treating it as such would fire the summary until you learned to ignore it.

Mute silences desktop notifications for an hour, twelve hours, or until switched back on. It does not touch webhook or email alerting: those exist to reach you when you are away from this machine, and silencing them because someone quietened a popup would invert the intent. The mute survives a restart — an indefinite one that quietly lifted itself would be the worst kind of bug — and shows in the status bar throughout, because a monitor you forgot you silenced is more dangerous than one that never alerted.

Zoom is Ctrl+scroll, or Ctrl++ / Ctrl+- / Ctrl+0. It scales the column widths, row height and font sizes that layout already reads rather than applying a render transform — WinUI has no LayoutTransform, and scaling pixels would blur the text and drift the header out of alignment with the rows.

Columns fit their content by default, remeasured every couple of seconds and only moved when a width changes by more than a few pixels. Both guards matter: latency and "Last OK" change several times a second, and a column that resizes on every tick moves under the cursor and makes the board unusable. Text is measured rather than estimated per character — the board runs in three faces and scales with zoom, so an estimate would be wrong by a different amount in each. Arrange columns… in the same menu reorders them; the order is data, so a cell's position and the width of the slot it sits in both follow one list rather than being written into the markup twice. Toggle it, or fit once, from the ⚙ menu.

About (the ⓘ button) carries the version and a link to the project, and checks GitHub for a newer release. It also checks quietly at startup, at most once a day, and says nothing unless there is something to say — a tool that interrupts you at launch to report it is already up to date has spent your attention on nothing. Turn it off in the ⚙ menu.

An update is offered, never installed. Silently replacing the binary of something you are relying on to watch your network is not a decision this code gets to make: it tells you, and you choose. The download URL is restricted to GitHub over HTTPS, because it arrives in a network response and a downloader that will run whatever it is handed is a remote code execution primitive.

Filtering is a text box over name, IP and hostname plus a status filter. Filtered-out targets keep being probed and keep alerting; the status bar always says how many of the total you are seeing, because counts that silently describe a subset are how someone concludes all is well while looking at three of forty hosts.

Failure traces

When a target crosses the down threshold, PingBoard traces the path and stores where it broke. Expanding a row shows the latency graph and, beneath it, the hops:

path breaks after hop 2 (38.34.167.2), 7 probed
 1  10.4.0.1  49 ms
 2  38.34.167.2  49 ms
 3  *

That is the difference between knowing something broke and knowing whose problem it is — and it has to be captured at the moment of failure, because by the time anyone reaches the machine the path has usually healed. Right-click a row to trace on demand. Traces also append to a sibling <log>.traces.txt; a dozen hop lines crammed into one CSV cell would ruin the thing the CSV is good at. TraceOnFailure, TraceMaxHops and TraceHopTimeoutMs are in [Settings].

Two guards worth knowing: only down transitions trace, so a permanently dead host costs one trace rather than one per second; and at most two run at once, skipped rather than queued, because when an uplink drops every target crosses the threshold within seconds of the others and forty simultaneous traces would flood a network already in trouble.

The latency graph

Expanding a row also plots the rolling window: one bar per probe scaled to that target's own peak, failures as full-height blocks, with min/avg/max gridlines. The sparkline tells you the shape of a problem in 44 bars; the graph tells you how much, against what baseline. A link that normally sits at 8 ms and is now at 40 ms is not down and loses no packets — every other column reads healthy, and only a plot scaled to its own history shows it.


Design notes

The decisions that matter, and why.

Sleep, resume and NIC loss never count as failures. The single most important guard here. SystemWatcher watches PowerModeChanged and NetworkAvailabilityChanged; while suspended every target reads Suspended, counters freeze and no notification fires. Without it, closing a laptop lid for an hour manufactures thousands of failures, wrecks the rolling loss figures and produces an alert storm on wake — and a monitor that cries wolf after every sleep gets ignored.

Fixed-size ring buffers, never a growing list. A List<ProbeResult> appended at 1 Hz across 40 targets grows ~100 MB/day. Measured over 7 minutes at ~48 probes/sec (≈20,000 probes) the managed heap stayed flat at 0.6 MB and handle count was unchanged.

One scheduler, staggered — not N timers. A single PeriodicTimer drives everything, each target phase-offset across its interval. Probes that are still outstanding are skipped rather than stacked, so a dead host with a long timeout cannot accumulate work; a SemaphoreSlim caps total concurrency.

Monotonic clock for durations, wall clock only for display. Mixing them means an NTP correction or a DST rollover silently corrupts every elapsed time on screen.

DNS is a separate failure mode. Names resolve once and cache with a TTL, re-resolving on expiry or after repeated failures so a DHCP change is still picked up. A name that stops resolving shows DNS FAIL, not TIMEOUT — a different problem at a different layer.

Probe rate is decoupled from render rate. Probes complete on threadpool threads and mutate the engine; a single 4 Hz timer pulls immutable snapshots to the UI. Marshalling every result to the dispatcher would mean 40+ hops/sec to redraw text nobody can read that fast.

Atomic config writes. Content goes to a temp file, is flushed to disk, then renamed over the destination with File.Move(overwrite: true)MoveFileEx, a true atomic rename. File.Replace was tried first and rejected: it moves the destination to the backup and then renames the temp in, so a kill between those steps leaves no config at all. A torture test that hard-killed a writer mid-save hit exactly that window. Loading also recovers from .bak and clears orphaned .tmp.

WinUI 3 rough edges worth knowing

Three cost real time and are easy to hit again:

  1. A Window is not a FrameworkElement. x:Bind with a converter inside a DataTemplate cannot compile when the XAML root is a Window. The board therefore lives in BoardView, a UserControl, and MainWindow is an empty shell.

  2. dotnet publish drops the app's resources.pri. Compiled XAML lives there, so the published app dies at InitializeComponent while the identical build in bin\ runs perfectly. PingBoard.App.csproj has an explicit copy target with an Error guard so this fails loudly at build time rather than silently at startup.

  3. A custom Panel must not touch layout state from inside layout. Adding children, calling Measure, or setting TextBlock.Text from ArrangeOverride invalidates layout from within layout. WinUI responds with LayoutCycleException and abandons the pass, which freezes the entire window and kills hit-testing — while the process still reports Responding = true and sits at idle CPU. There is no spinning to give it away, and if UnhandledException marks it handled the app limps on looking merely broken. LatencyGraph therefore creates its shapes and computes all its text in a property-changed callback, and ArrangeOverride only assigns brushes and calls Arrange. Cost: two wrong diagnoses before reading the crash log properly.

  4. Toast registration fails under self-contained deployment. AppNotificationManager.Register() needs Microsoft.WindowsAppRuntime.Insights.Resource.dll, which ships with the installed framework runtime and is not in the self-contained payload or any NuGet package. Rather than give up portability, notifications fall back to a tray balloon — which Windows 10/11 renders as an ordinary toast in the notification centre anyway.

There is also no DataGrid in WinUI 3 (the Community Toolkit dropped it at v8.0), so the board is a virtualized ListView with a hand-built header. ColumnLayout is a single shared object both the header and the row template bind to, which makes header/row misalignment structurally impossible. Hidden columns collapse to zero width and Visibility.Collapsed — zero width alone is not enough, because a TextBlock arranged into it still paints and bleeds over its neighbour.

Similarly there is no tray support, so TrayIcon builds on Shell_NotifyIcon directly rather than taking H.NotifyIcon.WinUI, whose latest stable release predates the Windows App SDK 2.0 breaking changes.


Layout

src/PingBoard.Core/      engine — no UI references, ever
src/PingBoard.App/       WinUI 3 front end
src/PingBoard.Harness/   headless driver and self-tests

Not included

SNMP, a service mode, and multi-machine sync. Each is a reasonable next step; none belonged in the first build. Traceroute, latency graphing and webhook/email alerting were on this list and have since landed.

Licence

MIT — see LICENSE.

About

Always-on ping monitor for Windows 11. ICMP, TCP and HTTP probes with per-host history, latency graphs, traceroute on failure, availability stats and webhook/email alerts.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages