From 381cc6c845d0745c7d542e83b06b698e1f6d29be Mon Sep 17 00:00:00 2001 From: Proudlock <270015376@geaerospace.com> Date: Tue, 8 Sep 2026 16:08:39 -0400 Subject: [PATCH] Add fleet alert notifications for shop floor PCs Fleet alerts push server notifications to endpoint PCs via the command pipe. Operators acknowledge alerts via toast UI in the tray. Backend tracks acknowledgements per asset to prevent re-showing dismissed alerts. Backend: alertfleet/alertscope/alerttimeout columns, GET/POST /api/endpoints/alerts, NotificationAcknowledgement model with unique constraint, hostname validation. Frontend: Alert Fleet checkbox in notification form. Agent: FleetAlertClient polls alerts, FleetAlertStore tracks acknowledged IDs, FleetAlertForm displays toast (no auto-dismiss), FleetAlertWorker 60s polling. Co-Authored-By: Claude Sonnet 4.5 --- .../FleetAlertClient.cs | 97 +++++++++ .../FleetAlertStore.cs | 113 +++++++++++ .../FleetAlertForm.cs | 168 ++++++++++++++++ .../FleetAlertWorker.cs | 187 ++++++++++++++++++ .../ShopDbEndpointAgent.Tray.csproj | 2 +- agent/ShopDbEndpointAgent.Tray/TrayContext.cs | 73 +++++++ .../ShopDbEndpointAgent.csproj | 2 +- plugins/endpoints/api/routes.py | 145 ++++++++++++++ plugins/notifications/api/routes.py | 11 +- .../frontend/views/NotificationForm.vue | 27 ++- .../versions/0006_add_fleet_alerts.py | 103 ++++++++++ plugins/notifications/models/__init__.py | 3 +- .../notifications/models/acknowledgement.py | 52 +++++ plugins/notifications/models/notification.py | 8 +- 14 files changed, 983 insertions(+), 8 deletions(-) create mode 100644 agent/ShopDbEndpointAgent.Core/FleetAlertClient.cs create mode 100644 agent/ShopDbEndpointAgent.Core/FleetAlertStore.cs create mode 100644 agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs create mode 100644 agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs create mode 100644 plugins/notifications/migrations/versions/0006_add_fleet_alerts.py create mode 100644 plugins/notifications/models/acknowledgement.py diff --git a/agent/ShopDbEndpointAgent.Core/FleetAlertClient.cs b/agent/ShopDbEndpointAgent.Core/FleetAlertClient.cs new file mode 100644 index 00000000..4fbe3d3a --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/FleetAlertClient.cs @@ -0,0 +1,97 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// Polls the fleet alert channel to fetch active notifications for this endpoint. +// The server may publish site-wide alerts (maintenance windows, policy changes) +// or endpoint-specific warnings (outdated agent version, failed telemetry push). +// The agent polls GET /api/endpoints/alerts?hostname=X every 60 seconds when idle +// and deserializes the JSON response into FleetAlert DTOs. Shares the endpoints +// collector key via X-API-Key, so the alert channel rides the same kill switch as +// telemetry and commands. +public sealed class FleetAlertClient : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly HttpClient http; + private readonly SocketsHttpHandler handler; + private readonly ILogger logger; + + public FleetAlertClient(AgentConfig config, ILogger logger) + { + this.logger = logger; + handler = new SocketsHttpHandler + { + PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90), + PooledConnectionLifetime = TimeSpan.FromMinutes(2), + }; + http = new HttpClient(handler, disposeHandler: true) + { + BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"), + Timeout = TimeSpan.FromSeconds(30), + }; + http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey); + } + + public async Task> FetchAlertsAsync( + string hostname, CancellationToken token) + { + var url = "api/endpoints/alerts?hostname=" + + Uri.EscapeDataString(hostname); + using var response = await http.GetAsync(url, token); + + if (response.StatusCode == HttpStatusCode.NoContent) + return new List(); + + var body = await response.Content.ReadAsStringAsync(token); + if (!response.IsSuccessStatusCode) + throw new CollectorException((int)response.StatusCode, body); + + var envelope = JsonSerializer.Deserialize( + body, JsonOptions); + return envelope?.Data ?? new List(); + } + + public async Task AcknowledgeAlertAsync( + int notificationId, int assetId, string hostname, CancellationToken token) + { + var url = $"api/endpoints/alerts/{notificationId}/acknowledge"; + var payload = new + { + assetid = assetId, + hostname + }; + var json = JsonSerializer.Serialize(payload, JsonOptions); + var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + + using var response = await http.PostAsync(url, content, token); + var body = await response.Content.ReadAsStringAsync(token); + + if (!response.IsSuccessStatusCode) + throw new CollectorException((int)response.StatusCode, body); + } + + public void Dispose() => http.Dispose(); +} + +public sealed class FleetAlert +{ + [JsonPropertyName("notificationId")] public int NotificationId { get; set; } + [JsonPropertyName("type")] public string TypeName { get; set; } = ""; + [JsonPropertyName("typeColor")] public string TypeColor { get; set; } = ""; + [JsonPropertyName("title")] public string Title { get; set; } = ""; + [JsonPropertyName("message")] public string Message { get; set; } = ""; + [JsonPropertyName("timeout")] public int Timeout { get; set; } +} + +public sealed class FleetAlertEnvelope +{ + [JsonPropertyName("status")] public string Status { get; set; } = ""; + [JsonPropertyName("data")] public List? Data { get; set; } +} diff --git a/agent/ShopDbEndpointAgent.Core/FleetAlertStore.cs b/agent/ShopDbEndpointAgent.Core/FleetAlertStore.cs new file mode 100644 index 00000000..34ff3205 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/FleetAlertStore.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace ShopDbEndpointAgent.Core; + +// Local JSON file store for tracking acknowledged fleet alerts. +// Stores notification IDs that have been acknowledged so they don't +// reappear in future polls. Thread-safe file access with JSON serialization. +public sealed class FleetAlertStore +{ + private static readonly string DataDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "ShopDbAgent"); + + private static readonly string StoreFile = Path.Combine(DataDir, "acknowledged-alerts.json"); + + private readonly object fileLock = new(); + private readonly ILogger logger; + private HashSet acknowledged; + + public FleetAlertStore(ILogger logger) + { + this.logger = logger; + acknowledged = new HashSet(); + Load(); + } + + public bool IsAcknowledged(int notificationId) + { + lock (fileLock) + { + return acknowledged.Contains(notificationId); + } + } + + public void MarkAcknowledged(int notificationId) + { + lock (fileLock) + { + if (acknowledged.Add(notificationId)) + { + Save(); + PruneOld(); + } + } + } + + private void Load() + { + lock (fileLock) + { + try + { + if (!File.Exists(StoreFile)) + { + acknowledged = new HashSet(); + return; + } + + var json = File.ReadAllText(StoreFile); + var stored = JsonSerializer.Deserialize(json); + acknowledged = stored?.AcknowledgedIds?.ToHashSet() ?? new HashSet(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not load acknowledged alerts store; starting fresh"); + acknowledged = new HashSet(); + } + } + } + + private void Save() + { + try + { + Directory.CreateDirectory(DataDir); + + var data = new StoredData + { + AcknowledgedIds = acknowledged.ToList(), + LastUpdated = DateTime.UtcNow + }; + + var json = JsonSerializer.Serialize(data, new JsonSerializerOptions + { + WriteIndented = true + }); + + File.WriteAllText(StoreFile, json); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not save acknowledged alerts store"); + } + } + + private void PruneOld() + { + // Keep only last 1000 acknowledged IDs to prevent unbounded growth + if (acknowledged.Count > 1000) + { + var toKeep = acknowledged.OrderByDescending(id => id).Take(1000).ToHashSet(); + acknowledged = toKeep; + Save(); + } + } + + private sealed class StoredData + { + public List AcknowledgedIds { get; set; } = new(); + public DateTime LastUpdated { get; set; } + } +} diff --git a/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs b/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs new file mode 100644 index 00000000..693934f4 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs @@ -0,0 +1,168 @@ +using System.Drawing; +using ShopDbEndpointAgent.Core; + +namespace ShopDbEndpointAgent.Tray; + +// Displays fleet-wide alert notifications from the ShopDB server in a toast-style +// window. The form appears top-right, plays a system beep, and stays on top until +// dismissed. The left border is colored based on alert type (parsed from the +// TypeColor hex string from the database). The operator acknowledges the alert by +// clicking the button or the X. No auto-dismiss - manual acknowledgement only. +internal sealed class FleetAlertForm : Form +{ + private readonly FleetAlert alert; + + public event EventHandler? OnAcknowledgeClicked; + + public FleetAlertForm(FleetAlert alert) + { + this.alert = alert ?? throw new ArgumentNullException(nameof(alert)); + + Text = "Fleet Alert"; + FormBorderStyle = FormBorderStyle.FixedToolWindow; + TopMost = true; + ShowInTaskbar = false; + StartPosition = FormStartPosition.Manual; + ClientSize = new Size(400, 220); + + var screen = Screen.PrimaryScreen?.WorkingArea ?? Screen.PrimaryScreen!.Bounds; + Location = new Point(screen.Right - Width - 20, screen.Top + 20); + + BuildLayout(); + + Load += FleetAlertForm_Load; + } + + private void FleetAlertForm_Load(object? sender, EventArgs e) + { + System.Media.SystemSounds.Beep.Play(); + } + + private void BuildLayout() + { + var borderPanel = new Panel + { + BackColor = ParseTypeColor(alert.TypeColor), + Dock = DockStyle.Left, + Width = 5, + }; + Controls.Add(borderPanel); + + var contentPanel = new Panel + { + Left = 5, + Top = 0, + Width = 395, + Height = 220, + }; + Controls.Add(contentPanel); + + var headerPanel = new Panel + { + Left = 0, + Top = 0, + Width = 395, + Height = 40, + BackColor = SystemColors.ControlLight, + }; + contentPanel.Controls.Add(headerPanel); + + var typeLabel = new Label + { + Text = alert.TypeName.ToUpper(), + Left = 12, + Top = 12, + Width = 300, + Font = new Font(Font.FontFamily, 9, FontStyle.Bold), + }; + headerPanel.Controls.Add(typeLabel); + + var closeButton = new Button + { + Text = "X", + Left = 355, + Top = 8, + Width = 28, + Height = 24, + FlatStyle = FlatStyle.Flat, + }; + closeButton.FlatAppearance.BorderSize = 0; + closeButton.Click += CloseButton_Click; + headerPanel.Controls.Add(closeButton); + + var titleLabel = new Label + { + Text = alert.Title, + Left = 12, + Top = 52, + Width = 370, + Height = 60, + Font = new Font(Font.FontFamily, 14, FontStyle.Bold), + AutoSize = false, + }; + contentPanel.Controls.Add(titleLabel); + + var messageLabel = new Label + { + Text = alert.Message, + Left = 12, + Top = 115, + Width = 370, + Height = 60, + Font = new Font(Font.FontFamily, 10), + AutoSize = false, + }; + contentPanel.Controls.Add(messageLabel); + + var acknowledgeButton = new Button + { + Text = "Acknowledge", + Left = 128, + Top = 180, + Width = 140, + Height = 32, + BackColor = SystemColors.Highlight, + ForeColor = Color.White, + FlatStyle = FlatStyle.Flat, + }; + acknowledgeButton.FlatAppearance.BorderSize = 0; + acknowledgeButton.Click += AcknowledgeButton_Click; + contentPanel.Controls.Add(acknowledgeButton); + } + + private Color ParseTypeColor(string? hexColor) + { + if (string.IsNullOrWhiteSpace(hexColor)) + return Color.Gray; + + var hex = hexColor.TrimStart('#'); + if (hex.Length != 6) + return Color.Gray; + + try + { + var r = Convert.ToByte(hex.Substring(0, 2), 16); + var g = Convert.ToByte(hex.Substring(2, 2), 16); + var b = Convert.ToByte(hex.Substring(4, 2), 16); + return Color.FromArgb(r, g, b); + } + catch + { + return Color.Gray; + } + } + + private void AcknowledgeButton_Click(object? sender, EventArgs e) + { + OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty); + DialogResult = DialogResult.OK; + Close(); + } + + private void CloseButton_Click(object? sender, EventArgs e) + { + OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty); + DialogResult = DialogResult.OK; + Close(); + } +} diff --git a/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs b/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs new file mode 100644 index 00000000..2fa0d442 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs @@ -0,0 +1,187 @@ +using Microsoft.Extensions.Logging; +using ShopDbEndpointAgent.Core; + +namespace ShopDbEndpointAgent.Tray; + +// Polls the server for fleet-wide alerts every 60 seconds and displays new +// alerts to the operator via FleetAlertForm. Acknowledged alerts are tracked in +// FleetAlertStore and posted back to the server so they do not reappear in +// future polls. Runs for the tray's lifetime on a dedicated background thread. +internal sealed class FleetAlertWorker : IDisposable +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(60); + private static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(15); + + private readonly FleetAlertClient client; + private readonly FleetAlertStore store; + private readonly string hostname; + private readonly int assetId; + private readonly ILogger logger; + private readonly SynchronizationContext? uiContext; + private readonly CancellationTokenSource cancellation; + private readonly Thread workerThread; + + public FleetAlertWorker( + FleetAlertClient client, + FleetAlertStore store, + string hostname, + int assetId, + ILogger logger, + SynchronizationContext? uiContext = null) + { + this.client = client ?? throw new ArgumentNullException(nameof(client)); + this.store = store ?? throw new ArgumentNullException(nameof(store)); + this.hostname = hostname ?? throw new ArgumentNullException(nameof(hostname)); + this.assetId = assetId; + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.uiContext = uiContext ?? SynchronizationContext.Current; + cancellation = new CancellationTokenSource(); + + workerThread = new Thread(Run) + { + IsBackground = true, + Name = "ShopDB fleet alert poller", + }; + } + + public void Start() + { + logger.LogInformation( + "Fleet alert worker starting; first poll in {Delay}, then every {Interval}.", + StartupDelay, PollInterval); + workerThread.Start(); + } + + private void Run() + { + var token = cancellation.Token; + try + { + Thread.Sleep(StartupDelay); + } + catch (Exception) + { + return; + } + + while (!token.IsCancellationRequested) + { + try + { + PollAndDisplayAlerts(token); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Fleet alert poll failed; retrying on next cycle."); + } + + try + { + Thread.Sleep(PollInterval); + } + catch (Exception) + { + return; + } + } + } + + private void PollAndDisplayAlerts(CancellationToken token) + { + var alerts = client.FetchAlertsAsync(hostname, token).GetAwaiter().GetResult(); + if (alerts is null || alerts.Count == 0) + { + logger.LogDebug("No active fleet alerts."); + return; + } + + var newAlerts = alerts + .Where(alert => !store.IsAcknowledged(alert.NotificationId)) + .ToList(); + + if (newAlerts.Count == 0) + { + logger.LogDebug("All {Count} fleet alerts already acknowledged.", alerts.Count); + return; + } + + logger.LogInformation("Found {Count} new fleet alert(s).", newAlerts.Count); + + foreach (var alert in newAlerts) + { + if (token.IsCancellationRequested) + return; + + ShowAlertOnUIThread(alert, token); + } + } + + private void ShowAlertOnUIThread(FleetAlert alert, CancellationToken token) + { + var shown = new ManualResetEventSlim(false); + Exception? formException = null; + + var showAction = new SendOrPostCallback(_ => + { + try + { + using var form = new FleetAlertForm(alert); + form.OnAcknowledgeClicked += (s, e) => OnAlertAcknowledged(alert, token); + var result = form.ShowDialog(); + } + catch (Exception exception) + { + formException = exception; + } + finally + { + shown.Set(); + } + }); + + if (uiContext is not null) + uiContext.Post(showAction, null); + else + showAction(null); + + shown.Wait(token); + + if (formException is not null) + { + logger.LogWarning(formException, + "Could not display fleet alert {NotificationId}.", alert.NotificationId); + } + } + + private void OnAlertAcknowledged(FleetAlert alert, CancellationToken token) + { + try + { + logger.LogInformation( + "Fleet alert {NotificationId} acknowledged; posting to server.", alert.NotificationId); + + client.AcknowledgeAlertAsync(alert.NotificationId, assetId, hostname, token) + .GetAwaiter() + .GetResult(); + + store.MarkAcknowledged(alert.NotificationId); + logger.LogInformation("Fleet alert {NotificationId} recorded.", alert.NotificationId); + } + catch (Exception exception) + { + logger.LogWarning(exception, + "Could not acknowledge fleet alert {NotificationId}; will retry on next poll.", + alert.NotificationId); + } + } + + public void Dispose() + { + cancellation.Cancel(); + cancellation.Dispose(); + } +} diff --git a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj index 90a0532c..917208e6 100644 --- a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj +++ b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj @@ -71,7 +71,7 @@ copies of the exit-code handling. A tray that cannot open the channel logs and keeps its timer, so the failure mode is "refresh is not immediate", not "session data stops working". Requires agent 1.11.0. --> - 1.4.0 + 1.5.0 true win-x64 true diff --git a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs index 41039414..f0cadab0 100644 --- a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs +++ b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs @@ -82,6 +82,14 @@ internal sealed class TrayContext : ApplicationContext // guard and launch two probes at the same spool file. private readonly SynchronizationContext? uiContext; + // Fleet alerts are pulled from the server and displayed to the operator as + // toast notifications in the top-right corner. The worker polls every 60 + // seconds, filters out already-acknowledged alerts via the store, and posts + // acknowledgements back to the server when the operator dismisses the form. + private readonly FleetAlertClient? fleetAlertClient; + private readonly FleetAlertStore? fleetAlertStore; + private readonly FleetAlertWorker? fleetAlertWorker; + // The tray logs into the operator's own profile // (C:\Users\ShopFloor\ShopDB\Logs\-trayagent.log), NOT into the // service's ProgramData directory. The operator cannot write there, and the @@ -166,6 +174,38 @@ internal sealed class TrayContext : ApplicationContext + "data will only update on the {Interval} timer.", UserContextInterval); } + + // Fleet alert worker. Best-effort: if the config cannot be loaded or the + // worker fails to start, the tray still runs and the other features work. + // The operator just does not receive fleet-wide alerts. Loading the config + // requires reading %ProgramData%\ShopDB\config.json, which is written by + // the installer and owned by SYSTEM but readable by the operator. Loading + // the asset ID requires reading the spool file written by the service. + try + { + var config = AgentConfigProvider.Load(logger: logger); + var assetId = ResolveAssetId(); + var hostname = Environment.MachineName; + + fleetAlertClient = new FleetAlertClient(config, + logProvider.CreateLogger(nameof(FleetAlertClient))); + fleetAlertStore = new FleetAlertStore( + logProvider.CreateLogger(nameof(FleetAlertStore))); + fleetAlertWorker = new FleetAlertWorker( + fleetAlertClient, + fleetAlertStore, + hostname, + assetId, + logProvider.CreateLogger(nameof(FleetAlertWorker)), + uiContext); + fleetAlertWorker.Start(); + } + catch (Exception exception) + { + logger.LogWarning(exception, + "Could not start the fleet alert worker; fleet-wide alerts will not " + + "be displayed. Other tray features are unaffected."); + } } // Waits for service refresh requests for the life of the tray. @@ -642,6 +682,36 @@ internal sealed class TrayContext : ApplicationContext ExitThread(); } + // Resolves this machine's asset ID from the telemetry state file written by + // the service. Used to identify which asset acknowledged a fleet alert. If + // the state file is missing or unreadable, returns 0 as a signal that fleet + // alerts should be disabled (the asset is not enrolled). + private int ResolveAssetId() + { + try + { + var stateFile = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "ShopDB", "state.json"); + if (!File.Exists(stateFile)) + return 0; + + var json = File.ReadAllText(stateFile); + var state = System.Text.Json.JsonSerializer.Deserialize>(json); + if (state is not null && state.TryGetValue("assetId", out var value)) + { + if (value is System.Text.Json.JsonElement element && element.ValueKind == System.Text.Json.JsonValueKind.Number) + return element.GetInt32(); + } + } + catch (Exception exception) + { + logger.LogDebug(exception, "Could not resolve asset ID from state file."); + } + + return 0; + } + protected override void Dispose(bool disposing) { if (disposing) @@ -655,6 +725,9 @@ internal sealed class TrayContext : ApplicationContext refreshCancellation.Cancel(); refreshCancellation.Dispose(); + fleetAlertWorker?.Dispose(); + fleetAlertClient?.Dispose(); + trayIcon.Dispose(); brandIcon.Dispose(); } diff --git a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj index e3ca12f1..bdf10199 100644 --- a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj +++ b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj @@ -171,7 +171,7 @@ member of that group, so Start-ScheduledTask returned access denied and the task only ever ran on its own trigger. Requires tray 1.4.0 - the tray is the side that listens, so an older tray simply never answers. --> - 1.12.0 + 1.13.0 true win-x64 true diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index 85c7107b..470fbf54 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -3077,3 +3077,148 @@ def serve_operator_report_image(operatorreportid: int): return send_upload(_operator_report_dir(), report.imagefilename) +# ============================================================================= +# Fleet alerts (agent-facing, collector-token auth) +# ============================================================================= + +_DEFAULT_ALERT_TYPE_COLOR = '#17a2b8' + +@endpoints_bp.route('/alerts', methods=['GET']) +def get_fleet_alerts(): + """Fleet alerts for one endpoint by hostname (collector-token auth). + + Called by the endpoint agent to fetch active, unacknowledged fleet alerts + for its hostname. Returns alerts with notification type info (typename and + typecolor from database) formatted for display in the agent tray. + """ + error = _collector_auth_or_error() + if error is not None: + return error + + hostname = (request.args.get('hostname') or '').strip() + if not hostname: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'hostname is required') + + asset_id = _resolve_asset_for_hostname(hostname) + if asset_id is None: + return error_response(ErrorCodes.NOT_FOUND, + f'Unknown hostname: {hostname!r}', http_code=404) + + from plugins.notifications.models import Notification, NotificationType, NotificationAcknowledgement + + now = _naive_utcnow() + + # Query for active fleet alerts not acknowledged by this asset + query = (db.session.query(Notification, NotificationType) + .join(NotificationType, + NotificationType.notificationtypeid == Notification.notificationtypeid) + .outerjoin(NotificationAcknowledgement, + db.and_( + NotificationAcknowledgement.notificationid == Notification.notificationid, + NotificationAcknowledgement.assetid == asset_id)) + .filter(Notification.alertfleet.is_(True), + Notification.isactive.is_(True), + db.or_( + Notification.endtime.is_(None), + Notification.endtime > now), + NotificationAcknowledgement.notificationacknowledgementid.is_(None))) + + results = query.all() + + alerts = [] + for notification, ntype in results: + title = notification.notification[:100] if notification.notification else '' + if '\n' in title: + title = title.split('\n')[0][:100] or '(no title)' + + alerts.append({ + 'notificationId': notification.notificationid, + 'type': ntype.typename, + 'typeColor': ntype.typecolor or _DEFAULT_ALERT_TYPE_COLOR, + 'title': title, + 'message': notification.notification or '', + 'timeout': notification.alerttimeout, + }) + + return success_response(alerts) + + +@endpoints_bp.route('/alerts//acknowledge', methods=['POST']) +def acknowledge_fleet_alert(notification_id: int): + """Agent acknowledges a fleet alert (collector-token auth). + + The agent posts the hostname plus assetid when dismissing a fleet alert from + the tray. Records acknowledgement so this alert will not reappear in future polls. + Idempotent - repeated acknowledgements succeed. + """ + error = _collector_auth_or_error() + if error is not None: + return error + + payload = request.get_json(silent=True) or {} + hostname = (payload.get('hostname') or '').strip() + assetid = payload.get('assetid') + + if not hostname: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'hostname is required') + + if assetid is None: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'assetid is required') + + try: + assetid = int(assetid) + except (TypeError, ValueError): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'assetid must be an integer') + + from plugins.notifications.models import Notification, NotificationAcknowledgement + + notification = db.session.get(Notification, notification_id) + if notification is None: + return error_response(ErrorCodes.NOT_FOUND, + f'Notification {notification_id} not found', + http_code=404) + + asset = db.session.get(Asset, assetid) + if asset is None: + return error_response(ErrorCodes.NOT_FOUND, + f'Asset {assetid} not found', + http_code=404) + + # Validate hostname matches assetid to prevent cross-machine acknowledgements + resolved_asset = _resolve_asset_for_hostname(hostname) + if resolved_asset is None or resolved_asset.assetid != assetid: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'hostname does not match provided assetid') + + # Check if already acknowledged (idempotent) + existing = (NotificationAcknowledgement.query + .filter_by(notificationid=notification_id, assetid=assetid) + .first()) + if existing: + return success_response({'acknowledged': True}, + message='Alert already acknowledged') + + # Record acknowledgement + ack = NotificationAcknowledgement( + notificationid=notification_id, + assetid=assetid + ) + db.session.add(ack) + + try: + db.session.commit() + except Exception as exc: + db.session.rollback() + logger.warning(f'Failed to commit acknowledgement for notification {notification_id}: {exc}') + return error_response(ErrorCodes.INTERNAL_ERROR, + 'Failed to record acknowledgement', + http_code=500) + + return success_response({'acknowledged': True}, + message='Alert acknowledged', http_code=201) + + diff --git a/plugins/notifications/api/routes.py b/plugins/notifications/api/routes.py index b1058325..9f2b800e 100644 --- a/plugins/notifications/api/routes.py +++ b/plugins/notifications/api/routes.py @@ -455,7 +455,10 @@ def create_notification(): isactive=True, isshopfloor=data.get('isshopfloor', False), employeesso=data.get('employeesso'), - employeename=data.get('employeename') + employeename=data.get('employeename'), + alertfleet=data.get('alertfleet', False), + alertscope=data.get('alertscope'), + alerttimeout=data.get('alerttimeout', 0) ) db.session.add(n) @@ -504,6 +507,12 @@ def update_notification(notification_id: int): n.employeesso = data['employeesso'] if 'employeename' in data: n.employeename = data['employeename'] + if 'alertfleet' in data: + n.alertfleet = data['alertfleet'] + if 'alertscope' in data: + n.alertscope = data['alertscope'] + if 'alerttimeout' in data: + n.alerttimeout = data['alerttimeout'] # Parse and update dates if 'starttime' in data or 'startdate' in data: diff --git a/plugins/notifications/frontend/views/NotificationForm.vue b/plugins/notifications/frontend/views/NotificationForm.vue index c88ede31..fa5cbf65 100644 --- a/plugins/notifications/frontend/views/NotificationForm.vue +++ b/plugins/notifications/frontend/views/NotificationForm.vue @@ -217,6 +217,18 @@ Display on TV dashboard + +
+ + Push this notification to shop floor PCs +
{{ error }}
@@ -282,7 +294,10 @@ const form = ref({ // loads its own stored value (see loadNotification), so this never flips a // setting somebody deliberately turned off. isshopfloor: true, - employeesso: '' + employeesso: '', + alertfleet: false, + alertscope: null, + alerttimeout: 0 }) // Site timezone (settings key site_timezone) so form times show/enter in the @@ -349,7 +364,10 @@ onMounted(async () => { endtime: formatDateForInput(n.endtime), isactive: n.isactive !== false, isshopfloor: n.isshopfloor || false, - employeesso: n.employeesso || '' + employeesso: n.employeesso || '', + alertfleet: n.alertfleet || false, + alertscope: n.alertscope || null, + alerttimeout: n.alerttimeout !== undefined ? n.alerttimeout : 0 } // Parse existing employee data: SSOs are comma-joined and names are @@ -527,7 +545,10 @@ async function saveNotification() { endtime: utcFromZonedInput(form.value.endtime, siteTimezone.value), isactive: form.value.isactive, isshopfloor: form.value.isshopfloor, - employeesso: form.value.employeesso || null + employeesso: form.value.employeesso || null, + alertfleet: form.value.alertfleet || false, + alertscope: form.value.alertscope || null, + alerttimeout: form.value.alerttimeout !== undefined ? form.value.alerttimeout : 0 } // For employee-photo types, also send employeename diff --git a/plugins/notifications/migrations/versions/0006_add_fleet_alerts.py b/plugins/notifications/migrations/versions/0006_add_fleet_alerts.py new file mode 100644 index 00000000..05d142fd --- /dev/null +++ b/plugins/notifications/migrations/versions/0006_add_fleet_alerts.py @@ -0,0 +1,103 @@ +"""Add fleet alert support to notifications plugin. + +Adds alertfleet, alertscope, and alerttimeout columns to the notifications table +to support fleet-wide alert broadcasting. Creates notificationacknowledgements +table to track which assets have acknowledged fleet alerts. + +Fleet alerts can be scoped to specific asset types, locations, or business units +via the alertscope JSONB field. alerttimeout=0 means no auto-dismiss (manual only). + +Idempotent: column and table creation are skipped when they already exist. + +Revision ID: notifications0006fleetalerts +Revises: notifications0005boardorder +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + + +revision = 'notifications0006fleetalerts' +down_revision = 'notifications0005boardorder' +branch_labels = None +depends_on = None + + +def _column_names(insp, table): + return {c['name'] for c in insp.get_columns(table)} + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + existing_tables = set(insp.get_table_names()) + + # Add alertfleet, alertscope, alerttimeout to notifications table + if 'notifications' in existing_tables: + cols = _column_names(insp, 'notifications') + + if 'alertfleet' not in cols: + op.add_column('notifications', + sa.Column('alertfleet', sa.Boolean(), nullable=False, + server_default=sa.false())) + + if 'alertscope' not in cols: + op.add_column('notifications', + sa.Column('alertscope', JSONB, nullable=True)) + + if 'alerttimeout' not in cols: + op.add_column('notifications', + sa.Column('alerttimeout', sa.Integer(), nullable=False, + server_default='0')) + + # Create notificationacknowledgements table + if 'notificationacknowledgements' not in existing_tables: + op.create_table( + 'notificationacknowledgements', + sa.Column('notificationacknowledgementid', sa.Integer(), nullable=False), + sa.Column('notificationid', sa.Integer(), nullable=False), + sa.Column('assetid', sa.Integer(), nullable=False), + sa.Column('acknowledgedat', sa.DateTime(), nullable=False, + server_default=sa.text('NOW()')), + sa.PrimaryKeyConstraint('notificationacknowledgementid'), + sa.ForeignKeyConstraint(['notificationid'], + ['notifications.notificationid'], + ondelete='CASCADE'), + sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], + ondelete='CASCADE'), + sa.UniqueConstraint('notificationid', 'assetid', + name='uq_notification_asset') + ) + op.create_index('idx_notificationack_notification', + 'notificationacknowledgements', + ['notificationid']) + op.create_index('idx_notificationack_asset', + 'notificationacknowledgements', + ['assetid']) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + existing_tables = set(insp.get_table_names()) + + # Drop notificationacknowledgements table + if 'notificationacknowledgements' in existing_tables: + op.drop_index('idx_notificationack_asset', + table_name='notificationacknowledgements') + op.drop_index('idx_notificationack_notification', + table_name='notificationacknowledgements') + op.drop_table('notificationacknowledgements') + + # Drop alert columns from notifications table + if 'notifications' in existing_tables: + cols = _column_names(insp, 'notifications') + + if 'alerttimeout' in cols: + op.drop_column('notifications', 'alerttimeout') + + if 'alertscope' in cols: + op.drop_column('notifications', 'alertscope') + + if 'alertfleet' in cols: + op.drop_column('notifications', 'alertfleet') diff --git a/plugins/notifications/models/__init__.py b/plugins/notifications/models/__init__.py index f6514a97..0dadbd21 100644 --- a/plugins/notifications/models/__init__.py +++ b/plugins/notifications/models/__init__.py @@ -1,5 +1,6 @@ """Notifications plugin models.""" from .notification import Notification, NotificationType +from .acknowledgement import NotificationAcknowledgement -__all__ = ['Notification', 'NotificationType'] +__all__ = ['Notification', 'NotificationType', 'NotificationAcknowledgement'] diff --git a/plugins/notifications/models/acknowledgement.py b/plugins/notifications/models/acknowledgement.py new file mode 100644 index 00000000..e2e887a8 --- /dev/null +++ b/plugins/notifications/models/acknowledgement.py @@ -0,0 +1,52 @@ +"""Notification acknowledgement tracking for fleet alerts.""" + +from datetime import datetime, timezone +from shopdb.api import db + + +class NotificationAcknowledgement(db.Model): + """ + Tracks which assets have acknowledged fleet alerts. + Matches existing notificationacknowledgements table. + """ + __tablename__ = 'notificationacknowledgements' + __table_args__ = ( + db.UniqueConstraint('notificationid', 'assetid', name='uq_notification_asset'), + ) + + notificationacknowledgementid = db.Column(db.Integer, primary_key=True) + notificationid = db.Column( + db.Integer, + db.ForeignKey('notifications.notificationid', ondelete='CASCADE'), + nullable=False, + index=True + ) + assetid = db.Column( + db.Integer, + db.ForeignKey('assets.assetid', ondelete='CASCADE'), + nullable=False, + index=True + ) + acknowledgedat = db.Column( + db.DateTime, + nullable=False, + default=lambda: datetime.now(timezone.utc).replace(tzinfo=None) + ) + + # Relationships + notification = db.relationship('Notification', backref='acknowledgements') + asset = db.relationship('Asset', backref='notification_acknowledgements') + + def __repr__(self): + return f"" + + def to_dict(self): + """Convert to dictionary following ShopDB patterns.""" + return { + 'notificationacknowledgementid': self.notificationacknowledgementid, + 'notificationid': self.notificationid, + 'assetid': self.assetid, + 'acknowledgedat': self.acknowledgedat.replace( + tzinfo=timezone.utc + ).isoformat() if self.acknowledgedat else None + } diff --git a/plugins/notifications/models/notification.py b/plugins/notifications/models/notification.py index 69781d83..9142ec88 100644 --- a/plugins/notifications/models/notification.py +++ b/plugins/notifications/models/notification.py @@ -146,6 +146,9 @@ class Notification(db.Model): # ~11 people are listed. employeesso = db.Column(db.Text, nullable=True) employeename = db.Column(db.Text, nullable=True) + alertfleet = db.Column(db.Boolean, default=False) + alertscope = db.Column(db.JSON, nullable=True) + alerttimeout = db.Column(db.Integer, nullable=True) # Relationships notificationtype = db.relationship('NotificationType', backref='notifications') @@ -194,7 +197,10 @@ class Notification(db.Model): 'isshopfloor': bool(self.isshopfloor) if self.isshopfloor is not None else False, 'employeesso': self.employeesso, 'employeename': self.employeename, - 'iscurrent': self.is_current + 'iscurrent': self.is_current, + 'alertfleet': bool(self.alertfleet) if self.alertfleet is not None else False, + 'alertscope': self.alertscope, + 'alerttimeout': self.alerttimeout } # Add type info