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.
|
// Local JSON file store for tracking acknowledged fleet alerts.
|
||||||
// Stores notification IDs that have been acknowledged so they don't
|
// 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
|
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(
|
private static readonly string DataDir = Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
"ShopDB",
|
"ShopDB",
|
||||||
"FleetAlerts");
|
"FleetAlerts");
|
||||||
|
|
||||||
private static readonly string StoreFile = Path.Combine(DataDir, "acknowledged-alerts.json");
|
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 object fileLock = new();
|
||||||
private readonly ILogger logger;
|
private readonly ILogger logger;
|
||||||
private HashSet<int> acknowledged;
|
private HashSet<int> acknowledged = new();
|
||||||
|
|
||||||
public FleetAlertStore(ILogger logger)
|
public FleetAlertStore(ILogger logger)
|
||||||
{
|
{
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
acknowledged = new HashSet<int>();
|
|
||||||
Load();
|
Load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,11 +51,17 @@ public sealed class FleetAlertStore
|
|||||||
{
|
{
|
||||||
lock (fileLock)
|
lock (fileLock)
|
||||||
{
|
{
|
||||||
if (acknowledged.Add(notificationId))
|
WithFileMutex(() =>
|
||||||
{
|
{
|
||||||
Save();
|
// Merge what another instance may have written since our load.
|
||||||
PruneOld();
|
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)
|
lock (fileLock)
|
||||||
{
|
{
|
||||||
try
|
WithFileMutex(() =>
|
||||||
{
|
{
|
||||||
if (!File.Exists(StoreFile))
|
var stored = ReadFile(StoreFile);
|
||||||
{
|
|
||||||
acknowledged = new HashSet<int>();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var json = File.ReadAllText(StoreFile);
|
|
||||||
var stored = JsonSerializer.Deserialize<StoredData>(json);
|
|
||||||
acknowledged = stored?.AcknowledgedIds?.ToHashSet() ?? new HashSet<int>();
|
acknowledged = stored?.AcknowledgedIds?.ToHashSet() ?? new HashSet<int>();
|
||||||
}
|
|
||||||
catch (Exception ex)
|
if (stored?.MigratedLegacy == true)
|
||||||
{
|
return;
|
||||||
logger.LogWarning(ex, "Could not load acknowledged alerts store; starting fresh");
|
|
||||||
acknowledged = new HashSet<int>();
|
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
|
try
|
||||||
{
|
{
|
||||||
@@ -79,7 +116,8 @@ public sealed class FleetAlertStore
|
|||||||
var data = new StoredData
|
var data = new StoredData
|
||||||
{
|
{
|
||||||
AcknowledgedIds = acknowledged.ToList(),
|
AcknowledgedIds = acknowledged.ToList(),
|
||||||
LastUpdated = DateTime.UtcNow
|
LastUpdated = DateTime.UtcNow,
|
||||||
|
MigratedLegacy = migratedLegacy,
|
||||||
};
|
};
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(data, new JsonSerializerOptions
|
var json = JsonSerializer.Serialize(data, new JsonSerializerOptions
|
||||||
@@ -87,28 +125,66 @@ public sealed class FleetAlertStore
|
|||||||
WriteIndented = true
|
WriteIndented = true
|
||||||
});
|
});
|
||||||
|
|
||||||
File.WriteAllText(StoreFile, json);
|
var temp = StoreFile + ".tmp";
|
||||||
|
File.WriteAllText(temp, json);
|
||||||
|
File.Move(temp, StoreFile, overwrite: true);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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
|
Mutex? mutex = null;
|
||||||
if (acknowledged.Count > 1000)
|
var owned = false;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var toKeep = acknowledged.OrderByDescending(id => id).Take(1000).ToHashSet();
|
mutex = new Mutex(false, MutexName);
|
||||||
acknowledged = toKeep;
|
try
|
||||||
Save();
|
{
|
||||||
|
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
|
private sealed class StoredData
|
||||||
{
|
{
|
||||||
public List<int> AcknowledgedIds { get; set; } = new();
|
public List<int> AcknowledgedIds { get; set; } = new();
|
||||||
public DateTime LastUpdated { get; set; }
|
public DateTime LastUpdated { get; set; }
|
||||||
|
public bool MigratedLegacy { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Drawing.Drawing2D;
|
using System.Drawing.Drawing2D;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using ShopDbEndpointAgent.Core;
|
using ShopDbEndpointAgent.Core;
|
||||||
|
|
||||||
namespace ShopDbEndpointAgent.Tray;
|
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
|
// 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
|
// 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
|
// 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
|
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 FleetAlert alert;
|
||||||
private readonly FleetAlertClient? client;
|
private readonly FleetAlertClient? client;
|
||||||
private readonly string? hostname;
|
private readonly string? hostname;
|
||||||
|
private readonly ILogger? logger;
|
||||||
private System.Windows.Forms.Timer? countdownTimer;
|
private System.Windows.Forms.Timer? countdownTimer;
|
||||||
private System.Windows.Forms.Timer? statusCheckTimer;
|
private System.Windows.Forms.Timer? statusCheckTimer;
|
||||||
private readonly CancellationTokenSource statusCheckCancellation = new();
|
private readonly CancellationTokenSource statusCheckCancellation = new();
|
||||||
private int remainingSeconds;
|
private int remainingSeconds;
|
||||||
private Label? countdownLabel;
|
private Label? countdownLabel;
|
||||||
|
private TextBox? messageBox;
|
||||||
private readonly List<Font> fonts = new();
|
private readonly List<Font> fonts = new();
|
||||||
|
private DateTime armedAtUtc = DateTime.MaxValue;
|
||||||
|
private bool acknowledged;
|
||||||
|
|
||||||
public event EventHandler? OnAcknowledgeClicked;
|
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.alert = alert ?? throw new ArgumentNullException(nameof(alert));
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.hostname = hostname;
|
this.hostname = hostname;
|
||||||
|
this.logger = logger;
|
||||||
|
|
||||||
|
SuspendLayout();
|
||||||
|
AutoScaleDimensions = new SizeF(96F, 96F);
|
||||||
|
AutoScaleMode = AutoScaleMode.Dpi;
|
||||||
Text = string.Empty;
|
Text = string.Empty;
|
||||||
FormBorderStyle = FormBorderStyle.None;
|
FormBorderStyle = FormBorderStyle.None;
|
||||||
TopMost = true;
|
TopMost = true;
|
||||||
ShowInTaskbar = false;
|
ShowInTaskbar = false;
|
||||||
StartPosition = FormStartPosition.Manual;
|
StartPosition = FormStartPosition.Manual;
|
||||||
ClientSize = new Size(400, 280);
|
ClientSize = new Size(400, 320);
|
||||||
BackColor = Color.White;
|
BackColor = Color.White;
|
||||||
|
KeyPreview = false;
|
||||||
var screen = Screen.PrimaryScreen?.WorkingArea ?? Screen.PrimaryScreen?.Bounds ?? new Rectangle(0, 0, 1920, 1080);
|
|
||||||
Location = new Point(screen.Right - Width - 20, screen.Top + 20);
|
|
||||||
|
|
||||||
BuildLayout();
|
BuildLayout();
|
||||||
ApplyRoundedCorners();
|
ResumeLayout(false);
|
||||||
|
|
||||||
Load += FleetAlertForm_Load;
|
Load += FleetAlertForm_Load;
|
||||||
|
Shown += FleetAlertForm_Shown;
|
||||||
|
Resize += (s, e) => ApplyRoundedCorners();
|
||||||
FormClosing += FleetAlertForm_FormClosing;
|
FormClosing += FleetAlertForm_FormClosing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +77,11 @@ internal sealed class FleetAlertForm : Form
|
|||||||
|
|
||||||
private void FleetAlertForm_Load(object? sender, EventArgs e)
|
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();
|
System.Media.SystemSounds.Beep.Play();
|
||||||
|
|
||||||
if (client != null && hostname != null)
|
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()
|
private void StartStatusCheck()
|
||||||
{
|
{
|
||||||
statusCheckTimer = new System.Windows.Forms.Timer { Interval = 30000 };
|
statusCheckTimer = new System.Windows.Forms.Timer { Interval = 30000 };
|
||||||
@@ -76,15 +112,19 @@ internal sealed class FleetAlertForm : Form
|
|||||||
if (client == null || hostname == null)
|
if (client == null || hostname == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
var token = statusCheckCancellation.Token;
|
||||||
|
if (token.IsCancellationRequested)
|
||||||
|
return;
|
||||||
|
|
||||||
Task.Run(async () =>
|
Task.Run(async () =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var alerts = await client.FetchAlertsAsync(hostname, statusCheckCancellation.Token)
|
var alerts = await client.FetchAlertsAsync(hostname, token)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
var stillActive = alerts?.Any(a => a.NotificationId == alert.NotificationId) ?? false;
|
var stillActive = alerts?.Any(a => a.NotificationId == alert.NotificationId) ?? false;
|
||||||
|
|
||||||
if (!stillActive)
|
if (!stillActive && !token.IsCancellationRequested && IsHandleCreated && !IsDisposed)
|
||||||
{
|
{
|
||||||
BeginInvoke(new Action(() =>
|
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()
|
private void ApplyRoundedCorners()
|
||||||
{
|
{
|
||||||
using var path = new GraphicsPath();
|
using var path = new GraphicsPath();
|
||||||
int radius = 8;
|
int radius = LogicalToDeviceUnits(8);
|
||||||
var rect = new Rectangle(0, 0, Width, Height);
|
var rect = new Rectangle(0, 0, Width, Height);
|
||||||
|
|
||||||
path.AddArc(rect.X, rect.Y, radius, radius, 180, 90);
|
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.AddArc(rect.X, rect.Bottom - radius, radius, radius, 90, 90);
|
||||||
path.CloseFigure();
|
path.CloseFigure();
|
||||||
|
|
||||||
|
var old = Region;
|
||||||
Region = new Region(path);
|
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()
|
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 = new System.Windows.Forms.Timer { Interval = 1000 };
|
||||||
countdownTimer.Tick += CountdownTimer_Tick;
|
countdownTimer.Tick += CountdownTimer_Tick;
|
||||||
@@ -138,7 +188,6 @@ internal sealed class FleetAlertForm : Form
|
|||||||
if (remainingSeconds <= 0)
|
if (remainingSeconds <= 0)
|
||||||
{
|
{
|
||||||
countdownTimer?.Stop();
|
countdownTimer?.Stop();
|
||||||
OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty);
|
|
||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,7 +198,7 @@ internal sealed class FleetAlertForm : Form
|
|||||||
{
|
{
|
||||||
int minutes = remainingSeconds / 60;
|
int minutes = remainingSeconds / 60;
|
||||||
int seconds = 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;
|
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()
|
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
|
var borderPanel = new Panel
|
||||||
{
|
{
|
||||||
BackColor = ParseTypeColor(alert.TypeColor),
|
BackColor = ParseTypeColor(alert.TypeColor),
|
||||||
@@ -170,21 +240,103 @@ internal sealed class FleetAlertForm : Form
|
|||||||
};
|
};
|
||||||
Controls.Add(borderPanel);
|
Controls.Add(borderPanel);
|
||||||
|
|
||||||
var contentPanel = new Panel
|
var messagePanel = new Panel
|
||||||
{
|
{
|
||||||
Left = 5,
|
Dock = DockStyle.Fill,
|
||||||
Top = 0,
|
Padding = new Padding(20, 4, 12, 4),
|
||||||
Width = 395,
|
|
||||||
Height = 280,
|
|
||||||
BackColor = Color.White,
|
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
|
var headerPanel = new Panel
|
||||||
{
|
{
|
||||||
Left = 0,
|
Dock = DockStyle.Top,
|
||||||
Top = 0,
|
|
||||||
Width = 395,
|
|
||||||
Height = 45,
|
Height = 45,
|
||||||
BackColor = Color.FromArgb(245, 245, 245),
|
BackColor = Color.FromArgb(245, 245, 245),
|
||||||
};
|
};
|
||||||
@@ -192,148 +344,66 @@ internal sealed class FleetAlertForm : Form
|
|||||||
|
|
||||||
var typeLabel = new Label
|
var typeLabel = new Label
|
||||||
{
|
{
|
||||||
Text = alert.TypeName.ToUpper(),
|
Text = SafeText(alert.TypeName, DefaultTypeName).ToUpperInvariant(),
|
||||||
Left = 16,
|
Dock = DockStyle.Fill,
|
||||||
Top = 14,
|
Padding = new Padding(12, 0, 0, 0),
|
||||||
Width = 300,
|
TextAlign = ContentAlignment.MiddleLeft,
|
||||||
|
AutoEllipsis = true,
|
||||||
Font = CreateFont("Segoe UI", 10, FontStyle.Bold),
|
Font = CreateFont("Segoe UI", 10, FontStyle.Bold),
|
||||||
ForeColor = Color.FromArgb(51, 51, 51),
|
ForeColor = Color.FromArgb(51, 51, 51),
|
||||||
|
UseMnemonic = false,
|
||||||
};
|
};
|
||||||
headerPanel.Controls.Add(typeLabel);
|
headerPanel.Controls.Add(typeLabel);
|
||||||
|
|
||||||
var closeButton = new Button
|
var closeButton = new MouseOnlyButton(IsArmed)
|
||||||
{
|
{
|
||||||
Text = "×",
|
Text = "X",
|
||||||
Left = 358,
|
Dock = DockStyle.Right,
|
||||||
Top = 10,
|
Width = 40,
|
||||||
Width = 24,
|
|
||||||
Height = 24,
|
|
||||||
FlatStyle = FlatStyle.Flat,
|
FlatStyle = FlatStyle.Flat,
|
||||||
Font = CreateFont("Segoe UI", 14),
|
Font = CreateFont("Segoe UI", 11),
|
||||||
ForeColor = Color.FromArgb(102, 102, 102),
|
ForeColor = Color.FromArgb(102, 102, 102),
|
||||||
BackColor = Color.Transparent,
|
BackColor = Color.Transparent,
|
||||||
Cursor = Cursors.Hand,
|
Cursor = Cursors.Hand,
|
||||||
|
TabStop = false,
|
||||||
|
UseMnemonic = false,
|
||||||
};
|
};
|
||||||
closeButton.FlatAppearance.BorderSize = 0;
|
closeButton.FlatAppearance.BorderSize = 0;
|
||||||
closeButton.FlatAppearance.MouseOverBackColor = Color.FromArgb(224, 224, 224);
|
closeButton.FlatAppearance.MouseOverBackColor = Color.FromArgb(224, 224, 224);
|
||||||
closeButton.Click += CloseButton_Click;
|
closeButton.Click += CloseButton_Click;
|
||||||
headerPanel.Controls.Add(closeButton);
|
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))
|
if (string.IsNullOrWhiteSpace(hexColor))
|
||||||
return Color.Gray;
|
return DefaultTypeColor;
|
||||||
|
|
||||||
var hex = hexColor.TrimStart('#');
|
var hex = hexColor.Trim().TrimStart('#');
|
||||||
if (hex.Length != 6)
|
if (hex.Length != 6 || !int.TryParse(hex, System.Globalization.NumberStyles.AllowHexSpecifier,
|
||||||
return Color.Gray;
|
System.Globalization.CultureInfo.InvariantCulture, out var rgb))
|
||||||
|
return DefaultTypeColor;
|
||||||
|
|
||||||
try
|
return Color.FromArgb((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF);
|
||||||
{
|
|
||||||
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)
|
private void Acknowledge()
|
||||||
{
|
{
|
||||||
|
if (acknowledged)
|
||||||
|
return;
|
||||||
|
acknowledged = true;
|
||||||
|
|
||||||
countdownTimer?.Stop();
|
countdownTimer?.Stop();
|
||||||
OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty);
|
OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty);
|
||||||
DialogResult = DialogResult.OK;
|
DialogResult = DialogResult.OK;
|
||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CloseButton_Click(object? sender, EventArgs e)
|
private void AcknowledgeButton_Click(object? sender, EventArgs e) => Acknowledge();
|
||||||
{
|
|
||||||
countdownTimer?.Stop();
|
private void CloseButton_Click(object? sender, EventArgs e) => Acknowledge();
|
||||||
OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty);
|
|
||||||
DialogResult = DialogResult.OK;
|
|
||||||
Close();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
@@ -353,4 +423,37 @@ internal sealed class FleetAlertForm : Form
|
|||||||
}
|
}
|
||||||
base.Dispose(disposing);
|
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 CancellationTokenSource cancellation;
|
||||||
private readonly Thread workerThread;
|
private readonly Thread workerThread;
|
||||||
|
|
||||||
|
// Set and cleared on the UI thread only.
|
||||||
|
private FleetAlertForm? openForm;
|
||||||
|
|
||||||
public FleetAlertWorker(
|
public FleetAlertWorker(
|
||||||
FleetAlertClient client,
|
FleetAlertClient client,
|
||||||
FleetAlertStore store,
|
FleetAlertStore store,
|
||||||
@@ -55,14 +58,8 @@ internal sealed class FleetAlertWorker : IDisposable
|
|||||||
private void Run()
|
private void Run()
|
||||||
{
|
{
|
||||||
var token = cancellation.Token;
|
var token = cancellation.Token;
|
||||||
try
|
if (token.WaitHandle.WaitOne(StartupDelay))
|
||||||
{
|
|
||||||
Thread.Sleep(StartupDelay);
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
while (!token.IsCancellationRequested)
|
while (!token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -70,7 +67,7 @@ internal sealed class FleetAlertWorker : IDisposable
|
|||||||
{
|
{
|
||||||
PollAndDisplayAlerts(token);
|
PollAndDisplayAlerts(token);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -79,20 +76,20 @@ internal sealed class FleetAlertWorker : IDisposable
|
|||||||
logger.LogWarning(exception, "Fleet alert poll failed; retrying on next cycle.");
|
logger.LogWarning(exception, "Fleet alert poll failed; retrying on next cycle.");
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
if (token.WaitHandle.WaitOne(PollInterval))
|
||||||
{
|
|
||||||
Thread.Sleep(PollInterval);
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PollAndDisplayAlerts(CancellationToken token)
|
private void PollAndDisplayAlerts(CancellationToken token)
|
||||||
{
|
{
|
||||||
var alerts = client.FetchAlertsAsync(hostname, token).GetAwaiter().GetResult();
|
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)
|
if (alerts is null || alerts.Count == 0)
|
||||||
{
|
{
|
||||||
logger.LogDebug("No active fleet alerts.");
|
logger.LogDebug("No active fleet alerts.");
|
||||||
@@ -129,9 +126,20 @@ internal sealed class FleetAlertWorker : IDisposable
|
|||||||
{
|
{
|
||||||
try
|
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);
|
form.OnAcknowledgeClicked += (s, e) => OnAlertAcknowledged(alert, token);
|
||||||
var result = form.ShowDialog();
|
openForm = form;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
form.ShowDialog();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
openForm = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
cancellation.Cancel();
|
cancellation.Cancel();
|
||||||
|
CloseOpenAlert();
|
||||||
|
|
||||||
if (workerThread.IsAlive)
|
if (workerThread.IsAlive)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -96,7 +96,17 @@
|
|||||||
No tray code changes, but deployed together for version consistency. -->
|
No tray code changes, but deployed together for version consistency. -->
|
||||||
<!-- 1.6.10: coordinated release with agent 1.18.0 (fingerprint computation).
|
<!-- 1.6.10: coordinated release with agent 1.18.0 (fingerprint computation).
|
||||||
No tray code changes, but deployed together for version consistency. -->
|
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>
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
<SelfContained>true</SelfContained>
|
<SelfContained>true</SelfContained>
|
||||||
|
|||||||
Reference in New Issue
Block a user