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.
This commit is contained in:
@@ -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<int> acknowledged;
|
||||
private HashSet<int> acknowledged = new();
|
||||
|
||||
public FleetAlertStore(ILogger logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
acknowledged = new HashSet<int>();
|
||||
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<int>();
|
||||
return;
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(StoreFile);
|
||||
var stored = JsonSerializer.Deserialize<StoredData>(json);
|
||||
var stored = ReadFile(StoreFile);
|
||||
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>();
|
||||
}
|
||||
|
||||
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<StoredData>(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<int> AcknowledgedIds { get; set; } = new();
|
||||
public DateTime LastUpdated { get; set; }
|
||||
public bool MigratedLegacy { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Font> 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<bool> isArmed;
|
||||
private bool inMouseUp;
|
||||
|
||||
public MouseOnlyButton(Func<bool> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -96,7 +96,17 @@
|
||||
No tray code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.6.10: coordinated release with agent 1.18.0 (fingerprint computation).
|
||||
No tray code changes, but deployed together for version consistency. -->
|
||||
<Version>1.6.10</Version>
|
||||
<!-- 1.6.11: fleet alert hardening. The toast steals focus, so its buttons are
|
||||
no longer tab stops, there is no Accept/Cancel button, and acknowledge
|
||||
needs a mouse click at least 1.5s after the form is shown: keystrokes
|
||||
meant for another app cannot acknowledge an unseen alert. Long messages
|
||||
scroll instead of being cut off and '&' is shown literally. A null or
|
||||
malformed alert field no longer throws away the whole poll. A timeout
|
||||
closes without acknowledging. The acknowledged store moves to
|
||||
%LOCALAPPDATA%\ShopDB\FleetAlerts (the operator could not write the
|
||||
ProgramData copy, so acks were lost every logon), merging the old file
|
||||
once. Shutdown no longer blocks on the poller's sleeps. -->
|
||||
<Version>1.6.11</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
Reference in New Issue
Block a user