Add fleet alert notifications for shop floor PCs

Fleet alerts push server notifications to endpoint PCs via the command pipe.
Operators acknowledge alerts via toast UI in the tray. Backend tracks
acknowledgements per asset to prevent re-showing dismissed alerts.

Backend: alertfleet/alertscope/alerttimeout columns, GET/POST /api/endpoints/alerts,
NotificationAcknowledgement model with unique constraint, hostname validation.

Frontend: Alert Fleet checkbox in notification form.

Agent: FleetAlertClient polls alerts, FleetAlertStore tracks acknowledged IDs,
FleetAlertForm displays toast (no auto-dismiss), FleetAlertWorker 60s polling.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Proudlock
2026-09-08 16:08:39 -04:00
parent 86256e7041
commit 381cc6c845
14 changed files with 983 additions and 8 deletions

View File

@@ -0,0 +1,97 @@
using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Polls the fleet alert channel to fetch active notifications for this endpoint.
// The server may publish site-wide alerts (maintenance windows, policy changes)
// or endpoint-specific warnings (outdated agent version, failed telemetry push).
// The agent polls GET /api/endpoints/alerts?hostname=X every 60 seconds when idle
// and deserializes the JSON response into FleetAlert DTOs. Shares the endpoints
// collector key via X-API-Key, so the alert channel rides the same kill switch as
// telemetry and commands.
public sealed class FleetAlertClient : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private readonly HttpClient http;
private readonly SocketsHttpHandler handler;
private readonly ILogger logger;
public FleetAlertClient(AgentConfig config, ILogger logger)
{
this.logger = logger;
handler = new SocketsHttpHandler
{
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90),
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
};
http = new HttpClient(handler, disposeHandler: true)
{
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
Timeout = TimeSpan.FromSeconds(30),
};
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
}
public async Task<List<FleetAlert>> FetchAlertsAsync(
string hostname, CancellationToken token)
{
var url = "api/endpoints/alerts?hostname="
+ Uri.EscapeDataString(hostname);
using var response = await http.GetAsync(url, token);
if (response.StatusCode == HttpStatusCode.NoContent)
return new List<FleetAlert>();
var body = await response.Content.ReadAsStringAsync(token);
if (!response.IsSuccessStatusCode)
throw new CollectorException((int)response.StatusCode, body);
var envelope = JsonSerializer.Deserialize<FleetAlertEnvelope>(
body, JsonOptions);
return envelope?.Data ?? new List<FleetAlert>();
}
public async Task AcknowledgeAlertAsync(
int notificationId, int assetId, string hostname, CancellationToken token)
{
var url = $"api/endpoints/alerts/{notificationId}/acknowledge";
var payload = new
{
assetid = assetId,
hostname
};
var json = JsonSerializer.Serialize(payload, JsonOptions);
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
using var response = await http.PostAsync(url, content, token);
var body = await response.Content.ReadAsStringAsync(token);
if (!response.IsSuccessStatusCode)
throw new CollectorException((int)response.StatusCode, body);
}
public void Dispose() => http.Dispose();
}
public sealed class FleetAlert
{
[JsonPropertyName("notificationId")] public int NotificationId { get; set; }
[JsonPropertyName("type")] public string TypeName { get; set; } = "";
[JsonPropertyName("typeColor")] public string TypeColor { get; set; } = "";
[JsonPropertyName("title")] public string Title { get; set; } = "";
[JsonPropertyName("message")] public string Message { get; set; } = "";
[JsonPropertyName("timeout")] public int Timeout { get; set; }
}
public sealed class FleetAlertEnvelope
{
[JsonPropertyName("status")] public string Status { get; set; } = "";
[JsonPropertyName("data")] public List<FleetAlert>? Data { get; set; }
}

View File

