From ab888d5780e1d58ff632270d072be7e4fca5b80b Mon Sep 17 00:00:00 2001 From: cproudlock Date: Tue, 15 Sep 2026 11:28:52 -0400 Subject: [PATCH] Make fleet alerts readable, deliberate to acknowledge and remembered Tray 1.6.11. - One alert with a null or malformed field no longer throws away the whole alert list; nullable server fields are tolerated. - Keystrokes can no longer acknowledge an alert the operator has not seen. There is no default button, focus starts on the message, and acknowledging takes a mouse click after the alert has been on screen for 1.5 seconds. - Long messages scroll instead of being cut off, and an ampersand is shown as typed. - A timeout closes the alert without acknowledging it. - Missing type names, titles and colours fall back to safe defaults. - Shutdown waits on cancellation instead of sleeping, and closes an open alert before the client is disposed. - The acknowledged-alerts store moved from ProgramData, which the operator cannot write, to the user's local app data, guarded by a mutex. Acked alerts used to reappear at every logon. --- .../FleetAlertStore.cs | 142 +++++-- .../FleetAlertForm.cs | 379 +++++++++++------- .../FleetAlertWorker.cs | 64 ++- .../ShopDbEndpointAgent.Tray.csproj | 12 +- 4 files changed, 408 insertions(+), 189 deletions(-) diff --git a/agent/ShopDbEndpointAgent.Core/FleetAlertStore.cs b/agent/ShopDbEndpointAgent.Core/FleetAlertStore.cs index 858a2980..33dcc7e9 100644 --- a/agent/ShopDbEndpointAgent.Core/FleetAlertStore.cs +++ b/agent/ShopDbEndpointAgent.Core/FleetAlertStore.cs @@ -5,24 +5,37 @@ 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. +// reappear in future polls. Lives under %LOCALAPPDATA% because the tray runs +// as the standard operator, who cannot write to %ProgramData%. Read-modify-write +// is serialized across processes by a named mutex. public sealed class FleetAlertStore { + private const int MaxIds = 1000; + private const string MutexName = @"Local\ShopDB-FleetAlertStore"; + private static readonly TimeSpan MutexWait = TimeSpan.FromSeconds(5); + private static readonly string DataDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ShopDB", "FleetAlerts"); private static readonly string StoreFile = Path.Combine(DataDir, "acknowledged-alerts.json"); + // Pre-1.6.11 location. Standard users could not write it, but an elevated + // run may have left IDs there; merged once. + private static readonly string LegacyStoreFile = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "ShopDB", + "FleetAlerts", + "acknowledged-alerts.json"); + private readonly object fileLock = new(); private readonly ILogger logger; - private HashSet acknowledged; + private HashSet acknowledged = new(); public FleetAlertStore(ILogger logger) { this.logger = logger; - acknowledged = new HashSet(); Load(); } @@ -38,11 +51,17 @@ public sealed class FleetAlertStore { lock (fileLock) { - if (acknowledged.Add(notificationId)) + WithFileMutex(() => { - Save(); - PruneOld(); - } + // Merge what another instance may have written since our load. + var onDisk = ReadFile(StoreFile); + if (onDisk?.AcknowledgedIds is not null) + acknowledged.UnionWith(onDisk.AcknowledgedIds); + + acknowledged.Add(notificationId); + Prune(); + Save(onDisk?.MigratedLegacy ?? true); + }); } } @@ -50,27 +69,45 @@ public sealed class FleetAlertStore { lock (fileLock) { - try + WithFileMutex(() => { - if (!File.Exists(StoreFile)) - { - acknowledged = new HashSet(); - return; - } - - var json = File.ReadAllText(StoreFile); - var stored = JsonSerializer.Deserialize(json); + var stored = ReadFile(StoreFile); acknowledged = stored?.AcknowledgedIds?.ToHashSet() ?? new HashSet(); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Could not load acknowledged alerts store; starting fresh"); - acknowledged = new HashSet(); - } + + if (stored?.MigratedLegacy == true) + return; + + var legacy = ReadFile(LegacyStoreFile); + var merged = legacy?.AcknowledgedIds?.Count(id => acknowledged.Add(id)) ?? 0; + if (merged > 0) + { + logger.LogInformation( + "Merged {Count} acknowledged alert ID(s) from legacy store {Path}.", + merged, LegacyStoreFile); + Prune(); + } + if (merged > 0 || stored is not null) + Save(migratedLegacy: true); + }); } } - private void Save() + private StoredData? ReadFile(string path) + { + try + { + if (!File.Exists(path)) + return null; + return JsonSerializer.Deserialize(File.ReadAllText(path)); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not read acknowledged alerts store {Path}.", path); + return null; + } + } + + private void Save(bool migratedLegacy) { try { @@ -79,7 +116,8 @@ public sealed class FleetAlertStore var data = new StoredData { AcknowledgedIds = acknowledged.ToList(), - LastUpdated = DateTime.UtcNow + LastUpdated = DateTime.UtcNow, + MigratedLegacy = migratedLegacy, }; var json = JsonSerializer.Serialize(data, new JsonSerializerOptions @@ -87,28 +125,66 @@ public sealed class FleetAlertStore WriteIndented = true }); - File.WriteAllText(StoreFile, json); + var temp = StoreFile + ".tmp"; + File.WriteAllText(temp, json); + File.Move(temp, StoreFile, overwrite: true); } catch (Exception ex) { - logger.LogWarning(ex, "Could not save acknowledged alerts store"); + logger.LogError(ex, + "Could not save acknowledged alerts store {Path}; acknowledged alerts may reappear.", + StoreFile); } } - private void PruneOld() + // Runs action under the cross-process mutex. If the mutex cannot be had, + // logs and runs anyway: a rare clobber beats never recording an ack. + private void WithFileMutex(Action action) { - // Keep only last 1000 acknowledged IDs to prevent unbounded growth - if (acknowledged.Count > 1000) + Mutex? mutex = null; + var owned = false; + try { - var toKeep = acknowledged.OrderByDescending(id => id).Take(1000).ToHashSet(); - acknowledged = toKeep; - Save(); + mutex = new Mutex(false, MutexName); + try + { + owned = mutex.WaitOne(MutexWait); + } + catch (AbandonedMutexException) + { + owned = true; + } + if (!owned) + logger.LogWarning("Timed out waiting for fleet alert store mutex; proceeding without it."); } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not open fleet alert store mutex; proceeding without it."); + } + + try + { + action(); + } + finally + { + if (owned) + mutex!.ReleaseMutex(); + mutex?.Dispose(); + } + } + + private void Prune() + { + // Keep only newest IDs to prevent unbounded growth + if (acknowledged.Count > MaxIds) + acknowledged = acknowledged.OrderByDescending(id => id).Take(MaxIds).ToHashSet(); } private sealed class StoredData { public List AcknowledgedIds { get; set; } = new(); public DateTime LastUpdated { get; set; } + public bool MigratedLegacy { get; set; } } } diff --git a/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs b/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs index 617a92db..88cdb54d 100644 --- a/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs +++ b/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs @@ -1,5 +1,6 @@ using System.Drawing; using System.Drawing.Drawing2D; +using Microsoft.Extensions.Logging; using ShopDbEndpointAgent.Core; namespace ShopDbEndpointAgent.Tray; @@ -8,42 +9,62 @@ namespace ShopDbEndpointAgent.Tray; // 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. Countdown timer shows auto-dismiss time if configured. +// clicking the button or the X with the mouse. The form steals focus when shown, +// so neither button is a tab stop, there is no Accept/Cancel button, keyboard +// clicks are ignored, and mouse clicks are ignored until ArmDelay has passed: +// keystrokes meant for another app must not acknowledge an unseen alert. +// Closing any other way (server withdrew it, timeout, shutdown) is NOT an +// acknowledgement. internal sealed class FleetAlertForm : Form { + private const int MaxMessageLength = 8000; + private const string DefaultTypeName = "Alert"; + private const string DefaultTitle = "(no title)"; + private static readonly TimeSpan ArmDelay = TimeSpan.FromMilliseconds(1500); + private static readonly Color DefaultTypeColor = Color.Gray; + private readonly FleetAlert alert; private readonly FleetAlertClient? client; private readonly string? hostname; + private readonly ILogger? logger; private System.Windows.Forms.Timer? countdownTimer; private System.Windows.Forms.Timer? statusCheckTimer; private readonly CancellationTokenSource statusCheckCancellation = new(); private int remainingSeconds; private Label? countdownLabel; + private TextBox? messageBox; private readonly List fonts = new(); + private DateTime armedAtUtc = DateTime.MaxValue; + private bool acknowledged; public event EventHandler? OnAcknowledgeClicked; - public FleetAlertForm(FleetAlert alert, FleetAlertClient? client = null, string? hostname = null) + public FleetAlertForm( + FleetAlert alert, FleetAlertClient? client = null, string? hostname = null, ILogger? logger = null) { this.alert = alert ?? throw new ArgumentNullException(nameof(alert)); this.client = client; this.hostname = hostname; + this.logger = logger; + SuspendLayout(); + AutoScaleDimensions = new SizeF(96F, 96F); + AutoScaleMode = AutoScaleMode.Dpi; Text = string.Empty; FormBorderStyle = FormBorderStyle.None; TopMost = true; ShowInTaskbar = false; StartPosition = FormStartPosition.Manual; - ClientSize = new Size(400, 280); + ClientSize = new Size(400, 320); BackColor = Color.White; - - var screen = Screen.PrimaryScreen?.WorkingArea ?? Screen.PrimaryScreen?.Bounds ?? new Rectangle(0, 0, 1920, 1080); - Location = new Point(screen.Right - Width - 20, screen.Top + 20); + KeyPreview = false; BuildLayout(); - ApplyRoundedCorners(); + ResumeLayout(false); Load += FleetAlertForm_Load; + Shown += FleetAlertForm_Shown; + Resize += (s, e) => ApplyRoundedCorners(); FormClosing += FleetAlertForm_FormClosing; } @@ -56,6 +77,11 @@ internal sealed class FleetAlertForm : Form private void FleetAlertForm_Load(object? sender, EventArgs e) { + // Size is final (DPI scaled) by Load; place and shape here. + var screen = Screen.PrimaryScreen?.WorkingArea ?? Screen.PrimaryScreen?.Bounds ?? new Rectangle(0, 0, 1920, 1080); + Location = new Point(screen.Right - Width - 20, screen.Top + 20); + ApplyRoundedCorners(); + System.Media.SystemSounds.Beep.Play(); if (client != null && hostname != null) @@ -64,6 +90,16 @@ internal sealed class FleetAlertForm : Form } } + private void FleetAlertForm_Shown(object? sender, EventArgs e) + { + armedAtUtc = DateTime.UtcNow + ArmDelay; + if (messageBox != null) + { + ActiveControl = messageBox; + messageBox.Select(0, 0); + } + } + private void StartStatusCheck() { statusCheckTimer = new System.Windows.Forms.Timer { Interval = 30000 }; @@ -76,15 +112,19 @@ internal sealed class FleetAlertForm : Form if (client == null || hostname == null) return; + var token = statusCheckCancellation.Token; + if (token.IsCancellationRequested) + return; + Task.Run(async () => { try { - var alerts = await client.FetchAlertsAsync(hostname, statusCheckCancellation.Token) + var alerts = await client.FetchAlertsAsync(hostname, token) .ConfigureAwait(false); var stillActive = alerts?.Any(a => a.NotificationId == alert.NotificationId) ?? false; - if (!stillActive) + if (!stillActive && !token.IsCancellationRequested && IsHandleCreated && !IsDisposed) { BeginInvoke(new Action(() => { @@ -93,13 +133,15 @@ internal sealed class FleetAlertForm : Form })); } } - catch (OperationCanceledException) + catch (OperationCanceledException) when (token.IsCancellationRequested) { - // Form is closing, ignore + // Form is closing } - catch + catch (Exception exception) { - // Ignore errors - keep the form open if we can't check status + // Keep the form open if status cannot be checked + logger?.LogDebug(exception, + "Status check for fleet alert {NotificationId} failed.", alert.NotificationId); } }); } @@ -107,7 +149,7 @@ internal sealed class FleetAlertForm : Form private void ApplyRoundedCorners() { using var path = new GraphicsPath(); - int radius = 8; + int radius = LogicalToDeviceUnits(8); var rect = new Rectangle(0, 0, Width, Height); path.AddArc(rect.X, rect.Y, radius, radius, 180, 90); @@ -116,12 +158,20 @@ internal sealed class FleetAlertForm : Form path.AddArc(rect.X, rect.Bottom - radius, radius, radius, 90, 90); path.CloseFigure(); + var old = Region; Region = new Region(path); + old?.Dispose(); } + // Not wired (1.6.2 made alerts manual-only). If wired, a timeout closes the + // form WITHOUT acknowledging, so the alert can be surfaced again. private void StartCountdown() { - remainingSeconds = alert.Timeout > 0 ? alert.Timeout * 60 : 300; + var minutes = alert.TimeoutMinutes; + if (minutes <= 0) + return; + + remainingSeconds = minutes * 60; countdownTimer = new System.Windows.Forms.Timer { Interval = 1000 }; countdownTimer.Tick += CountdownTimer_Tick; @@ -138,7 +188,6 @@ internal sealed class FleetAlertForm : Form if (remainingSeconds <= 0) { countdownTimer?.Stop(); - OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty); Close(); } } @@ -149,7 +198,7 @@ internal sealed class FleetAlertForm : Form { int minutes = remainingSeconds / 60; int seconds = remainingSeconds % 60; - countdownLabel.Text = $"Auto-dismiss in {minutes}:{seconds:D2}"; + countdownLabel.Text = $"Closes in {minutes}:{seconds:D2}"; } } @@ -160,8 +209,29 @@ internal sealed class FleetAlertForm : Form return font; } + private static string SafeText(string? value, string fallback) + { + return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + } + + private static string PrepareMessage(string? value) + { + var text = value ?? string.Empty; + if (text.Length > MaxMessageLength) + text = text.Substring(0, MaxMessageLength) + "..."; + return text.Replace("\r\n", "\n").Replace('\r', '\n').Replace("\n", "\r\n"); + } + private void BuildLayout() { + // Dock order: Fill added first, outermost edges added last. + var contentPanel = new Panel + { + Dock = DockStyle.Fill, + BackColor = Color.White, + }; + Controls.Add(contentPanel); + var borderPanel = new Panel { BackColor = ParseTypeColor(alert.TypeColor), @@ -170,21 +240,103 @@ internal sealed class FleetAlertForm : Form }; Controls.Add(borderPanel); - var contentPanel = new Panel + var messagePanel = new Panel { - Left = 5, - Top = 0, - Width = 395, - Height = 280, + Dock = DockStyle.Fill, + Padding = new Padding(20, 4, 12, 4), BackColor = Color.White, }; - Controls.Add(contentPanel); + contentPanel.Controls.Add(messagePanel); + + messageBox = new TextBox + { + Text = PrepareMessage(alert.Message), + Dock = DockStyle.Fill, + Multiline = true, + ReadOnly = true, + WordWrap = true, + ScrollBars = ScrollBars.Vertical, + BorderStyle = BorderStyle.None, + BackColor = Color.White, + Font = CreateFont("Segoe UI", 9.75f), + ForeColor = Color.FromArgb(85, 85, 85), + TabIndex = 0, + }; + messagePanel.Controls.Add(messageBox); + + var titleLabel = new Label + { + Text = SafeText(alert.Title, DefaultTitle), + Dock = DockStyle.Top, + Height = 50, + Padding = new Padding(16, 8, 16, 0), + Font = CreateFont("Segoe UI", 11, FontStyle.Bold), + ForeColor = Color.FromArgb(51, 51, 51), + AutoSize = false, + AutoEllipsis = true, + UseMnemonic = false, + }; + contentPanel.Controls.Add(titleLabel); + + var buttonPanel = new Panel + { + Dock = DockStyle.Bottom, + Height = 46, + BackColor = Color.White, + }; + contentPanel.Controls.Add(buttonPanel); + + var acknowledgeButton = new MouseOnlyButton(IsArmed) + { + Text = "Acknowledge", + Width = 140, + Height = 32, + Left = (395 - 140) / 2, + Top = 7, + Anchor = AnchorStyles.None, + BackColor = Color.FromArgb(76, 175, 80), + ForeColor = Color.White, + FlatStyle = FlatStyle.Flat, + Font = CreateFont("Segoe UI", 9, FontStyle.Bold), + Cursor = Cursors.Hand, + TabStop = false, + UseMnemonic = false, + }; + acknowledgeButton.FlatAppearance.BorderSize = 0; + acknowledgeButton.FlatAppearance.MouseOverBackColor = Color.FromArgb(69, 160, 73); + acknowledgeButton.Click += AcknowledgeButton_Click; + buttonPanel.Controls.Add(acknowledgeButton); + + var footerPanel = new Panel + { + Dock = DockStyle.Bottom, + Height = 40, + BackColor = Color.White, + }; + contentPanel.Controls.Add(footerPanel); + + countdownLabel = new Label + { + Text = "Manual acknowledgment required", + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.MiddleCenter, + Font = CreateFont("Segoe UI", 8.25f), + ForeColor = Color.FromArgb(153, 153, 153), + UseMnemonic = false, + }; + footerPanel.Controls.Add(countdownLabel); + + var footerBorder = new Panel + { + Dock = DockStyle.Top, + Height = 1, + BackColor = Color.FromArgb(238, 238, 238), + }; + footerPanel.Controls.Add(footerBorder); var headerPanel = new Panel { - Left = 0, - Top = 0, - Width = 395, + Dock = DockStyle.Top, Height = 45, BackColor = Color.FromArgb(245, 245, 245), }; @@ -192,148 +344,66 @@ internal sealed class FleetAlertForm : Form var typeLabel = new Label { - Text = alert.TypeName.ToUpper(), - Left = 16, - Top = 14, - Width = 300, + Text = SafeText(alert.TypeName, DefaultTypeName).ToUpperInvariant(), + Dock = DockStyle.Fill, + Padding = new Padding(12, 0, 0, 0), + TextAlign = ContentAlignment.MiddleLeft, + AutoEllipsis = true, Font = CreateFont("Segoe UI", 10, FontStyle.Bold), ForeColor = Color.FromArgb(51, 51, 51), + UseMnemonic = false, }; headerPanel.Controls.Add(typeLabel); - var closeButton = new Button + var closeButton = new MouseOnlyButton(IsArmed) { - Text = "×", - Left = 358, - Top = 10, - Width = 24, - Height = 24, + Text = "X", + Dock = DockStyle.Right, + Width = 40, FlatStyle = FlatStyle.Flat, - Font = CreateFont("Segoe UI", 14), + Font = CreateFont("Segoe UI", 11), ForeColor = Color.FromArgb(102, 102, 102), BackColor = Color.Transparent, Cursor = Cursors.Hand, + TabStop = false, + UseMnemonic = false, }; closeButton.FlatAppearance.BorderSize = 0; closeButton.FlatAppearance.MouseOverBackColor = Color.FromArgb(224, 224, 224); closeButton.Click += CloseButton_Click; headerPanel.Controls.Add(closeButton); - - var titleLabel = new Label - { - Text = alert.Title, - Left = 20, - Top = 58, - Width = 360, - Height = 50, - Font = CreateFont("Segoe UI", 11, FontStyle.Bold), - ForeColor = Color.FromArgb(51, 51, 51), - AutoSize = false, - }; - contentPanel.Controls.Add(titleLabel); - - var messageLabel = new Label - { - Text = alert.Message, - Left = 20, - Top = 115, - Width = 360, - Height = 80, - Font = CreateFont("Segoe UI", 9.75f), - ForeColor = Color.FromArgb(85, 85, 85), - AutoSize = false, - }; - contentPanel.Controls.Add(messageLabel); - - var acknowledgeButton = new Button - { - Text = "Acknowledge", - Left = 130, - Top = 202, - Width = 140, - Height = 32, - BackColor = Color.FromArgb(76, 175, 80), - ForeColor = Color.White, - FlatStyle = FlatStyle.Flat, - Font = CreateFont("Segoe UI", 9, FontStyle.Bold), - Cursor = Cursors.Hand, - }; - acknowledgeButton.FlatAppearance.BorderSize = 0; - acknowledgeButton.FlatAppearance.MouseOverBackColor = Color.FromArgb(69, 160, 73); - acknowledgeButton.Click += AcknowledgeButton_Click; - contentPanel.Controls.Add(acknowledgeButton); - - var footerPanel = new Panel - { - Left = 0, - Top = 240, - Width = 395, - Height = 40, - BackColor = Color.White, - }; - contentPanel.Controls.Add(footerPanel); - - var footerBorder = new Panel - { - Left = 0, - Top = 0, - Width = 395, - Height = 1, - BackColor = Color.FromArgb(238, 238, 238), - }; - footerPanel.Controls.Add(footerBorder); - - countdownLabel = new Label - { - Text = "Manual acknowledgment required", - Left = 0, - Top = 12, - Width = 395, - Height = 20, - TextAlign = ContentAlignment.MiddleCenter, - Font = CreateFont("Segoe UI", 8.25f), - ForeColor = Color.FromArgb(153, 153, 153), - }; - footerPanel.Controls.Add(countdownLabel); } - private Color ParseTypeColor(string? hexColor) + private bool IsArmed() => DateTime.UtcNow >= armedAtUtc; + + private static Color ParseTypeColor(string? hexColor) { if (string.IsNullOrWhiteSpace(hexColor)) - return Color.Gray; + return DefaultTypeColor; - var hex = hexColor.TrimStart('#'); - if (hex.Length != 6) - return Color.Gray; + var hex = hexColor.Trim().TrimStart('#'); + if (hex.Length != 6 || !int.TryParse(hex, System.Globalization.NumberStyles.AllowHexSpecifier, + System.Globalization.CultureInfo.InvariantCulture, out var rgb)) + return DefaultTypeColor; - 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; - } + return Color.FromArgb((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF); } - private void AcknowledgeButton_Click(object? sender, EventArgs e) + private void Acknowledge() { + if (acknowledged) + return; + acknowledged = true; + countdownTimer?.Stop(); OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty); DialogResult = DialogResult.OK; Close(); } - private void CloseButton_Click(object? sender, EventArgs e) - { - countdownTimer?.Stop(); - OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty); - DialogResult = DialogResult.OK; - Close(); - } + private void AcknowledgeButton_Click(object? sender, EventArgs e) => Acknowledge(); + + private void CloseButton_Click(object? sender, EventArgs e) => Acknowledge(); protected override void Dispose(bool disposing) { @@ -353,4 +423,37 @@ internal sealed class FleetAlertForm : Form } base.Dispose(disposing); } + + // Raises Click only for a left mouse release on the button once armed. + // Space, Enter, mnemonics and PerformClick all route through OnClick + // without passing OnMouseUp, so they are dropped. + private sealed class MouseOnlyButton : Button + { + private readonly Func isArmed; + private bool inMouseUp; + + public MouseOnlyButton(Func isArmed) + { + this.isArmed = isArmed; + } + + protected override void OnMouseUp(MouseEventArgs mevent) + { + inMouseUp = true; + try + { + base.OnMouseUp(mevent); + } + finally + { + inMouseUp = false; + } + } + + protected override void OnClick(EventArgs e) + { + if (inMouseUp && isArmed()) + base.OnClick(e); + } + } } diff --git a/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs b/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs index 37711abb..5b3aa4c1 100644 --- a/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs +++ b/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs @@ -21,6 +21,9 @@ internal sealed class FleetAlertWorker : IDisposable private readonly CancellationTokenSource cancellation; private readonly Thread workerThread; + // Set and cleared on the UI thread only. + private FleetAlertForm? openForm; + public FleetAlertWorker( FleetAlertClient client, FleetAlertStore store, @@ -55,14 +58,8 @@ internal sealed class FleetAlertWorker : IDisposable private void Run() { var token = cancellation.Token; - try - { - Thread.Sleep(StartupDelay); - } - catch (Exception) - { + if (token.WaitHandle.WaitOne(StartupDelay)) return; - } while (!token.IsCancellationRequested) { @@ -70,7 +67,7 @@ internal sealed class FleetAlertWorker : IDisposable { PollAndDisplayAlerts(token); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (token.IsCancellationRequested) { return; } @@ -79,20 +76,20 @@ internal sealed class FleetAlertWorker : IDisposable logger.LogWarning(exception, "Fleet alert poll failed; retrying on next cycle."); } - try - { - Thread.Sleep(PollInterval); - } - catch (Exception) - { + if (token.WaitHandle.WaitOne(PollInterval)) return; - } } } private void PollAndDisplayAlerts(CancellationToken token) { var alerts = client.FetchAlertsAsync(hostname, token).GetAwaiter().GetResult(); + if (alerts is FleetAlertList { Skipped.Count: > 0 } parsed) + { + logger.LogWarning( + "Skipped {Count} malformed fleet alert(s): {Details}", + parsed.Skipped.Count, string.Join("; ", parsed.Skipped)); + } if (alerts is null || alerts.Count == 0) { logger.LogDebug("No active fleet alerts."); @@ -129,9 +126,20 @@ internal sealed class FleetAlertWorker : IDisposable { try { - using var form = new FleetAlertForm(alert, client, hostname); + if (token.IsCancellationRequested) + return; + + using var form = new FleetAlertForm(alert, client, hostname, logger); form.OnAcknowledgeClicked += (s, e) => OnAlertAcknowledged(alert, token); - var result = form.ShowDialog(); + openForm = form; + try + { + form.ShowDialog(); + } + finally + { + openForm = null; + } } catch (Exception exception) { @@ -180,9 +188,31 @@ internal sealed class FleetAlertWorker : IDisposable }); } + // Closes a displayed alert without acknowledging it. Call on the UI thread + // before disposing the client, so the form's status check stops using it. + public void CloseOpenAlert() + { + var form = openForm; + if (form is null || form.IsDisposed) + return; + + try + { + if (form.InvokeRequired) + form.BeginInvoke(new Action(form.Close)); + else + form.Close(); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Could not close open fleet alert on shutdown."); + } + } + public void Dispose() { cancellation.Cancel(); + CloseOpenAlert(); if (workerThread.IsAlive) { diff --git a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj index 6c60b080..6c48df02 100644 --- a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj +++ b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj @@ -96,7 +96,17 @@ No tray code changes, but deployed together for version consistency. --> - 1.6.10 + + 1.6.11 true win-x64 true