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