@@ -0,0 +1,113 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Local JSON file store for tracking acknowledged fleet alerts.
// Stores notification IDs that have been acknowledged so they don't
// reappear in future polls. Thread-safe file access with JSON serialization.
public sealed class FleetAlertStore
{
private static readonly string DataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"ShopDbAgent");
private static readonly string StoreFile = Path.Combine(DataDir, "acknowledged-alerts.json");
private readonly object fileLock = new();
private readonly ILogger logger;
private HashSet<int> acknowledged;
public FleetAlertStore(ILogger logger)
{
this.logger = logger;
acknowledged = new HashSet<int>();
Load();
}
public bool IsAcknowledged(int notificationId)
{
lock (fileLock)
{
return acknowledged.Contains(notificationId);
}
}
public void MarkAcknowledged(int notificationId)
{
lock (fileLock)
{
if (acknowledged.Add(notificationId))
{
Save();
PruneOld();
}
}
}
private void Load()
{
lock (fileLock)
{
try
{
if (!File.Exists(StoreFile))
{
acknowledged = new HashSet<int>();
return;
}
var json = File.ReadAllText(StoreFile);
var stored = JsonSerializer.Deserialize<StoredData>(json);
acknowledged = stored?.AcknowledgedIds?.ToHashSet() ?? new HashSet<int>();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not load acknowledged alerts store; starting fresh");
acknowledged = new HashSet<int>();
}
}
}
private void Save()
{
try
{
Directory.CreateDirectory(DataDir);
var data = new StoredData
{
AcknowledgedIds = acknowledged.ToList(),
LastUpdated = DateTime.UtcNow
};
var json = JsonSerializer.Serialize(data, new JsonSerializerOptions
{
WriteIndented = true
});
File.WriteAllText(StoreFile, json);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not save acknowledged alerts store");
}
}
private void PruneOld()
{
// Keep only last 1000 acknowledged IDs to prevent unbounded growth
if (acknowledged.Count > 1000)
{
var toKeep = acknowledged.OrderByDescending(id => id).Take(1000).ToHashSet();
acknowledged = toKeep;
Save();
}
}
private sealed class StoredData
{
public List<int> AcknowledgedIds { get; set; } = new();
public DateTime LastUpdated { get; set; }
}
}

View File

@@ -0,0 +1,168 @@
using System.Drawing;
using ShopDbEndpointAgent.Core;
namespace ShopDbEndpointAgent.Tray;
// Displays fleet-wide alert notifications from the ShopDB server in a toast-style
// window. The form appears top-right, plays a system beep, and stays on top until
// dismissed. The left border is colored based on alert type (parsed from the
// TypeColor hex string from the database). The operator acknowledges the alert by
// clicking the button or the X. No auto-dismiss - manual acknowledgement only.
internal sealed class FleetAlertForm : Form
{
private readonly FleetAlert alert;
public event EventHandler? OnAcknowledgeClicked;
public FleetAlertForm(FleetAlert alert)
{
this.alert = alert ?? throw new ArgumentNullException(nameof(alert));
Text = "Fleet Alert";
FormBorderStyle = FormBorderStyle.FixedToolWindow;
TopMost = true;
ShowInTaskbar = false;
StartPosition = FormStartPosition.Manual;
ClientSize = new Size(400, 220);
var screen = Screen.PrimaryScreen?.WorkingArea ?? Screen.PrimaryScreen!.Bounds;
Location = new Point(screen.Right - Width - 20, screen.Top + 20);
BuildLayout();
Load += FleetAlertForm_Load;
}
private void FleetAlertForm_Load(object? sender, EventArgs e)
{
System.Media.SystemSounds.Beep.Play();
}
private void BuildLayout()
{
var borderPanel = new Panel
{
BackColor = ParseTypeColor(alert.TypeColor),
Dock = DockStyle.Left,
Width = 5,
};
Controls.Add(borderPanel);
var contentPanel = new Panel
{
Left = 5,
Top = 0,
Width = 395,
Height = 220,
};
Controls.Add(contentPanel);
var headerPanel = new Panel
{
Left = 0,
Top = 0,
Width = 395,
Height = 40,
BackColor = SystemColors.ControlLight,
};
contentPanel.Controls.Add(headerPanel);
var typeLabel = new Label
{
Text = alert.TypeName.ToUpper(),
Left = 12,
Top = 12,
Width = 300,
Font = new Font(Font.FontFamily, 9, FontStyle.Bold),
};
headerPanel.Controls.Add(typeLabel);
var closeButton = new Button
{
Text = "X",
Left = 355,
Top = 8,
Width = 28,
Height = 24,
FlatStyle = FlatStyle.Flat,
};
closeButton.FlatAppearance.BorderSize = 0;
closeButton.Click += CloseButton_Click;
headerPanel.Controls.Add(closeButton);
var titleLabel = new Label
{
Text = alert.Title,
Left = 12,
Top = 52,
Width = 370,
Height = 60,
Font = new Font(Font.FontFamily, 14, FontStyle.Bold),
AutoSize = false,
};
contentPanel.Controls.Add(titleLabel);
var messageLabel = new Label
{
Text = alert.Message,
Left = 12,
Top = 115,
Width = 370,
Height = 60,
Font = new Font(Font.FontFamily, 10),
AutoSize = false,
};
contentPanel.Controls.Add(messageLabel);
var acknowledgeButton = new Button
{
Text = "Acknowledge",
Left = 128,
Top = 180,
Width = 140,
Height = 32,
BackColor = SystemColors.Highlight,
ForeColor = Color.White,
FlatStyle = FlatStyle.Flat,
};
acknowledgeButton.FlatAppearance.BorderSize = 0;
acknowledgeButton.Click += AcknowledgeButton_Click;
contentPanel.Controls.Add(acknowledgeButton);
}
private Color ParseTypeColor(string? hexColor)
{
if (string.IsNullOrWhiteSpace(hexColor))
return Color.Gray;
var hex = hexColor.TrimStart('#');
if (hex.Length != 6)
return Color.Gray;
try
{
var r = Convert.ToByte(hex.Substring(0, 2), 16);
var g = Convert.ToByte(hex.Substring(2, 2), 16);
var b = Convert.ToByte(hex.Substring(4, 2), 16);
return Color.FromArgb(r, g, b);
}
catch
{
return Color.Gray;
}
}
private void AcknowledgeButton_Click(object? sender, EventArgs e)
{
OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty);
DialogResult = DialogResult.OK;
Close();
}
private void CloseButton_Click(object? sender, EventArgs e)
{
OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty);
DialogResult = DialogResult.OK;
Close();
}
}

View File

@@ -0,0 +1,187 @@
using Microsoft.Extensions.Logging;
using ShopDbEndpointAgent.Core;
namespace ShopDbEndpointAgent.Tray;
// Polls the server for fleet-wide alerts every 60 seconds and displays new
// alerts to the operator via FleetAlertForm. Acknowledged alerts are tracked in
// FleetAlertStore and posted back to the server so they do not reappear in
// future polls. Runs for the tray's lifetime on a dedicated background thread.
internal sealed class FleetAlertWorker : IDisposable
{
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(60);
private static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(15);
private readonly FleetAlertClient client;
private readonly FleetAlertStore store;
private readonly string hostname;
private readonly int assetId;
private readonly ILogger logger;
private readonly SynchronizationContext? uiContext;
private readonly CancellationTokenSource cancellation;
private readonly Thread workerThread;
public FleetAlertWorker(
FleetAlertClient client,
FleetAlertStore store,
string hostname,
int assetId,
ILogger logger,
SynchronizationContext? uiContext = null)
{
this.client = client ?? throw new ArgumentNullException(nameof(client));
this.store = store ?? throw new ArgumentNullException(nameof(store));
this.hostname = hostname ?? throw new ArgumentNullException(nameof(hostname));
this.assetId = assetId;
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.uiContext = uiContext ?? SynchronizationContext.Current;
cancellation = new CancellationTokenSource();
workerThread = new Thread(Run)
{
IsBackground = true,
Name = "ShopDB fleet alert poller",
};
}
public void Start()
{
logger.LogInformation(
"Fleet alert worker starting; first poll in {Delay}, then every {Interval}.",
StartupDelay, PollInterval);
workerThread.Start();
}
private void Run()
{
var token = cancellation.Token;
try
{
Thread.Sleep(StartupDelay);
}
catch (Exception)
{
return;
}
while (!token.IsCancellationRequested)
{
try
{
PollAndDisplayAlerts(token);
}
catch (OperationCanceledException)
{
return;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Fleet alert poll failed; retrying on next cycle.");
}
try
{
Thread.Sleep(PollInterval);
}
catch (Exception)
{
return;
}
}
}
private void PollAndDisplayAlerts(CancellationToken token)
{
var alerts = client.FetchAlertsAsync(hostname, token).GetAwaiter().GetResult();
if (alerts is null || alerts.Count == 0)
{
logger.LogDebug("No active fleet alerts.");
return;
}
var newAlerts = alerts
.Where(alert => !store.IsAcknowledged(alert.NotificationId))
.ToList();
if (newAlerts.Count == 0)
{
logger.LogDebug("All {Count} fleet alerts already acknowledged.", alerts.Count);
return;
}
logger.LogInformation("Found {Count} new fleet alert(s).", newAlerts.Count);
foreach (var alert in newAlerts)
{
if (token.IsCancellationRequested)
return;
ShowAlertOnUIThread(alert, token);
}
}
private void ShowAlertOnUIThread(FleetAlert alert, CancellationToken token)
{
var shown = new ManualResetEventSlim(false);
Exception? formException = null;
var showAction = new SendOrPostCallback(_ =>
{
try
{
using var form = new FleetAlertForm(alert);
form.OnAcknowledgeClicked += (s, e) => OnAlertAcknowledged(alert, token);
var result = form.ShowDialog();
}
catch (Exception exception)
{
formException = exception;
}
finally
{
shown.Set();
}
});
if (uiContext is not null)
uiContext.Post(showAction, null);
else
showAction(null);
shown.Wait(token);
if (formException is not null)
{
logger.LogWarning(formException,
"Could not display fleet alert {NotificationId}.", alert.NotificationId);
}
}
private void OnAlertAcknowledged(FleetAlert alert, CancellationToken token)
{
try
{
logger.LogInformation(
"Fleet alert {NotificationId} acknowledged; posting to server.", alert.NotificationId);
client.AcknowledgeAlertAsync(alert.NotificationId, assetId, hostname, token)
.GetAwaiter()
.GetResult();
store.MarkAcknowledged(alert.NotificationId);
logger.LogInformation("Fleet alert {NotificationId} recorded.", alert.NotificationId);
}
catch (Exception exception)
{
logger.LogWarning(exception,
"Could not acknowledge fleet alert {NotificationId}; will retry on next poll.",
alert.NotificationId);
}
}
public void Dispose()
{
cancellation.Cancel();
cancellation.Dispose();
}
}

View File

@@ -71,7 +71,7 @@
copies of the exit-code handling. A tray that cannot open the channel
logs and keeps its timer, so the failure mode is "refresh is not
immediate", not "session data stops working". Requires agent 1.11.0. -->
<Version>1.4.0</Version>
<Version>1.5.0</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>

View File

@@ -82,6 +82,14 @@ internal sealed class TrayContext : ApplicationContext
// guard and launch two probes at the same spool file.
private readonly SynchronizationContext? uiContext;
// Fleet alerts are pulled from the server and displayed to the operator as
// toast notifications in the top-right corner. The worker polls every 60
// seconds, filters out already-acknowledged alerts via the store, and posts
// acknowledgements back to the server when the operator dismisses the form.
private readonly FleetAlertClient? fleetAlertClient;
private readonly FleetAlertStore? fleetAlertStore;
private readonly FleetAlertWorker? fleetAlertWorker;
// The tray logs into the operator's own profile
// (C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log), NOT into the
// service's ProgramData directory. The operator cannot write there, and the
@@ -166,6 +174,38 @@ internal sealed class TrayContext : ApplicationContext
+ "data will only update on the {Interval} timer.",
UserContextInterval);
}
// Fleet alert worker. Best-effort: if the config cannot be loaded or the
// worker fails to start, the tray still runs and the other features work.
// The operator just does not receive fleet-wide alerts. Loading the config
// requires reading %ProgramData%\ShopDB\config.json, which is written by
// the installer and owned by SYSTEM but readable by the operator. Loading
// the asset ID requires reading the spool file written by the service.
try
{
var config = AgentConfigProvider.Load(logger: logger);
var assetId = ResolveAssetId();
var hostname = Environment.MachineName;
fleetAlertClient = new FleetAlertClient(config,
logProvider.CreateLogger(nameof(FleetAlertClient)));
fleetAlertStore = new FleetAlertStore(
logProvider.CreateLogger(nameof(FleetAlertStore)));
fleetAlertWorker = new FleetAlertWorker(
fleetAlertClient,
fleetAlertStore,
hostname,
assetId,
logProvider.CreateLogger(nameof(FleetAlertWorker)),
uiContext);
fleetAlertWorker.Start();
}
catch (Exception exception)
{
logger.LogWarning(exception,
"Could not start the fleet alert worker; fleet-wide alerts will not "
+ "be displayed. Other tray features are unaffected.");
}
}
// Waits for service refresh requests for the life of the tray.
@@ -642,6 +682,36 @@ internal sealed class TrayContext : ApplicationContext
ExitThread();
}
// Resolves this machine's asset ID from the telemetry state file written by
// the service. Used to identify which asset acknowledged a fleet alert. If
// the state file is missing or unreadable, returns 0 as a signal that fleet
// alerts should be disabled (the asset is not enrolled).
private int ResolveAssetId()
{
try
{
var stateFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"ShopDB", "state.json");
if (!File.Exists(stateFile))
return 0;
var json = File.ReadAllText(stateFile);
var state = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(json);
if (state is not null && state.TryGetValue("assetId", out var value))
{
if (value is System.Text.Json.JsonElement element && element.ValueKind == System.Text.Json.JsonValueKind.Number)
return element.GetInt32();
}
}
catch (Exception exception)
{
logger.LogDebug(exception, "Could not resolve asset ID from state file.");
}
return 0;
}
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -655,6 +725,9 @@ internal sealed class TrayContext : ApplicationContext
refreshCancellation.Cancel();
refreshCancellation.Dispose();
fleetAlertWorker?.Dispose();
fleetAlertClient?.Dispose();
trayIcon.Dispose();
brandIcon.Dispose();
}

View File

@@ -171,7 +171,7 @@
member of that group, so Start-ScheduledTask returned access denied and
the task only ever ran on its own trigger. Requires tray 1.4.0 - the tray
is the side that listens, so an older tray simply never answers. -->
<Version>1.12.0</Version>
<Version>1.13.0</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>