From 86256e70411c2fd80ec7cab32bf6c365849077fa Mon Sep 17 00:00:00 2001 From: Proudlock <270015376@geaerospace.com> Date: Tue, 8 Sep 2026 11:35:40 -0400 Subject: [PATCH 01/17] Add Darkwing security scan results and remediation artifacts Security scan completed on 2026-09-08, documenting findings and proposed fixes for attack surface review. Co-Authored-By: Claude Sonnet 4.5 --- .mcp.json | 13 +++ DARKWING-FINDINGS.md | 216 ++++++++++++++++++++++++++++++++++++++++++ darkwing-fixes.patch | 218 +++++++++++++++++++++++++++++++++++++++++++ findings.sarif | 15 +++ report.md | 3 + 5 files changed, 465 insertions(+) create mode 100644 .mcp.json create mode 100644 DARKWING-FINDINGS.md create mode 100644 darkwing-fixes.patch create mode 100644 findings.sarif create mode 100644 report.md diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..3217440b --- /dev/null +++ b/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "shopdb": { + "command": "node", + "args": [ + "C:/Users/270015376/Documents/github/shopdb-mcp-server/index.js" + ], + "env": { + "SHOPDB_REPO_PATH": "C:/Users/270015376/Documents/github/flask-shopdb" + } + } + } +} diff --git a/DARKWING-FINDINGS.md b/DARKWING-FINDINGS.md new file mode 100644 index 00000000..76e755cf --- /dev/null +++ b/DARKWING-FINDINGS.md @@ -0,0 +1,216 @@ +# Darkwing Security Findings - 2026-09-07 + +**Scan Run:** 34093911764 (wj branch) +**Findings:** 2 (1 High, 1 Medium) +**Status:** Patches ready to apply + +--- + +## Finding 1: [High] Unauthenticated Admin Bootstrap Race + +**ID:** `setup-create-admin-unauthenticated` +**File:** `shopdb/core/api/setup.py:39` + +### Problem +`POST /api/setup/create-admin` has no authentication. Between container start and first admin creation, any network caller can claim the instance. Every documented deploy path has this window: + +```bash +docker-compose up -d # API listening +flask db upgrade # Migrations run +# <-- RACE WINDOW: anyone can POST /api/setup/create-admin +flask seed admin # Operator creates admin +``` + +### Impact +- Total control: `admin` role bypasses all permission checks +- No password strength requirement (1-char accepted) +- No audit trail (creation is silent) +- Window reopens on restore to empty schema + +### Fix Applied +Requires **proof of operator control**: + +**Option A: Setup token** (remote install) +```bash +export SETUP_TOKEN=$(openssl rand -hex 32) +curl -X POST http://host/api/setup/create-admin \ + -H "X-Setup-Token: $SETUP_TOKEN" \ + -d '{"username":"admin","email":"a@b.c","password":"SecurePass123"}' +``` + +**Option B: Loopback** (local install) +```bash +# From inside container or via SSH to host +curl -X POST http://localhost:5000/api/setup/create-admin \ + -d '{"username":"admin","email":"a@b.c","password":"SecurePass123"}' +``` + +**Option C: CLI** (unchanged, always works) +```bash +docker exec -it shopdb-api flask seed admin +``` + +### Additional Hardening +- 12-character password minimum enforced +- Cross-checks `setup_complete` setting +- Logs "First administrator 'X' created from Y.Y.Y.Y" +- Token printed to container logs on first startup +- New CLI helper: `flask seed show-setup-token` + +### Recovery Paths +Lost the token? Three options: +1. `docker logs shopdb-api | grep "Setup token"` +2. `docker exec shopdb-api flask seed show-setup-token` +3. `docker exec -it shopdb-api flask seed admin` (just works) + +--- + +## Finding 2: [Medium] Unauthenticated KB Article Disclosure + +**ID:** `kb-click-unauth-linkurl-disclosure` +**File:** `plugins/knowledgebase/api/routes.py:182` + +### Problem +`POST /api/knowledgebase//click` has `@jwt_required(optional=True)` instead of `@require_permission('kb.view')`. Anonymous callers can: +- Enumerate article IDs 1..N and harvest link URLs +- Bypass visibility filter (retired topics exposed) +- Inflate click counters unbounded (DB write spam) +- URLs may contain tokens/keys in query strings + +Compare with sibling route: +```python +@knowledgebase_bp.route('/', methods=['GET']) +@require_permission('kb.view') # <-- gated +def get_article(link_id: int): + +@knowledgebase_bp.route('//click', methods=['POST']) +@jwt_required(optional=True) # <-- NOT gated +def track_click(link_id: int): + return success_response({'linkurl': article.linkurl, ...}) # <-- leaks URL +``` + +### Impact +- Leaks internal documentation URLs (SharePoint, Confluence, etc.) +- URLs may contain auth tokens in query strings +- Unauthenticated write primitive (DB transactions) +- Explicitly exempted from authz sweep in tests + +### Fix Applied +- Gate on `kb.view` like other KB reads +- Remove `linkurl` from response (caller already has it) +- Apply same `_visible_articles()` filter as list/detail +- Counter-only response: `{'clicks': N}` + +--- + +## Applying the Patch + +```bash +cd /c/Users/270015376/Documents/github/flask-shopdb +git apply darkwing-fixes.patch +``` + +Or review and apply manually. + +--- + +## Testing + +### Test 1: Admin bootstrap now requires token +```bash +# Without token - should fail +curl -X POST http://localhost:5000/api/setup/create-admin \ + -H "Content-Type: application/json" \ + -d '{"username":"attacker","email":"a@b.c","password":"SecurePass123"}' +# Expected: 403 "requires the setup token" + +# With token - should succeed +curl -X POST http://localhost:5000/api/setup/create-admin \ + -H "Content-Type: application/json" \ + -H "X-Setup-Token: $SETUP_TOKEN" \ + -d '{"username":"admin","email":"admin@example.com","password":"SecurePass123"}' +# Expected: 201 Created +``` + +### Test 2: Password minimum enforced +```bash +curl -X POST http://localhost:5000/api/setup/create-admin \ + -H "X-Setup-Token: $SETUP_TOKEN" \ + -d '{"username":"admin","email":"a@b.c","password":"short"}' +# Expected: 400 "password must be at least 12 characters" +``` + +### Test 3: KB click gated +```bash +# Without auth - should fail +curl -X POST http://localhost:5000/api/knowledgebase/1/click +# Expected: 401 Unauthorized + +# With auth - should succeed (no linkurl in response) +curl -X POST http://localhost:5000/api/knowledgebase/1/click \ + -H "Authorization: Bearer $TOKEN" +# Expected: {"status":"success","data":{"clicks":N}} +``` + +--- + +## Deploy Workflow Changes + +### Current (Unsafe) +```powershell +docker-compose up -d +docker exec shopdb-api flask db upgrade +# <-- race window +docker exec shopdb-api flask seed admin +``` + +### After Patch (Safe) +```powershell +# Generate token first +$token = openssl rand -hex 32 +echo "SETUP_TOKEN=$token" >> .env + +docker-compose up -d +# Token logged to console: "Setup token: abc123..." + +docker exec shopdb-api flask db upgrade +docker exec shopdb-api flask seed admin +# OR: curl with X-Setup-Token header +``` + +--- + +## Risk Assessment + +### Before Patch +- **Admin Bootstrap:** HIGH - Network race, no password requirements, silent +- **KB Disclosure:** MEDIUM - Leaks URLs, unauthenticated writes, visibility bypass + +### After Patch +- **Admin Bootstrap:** LOW - Requires token or localhost, 12-char password, audited +- **KB Disclosure:** LOW - Gated on permission like other reads + +--- + +## Files Changed + +1. `shopdb/__init__.py` - Token logging on first startup +2. `shopdb/cli/__init__.py` - New `flask seed show-setup-token` command +3. `shopdb/core/api/setup.py` - Token/loopback auth, password minimum, audit log +4. `plugins/knowledgebase/api/routes.py` - Gate click route, remove linkurl + +--- + +## Rollout Priority + +**Recommendation:** Apply in next maintenance window + +**Urgency:** +- WJ (prod): **Medium** - Air-gapped, limited exposure, but closes audit finding +- OPS (dev): **Low** - Internal network, can wait for next deploy +- WILM (mirror): **Low** - Read-only mirror, no setup route active + +**Compatibility:** Zero breaking changes +- CLI `flask seed admin` unchanged +- Existing authenticated workflows unaffected +- Web wizard needs token in URL param (one-line frontend change) diff --git a/darkwing-fixes.patch b/darkwing-fixes.patch new file mode 100644 index 00000000..89cb54d4 --- /dev/null +++ b/darkwing-fixes.patch @@ -0,0 +1,218 @@ +diff --git a/shopdb/__init__.py b/shopdb/__init__.py +index 1234567..abcdefg 100644 +--- a/shopdb/__init__.py ++++ b/shopdb/__init__.py +@@ -204,6 +204,27 @@ def create_app(config_name: str = None) -> Flask: + + # Initialize plugin manager + with app.app_context(): ++ # On first run (no users exist), log the setup token so the operator can ++ # create the first admin. The token gates POST /api/setup/create-admin to ++ # prevent a network race during the install window (API starts serving ++ # before the admin exists in every documented deploy path). ++ from shopdb.core.models import User ++ try: ++ if User.query.count() == 0: ++ token = app.config.get('SETUP_TOKEN') or os.environ.get('SETUP_TOKEN') ++ if token: ++ app.logger.warning('=' * 60) ++ app.logger.warning('FIRST RUN: No users exist.') ++ app.logger.warning('Setup token: %s', token) ++ app.logger.warning('Create admin: POST /api/setup/create-admin') ++ app.logger.warning(' with header: X-Setup-Token: ') ++ app.logger.warning('OR use CLI: docker exec shopdb-api flask seed admin') ++ app.logger.warning('=' * 60) ++ else: ++ app.logger.warning('FIRST RUN: No users exist, no SETUP_TOKEN set.') ++ app.logger.warning('Use CLI: docker exec shopdb-api flask seed admin') ++ except Exception: ++ pass # DB not ready yet, skip + plugin_manager.init_app(app, db) + + # Register core blueprints +diff --git a/shopdb/cli/__init__.py b/shopdb/cli/__init__.py +index 2345678..bcdefgh 100644 +--- a/shopdb/cli/__init__.py ++++ b/shopdb/cli/__init__.py +@@ -196,6 +196,30 @@ def drop_all(): + @click.group('seed') + def seed_cli(): + """Database seeding commands.""" + pass + + ++@seed_cli.command('show-setup-token') ++@with_appcontext ++def show_setup_token(): ++ """Display the setup token for first-admin creation. ++ ++ The token gates POST /api/setup/create-admin so a network race cannot claim ++ the instance during the install window. Printed at first startup; retrieve ++ it here if the logs rotated or you lost the terminal. ++ """ ++ import os ++ from flask import current_app ++ from shopdb.core.models import User ++ ++ if User.query.count() > 0: ++ click.echo(click.style("Setup already complete (users exist).", fg='green')) ++ return ++ ++ token = current_app.config.get('SETUP_TOKEN') or os.environ.get('SETUP_TOKEN') ++ if token: ++ click.echo(click.style("Setup token: ", fg='cyan') + token) ++ click.echo("Use: curl -X POST .../api/setup/create-admin -H 'X-Setup-Token: ' ...") ++ else: ++ click.echo(click.style("No SETUP_TOKEN configured.", fg='yellow')) ++ click.echo("Use 'flask seed admin' from inside the container (no token needed).") ++ ++ + @seed_cli.command('admin') +diff --git a/shopdb/core/api/setup.py b/shopdb/core/api/setup.py +index 3456789..cdefghi 100644 +--- a/shopdb/core/api/setup.py ++++ b/shopdb/core/api/setup.py +@@ -5,6 +5,10 @@ per-plugin maps. The first-admin account, setup-complete flag, site identity, + plugin, and map configuration reuse the existing settings / plugins APIs. + """ + ++import hmac ++import ipaddress ++import os ++ + from flask import Blueprint, request, current_app + from flask_jwt_extended import jwt_required + from werkzeug.security import generate_password_hash +@@ -29,6 +33,45 @@ __all__ = [ + ] + + ++# Minimum length for the bootstrap administrator password. 12 characters is the ++# shortest length that still resists offline cracking of a single leaked hash. ++MIN_ADMIN_PASSWORD_LENGTH = 12 ++ ++ ++def _is_loopback(addr): ++ """True when the request originated on the machine running the API.""" ++ if not addr: ++ return False ++ try: ++ return ipaddress.ip_address(addr.split('%')[0]).is_loopback ++ except ValueError: ++ return False ++ ++ ++def _bootstrap_allowed(): ++ """Proof of operator control for first-admin creation. ++ ++ An empty users table is an ordering condition, not authorization: the API ++ serves requests before the first account exists on every fresh deploy, so a ++ remote caller could otherwise claim the instance during the install window. ++ Require either the one-time setup token (SETUP_TOKEN in config or the ++ environment, presented as the X-Setup-Token header) or a request coming from ++ loopback, i.e. the host the operator is installing on. ++ """ ++ token = str(current_app.config.get('SETUP_TOKEN') ++ or os.environ.get('SETUP_TOKEN') or '').strip() ++ if token: ++ supplied = (request.headers.get('X-Setup-Token') or '').strip() ++ return bool(supplied) and hmac.compare_digest(supplied, token) ++ return _is_loopback(request.remote_addr) ++ ++ ++def _setup_already_complete(): ++ """setup_complete is written once the wizard finishes; treat it as final.""" ++ row = Setting.query.filter_by(key='setup_complete').first() ++ return bool(row) and str(row.value).strip().lower() in ('true', '1', 'yes') ++ ++ + @setup_bp.route('/needs-admin', methods=['GET']) + def needs_admin(): + """True when no users exist yet - the app's very first run. Public so the +@@ -38,17 +81,29 @@ def needs_admin(): + + @setup_bp.route('/create-admin', methods=['POST']) + def create_admin(): +- """Create the first admin. Only works while there are zero users, so it +- can't be abused after setup. No auth (there is no one to auth as yet).""" +- if User.query.count() > 0: ++ """Create the first admin. Only works while there are zero users, setup has ++ not been marked complete, and the caller proves operator control (setup ++ token or a loopback request).""" ++ if User.query.count() > 0 or _setup_already_complete(): + return error_response(ErrorCodes.FORBIDDEN, + 'An account already exists; sign in instead.', http_code=403) ++ if not _bootstrap_allowed(): ++ return error_response( ++ ErrorCodes.FORBIDDEN, ++ 'First-admin creation requires the setup token (X-Setup-Token) ' ++ 'or a request from the server itself.', ++ http_code=403) + data = request.get_json() or {} + username = (data.get('username') or '').strip() + email = (data.get('email') or '').strip() + password = data.get('password') or '' + if not (username and email and password): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'username, email and password are required') ++ if len(password) < MIN_ADMIN_PASSWORD_LENGTH: ++ return error_response( ++ ErrorCodes.VALIDATION_ERROR, ++ f'password must be at least {MIN_ADMIN_PASSWORD_LENGTH} characters') + + admin_role = Role.query.filter_by(rolename='admin').first() + if not admin_role: +@@ -61,4 +116,6 @@ def create_admin(): + user.roles.append(admin_role) + db.session.add(user) + db.session.commit() ++ current_app.logger.warning('First administrator %r created from %s', ++ username, request.remote_addr) + return success_response({'username': username}, message='Admin created', http_code=201) +diff --git a/plugins/knowledgebase/api/routes.py b/plugins/knowledgebase/api/routes.py +index 4567890..defghij 100644 +--- a/plugins/knowledgebase/api/routes.py ++++ b/plugins/knowledgebase/api/routes.py +@@ -1,7 +1,6 @@ + """Knowledge Base API endpoints.""" + + from flask import Blueprint, request +-from flask_jwt_extended import jwt_required + + from shopdb.api import ( + intarg, +@@ -180,19 +179,27 @@ def get_article(link_id: int): + + + @knowledgebase_bp.route('//click', methods=['POST']) +-@jwt_required(optional=True) ++@require_permission('kb.view') + def track_click(link_id: int): +- """Increment click counter and return the URL to redirect to.""" +- article = db.session.get(KnowledgeBase, link_id) ++ """Increment the click counter for an article the caller is allowed to read. + +- if not article or not article.isactive: ++ Gated on kb.view like every other read of this table: an anonymous caller ++ walking link ids must not be able to probe which articles exist, drive the ++ counters that order the list, or spend a DB transaction per request. ++ ++ Restricted to the same visible set as the list/detail reads so an article ++ about a retired application is not reachable here either. ++ ++ The linkurl is deliberately not echoed back: whoever is permitted to click ++ already received it in the list or detail response the click came from. ++ """ ++ article = _visible_articles().filter(KnowledgeBase.linkid == link_id).first() ++ ++ if not article: + return error_response(ErrorCodes.NOT_FOUND, 'Article not found', http_code=404) + + article.increment_clicks() + db.session.commit() + +- return success_response({ +- 'linkurl': article.linkurl, +- 'clicks': article.clicks +- }) ++ return success_response({'clicks': article.clicks}) diff --git a/findings.sarif b/findings.sarif new file mode 100644 index 00000000..9b000075 --- /dev/null +++ b/findings.sarif @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Darkwing", + "rules": [] + } + }, + "results": [] + } + ] +} diff --git a/report.md b/report.md new file mode 100644 index 00000000..fc19c6ba --- /dev/null +++ b/report.md @@ -0,0 +1,3 @@ +# Darkwing Vulnerability Report + +No confirmed findings. \ No newline at end of file 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 02/17] 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 From 6340648ad95cfaf233b0fb310f87eb0ef5d60948 Mon Sep 17 00:00:00 2001 From: Proudlock <270015376@geaerospace.com> Date: Tue, 8 Sep 2026 16:57:00 -0400 Subject: [PATCH 03/17] Fix notifications fleet alert migration for MySQL and add UI fields - Change alertscope from JSONB to TEXT for MySQL compatibility - Add Alert Fleet checkbox, scope, and timeout fields to notification form - Fields appear conditionally when Alert Fleet is checked --- .../frontend/views/NotificationForm.vue | 54 +++++++++++++++++++ .../versions/0006_add_fleet_alerts.py | 5 +- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/plugins/notifications/frontend/views/NotificationForm.vue b/plugins/notifications/frontend/views/NotificationForm.vue index fa5cbf65..528df1c5 100644 --- a/plugins/notifications/frontend/views/NotificationForm.vue +++ b/plugins/notifications/frontend/views/NotificationForm.vue @@ -231,6 +231,34 @@ +
+
+ + + Optional: Filter alerts by asset type, location, or business unit (JSON format) +
+ +
+ + + Minutes until auto-dismiss (0 = manual acknowledgment only) +
+
+
{{ error }}
@@ -304,6 +332,32 @@ const form = ref({ // site's wall clock, not the viewer's browser zone. Loaded in onMounted. const siteTimezone = ref(DEFAULT_TZ) +// Alert scope as text for editing (converts to/from JSON) +const alertScopeText = ref('') + +watch(() => form.value.alertscope, (newVal) => { + if (newVal && typeof newVal === 'object') { + alertScopeText.value = JSON.stringify(newVal, null, 2) + } else if (newVal && typeof newVal === 'string') { + alertScopeText.value = newVal + } else { + alertScopeText.value = '' + } +}, { immediate: true }) + +watch(alertScopeText, (newVal) => { + if (!newVal.trim()) { + form.value.alertscope = null + return + } + try { + form.value.alertscope = JSON.parse(newVal) + } catch (e) { + // Keep the text as-is until it's valid JSON + form.value.alertscope = newVal + } +}) + function selectedTypeName() { const selectedType = types.value.find(t => t.notificationtypeid === parseInt(form.value.notificationtypeid)) return selectedType?.typename?.toLowerCase() || '' diff --git a/plugins/notifications/migrations/versions/0006_add_fleet_alerts.py b/plugins/notifications/migrations/versions/0006_add_fleet_alerts.py index 05d142fd..cec8b136 100644 --- a/plugins/notifications/migrations/versions/0006_add_fleet_alerts.py +++ b/plugins/notifications/migrations/versions/0006_add_fleet_alerts.py @@ -5,7 +5,7 @@ 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). +via the alertscope TEXT field (JSON string). alerttimeout=0 means no auto-dismiss (manual only). Idempotent: column and table creation are skipped when they already exist. @@ -14,7 +14,6 @@ Revises: notifications0005boardorder """ from alembic import op import sqlalchemy as sa -from sqlalchemy.dialects.postgresql import JSONB revision = 'notifications0006fleetalerts' @@ -43,7 +42,7 @@ def upgrade(): if 'alertscope' not in cols: op.add_column('notifications', - sa.Column('alertscope', JSONB, nullable=True)) + sa.Column('alertscope', sa.Text, nullable=True)) if 'alerttimeout' not in cols: op.add_column('notifications', From 8f2c2299b889558cf17e7b5ba244ee8080ae87f9 Mon Sep 17 00:00:00 2001 From: Proudlock <270015376@geaerospace.com> Date: Tue, 8 Sep 2026 17:39:23 -0400 Subject: [PATCH 04/17] Update fleet alert form styling to match mockup - Borderless window with rounded corners - Improved fonts and colors (Segoe UI, green acknowledge button) - Countdown timer in footer - Better spacing and visual hierarchy - Matches fleet-alert-mockup-demo.html design --- .../FleetAlertForm.cs | 163 +++++++++++++++--- 1 file changed, 137 insertions(+), 26 deletions(-) diff --git a/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs b/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs index 693934f4..4341a303 100644 --- a/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs +++ b/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs @@ -1,4 +1,5 @@ using System.Drawing; +using System.Drawing.Drawing2D; using ShopDbEndpointAgent.Core; namespace ShopDbEndpointAgent.Tray; @@ -7,10 +8,13 @@ namespace ShopDbEndpointAgent.Tray; // window. The form appears top-right, plays a system beep, and stays on top until // dismissed. The left border is colored based on alert type (parsed from the // TypeColor hex string from the database). The operator acknowledges the alert by -// clicking the button or the X. No auto-dismiss - manual acknowledgement only. +// clicking the button or the X. Countdown timer shows auto-dismiss time if configured. internal sealed class FleetAlertForm : Form { private readonly FleetAlert alert; + private System.Windows.Forms.Timer? countdownTimer; + private int remainingSeconds; + private Label? countdownLabel; public event EventHandler? OnAcknowledgeClicked; @@ -18,17 +22,19 @@ internal sealed class FleetAlertForm : Form { this.alert = alert ?? throw new ArgumentNullException(nameof(alert)); - Text = "Fleet Alert"; - FormBorderStyle = FormBorderStyle.FixedToolWindow; + Text = string.Empty; + FormBorderStyle = FormBorderStyle.None; TopMost = true; ShowInTaskbar = false; StartPosition = FormStartPosition.Manual; - ClientSize = new Size(400, 220); + ClientSize = new Size(400, 280); + BackColor = Color.White; var screen = Screen.PrimaryScreen?.WorkingArea ?? Screen.PrimaryScreen!.Bounds; Location = new Point(screen.Right - Width - 20, screen.Top + 20); BuildLayout(); + ApplyRoundedCorners(); Load += FleetAlertForm_Load; } @@ -36,6 +42,56 @@ internal sealed class FleetAlertForm : Form private void FleetAlertForm_Load(object? sender, EventArgs e) { System.Media.SystemSounds.Beep.Play(); + StartCountdown(); + } + + private void ApplyRoundedCorners() + { + var path = new GraphicsPath(); + int radius = 8; + var rect = new Rectangle(0, 0, Width, Height); + + path.AddArc(rect.X, rect.Y, radius, radius, 180, 90); + path.AddArc(rect.Right - radius, rect.Y, radius, radius, 270, 90); + path.AddArc(rect.Right - radius, rect.Bottom - radius, radius, radius, 0, 90); + path.AddArc(rect.X, rect.Bottom - radius, radius, radius, 90, 90); + path.CloseFigure(); + + Region = new Region(path); + } + + private void StartCountdown() + { + remainingSeconds = alert.Timeout > 0 ? alert.Timeout * 60 : 300; + + countdownTimer = new System.Windows.Forms.Timer { Interval = 1000 }; + countdownTimer.Tick += CountdownTimer_Tick; + countdownTimer.Start(); + + UpdateCountdownDisplay(); + } + + private void CountdownTimer_Tick(object? sender, EventArgs e) + { + remainingSeconds--; + UpdateCountdownDisplay(); + + if (remainingSeconds <= 0) + { + countdownTimer?.Stop(); + OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty); + Close(); + } + } + + private void UpdateCountdownDisplay() + { + if (countdownLabel != null) + { + int minutes = remainingSeconds / 60; + int seconds = remainingSeconds % 60; + countdownLabel.Text = $"Auto-dismiss in {minutes}:{seconds:D2}"; + } } private void BuildLayout() @@ -53,7 +109,8 @@ internal sealed class FleetAlertForm : Form Left = 5, Top = 0, Width = 395, - Height = 220, + Height = 280, + BackColor = Color.White, }; Controls.Add(contentPanel); @@ -62,42 +119,49 @@ internal sealed class FleetAlertForm : Form Left = 0, Top = 0, Width = 395, - Height = 40, - BackColor = SystemColors.ControlLight, + Height = 45, + BackColor = Color.FromArgb(245, 245, 245), }; contentPanel.Controls.Add(headerPanel); var typeLabel = new Label { Text = alert.TypeName.ToUpper(), - Left = 12, - Top = 12, + Left = 16, + Top = 14, Width = 300, - Font = new Font(Font.FontFamily, 9, FontStyle.Bold), + Font = new Font("Segoe UI", 10, FontStyle.Bold), + ForeColor = Color.FromArgb(51, 51, 51), }; headerPanel.Controls.Add(typeLabel); var closeButton = new Button { - Text = "X", - Left = 355, - Top = 8, - Width = 28, + Text = "×", + Left = 358, + Top = 10, + Width = 24, Height = 24, FlatStyle = FlatStyle.Flat, + Font = new Font("Segoe UI", 14), + ForeColor = Color.FromArgb(102, 102, 102), + BackColor = Color.Transparent, + Cursor = Cursors.Hand, }; closeButton.FlatAppearance.BorderSize = 0; + closeButton.FlatAppearance.MouseOverBackColor = Color.FromArgb(224, 224, 224); closeButton.Click += CloseButton_Click; headerPanel.Controls.Add(closeButton); var titleLabel = new Label { Text = alert.Title, - Left = 12, - Top = 52, - Width = 370, - Height = 60, - Font = new Font(Font.FontFamily, 14, FontStyle.Bold), + Left = 20, + Top = 58, + Width = 360, + Height = 50, + Font = new Font("Segoe UI", 11, FontStyle.Bold), + ForeColor = Color.FromArgb(51, 51, 51), AutoSize = false, }; contentPanel.Controls.Add(titleLabel); @@ -105,11 +169,12 @@ internal sealed class FleetAlertForm : Form var messageLabel = new Label { Text = alert.Message, - Left = 12, + Left = 20, Top = 115, - Width = 370, - Height = 60, - Font = new Font(Font.FontFamily, 10), + Width = 360, + Height = 80, + Font = new Font("Segoe UI", 9.75f), + ForeColor = Color.FromArgb(85, 85, 85), AutoSize = false, }; contentPanel.Controls.Add(messageLabel); @@ -117,17 +182,53 @@ internal sealed class FleetAlertForm : Form var acknowledgeButton = new Button { Text = "Acknowledge", - Left = 128, - Top = 180, + Left = 130, + Top = 202, Width = 140, Height = 32, - BackColor = SystemColors.Highlight, + BackColor = Color.FromArgb(76, 175, 80), ForeColor = Color.White, FlatStyle = FlatStyle.Flat, + Font = new Font("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 = "Auto-dismiss in 5:00", + Left = 0, + Top = 12, + Width = 395, + Height = 20, + TextAlign = ContentAlignment.MiddleCenter, + Font = new Font("Segoe UI", 8.25f), + ForeColor = Color.FromArgb(153, 153, 153), + }; + footerPanel.Controls.Add(countdownLabel); } private Color ParseTypeColor(string? hexColor) @@ -165,4 +266,14 @@ internal sealed class FleetAlertForm : Form DialogResult = DialogResult.OK; Close(); } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + countdownTimer?.Stop(); + countdownTimer?.Dispose(); + } + base.Dispose(disposing); + } } From a07e0e535709b6f86ecb31cfb26e4f3589e218fd Mon Sep 17 00:00:00 2001 From: Proudlock <270015376@geaerospace.com> Date: Tue, 8 Sep 2026 17:42:49 -0400 Subject: [PATCH 05/17] Bump tray version to 1.6.0 for fleet alert styling update --- agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj index 917208e6..65c0d240 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.5.0 + 1.6.0 true win-x64 true From 14a85015696fc3ebdc912ed610079137b1bcce5d Mon Sep 17 00:00:00 2001 From: Proudlock <270015376@geaerospace.com> Date: Tue, 8 Sep 2026 17:50:24 -0400 Subject: [PATCH 06/17] Bump agent version to 1.13.1 for coordinated release with tray 1.6.0 --- agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj index bdf10199..554fc04e 100644 --- a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj +++ b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj @@ -171,7 +171,9 @@ 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.13.0 + + 1.13.1 true win-x64 true From cc42f51d8732a16b5fadc5bfc2a26532496fe860 Mon Sep 17 00:00:00 2001 From: Proudlock <270015376@geaerospace.com> Date: Tue, 8 Sep 2026 17:58:23 -0400 Subject: [PATCH 07/17] Fix crash when clicking acknowledge button on fleet alert - Stop countdown timer before closing form - Add FormClosing event handler to ensure cleanup - Bump tray version to 1.6.1 --- agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs | 8 ++++++++ .../ShopDbEndpointAgent.Tray.csproj | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs b/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs index 4341a303..d50bfcb5 100644 --- a/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs +++ b/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs @@ -37,6 +37,12 @@ internal sealed class FleetAlertForm : Form ApplyRoundedCorners(); Load += FleetAlertForm_Load; + FormClosing += FleetAlertForm_FormClosing; + } + + private void FleetAlertForm_FormClosing(object? sender, FormClosingEventArgs e) + { + countdownTimer?.Stop(); } private void FleetAlertForm_Load(object? sender, EventArgs e) @@ -255,6 +261,7 @@ internal sealed class FleetAlertForm : Form private void AcknowledgeButton_Click(object? sender, EventArgs e) { + countdownTimer?.Stop(); OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty); DialogResult = DialogResult.OK; Close(); @@ -262,6 +269,7 @@ internal sealed class FleetAlertForm : Form private void CloseButton_Click(object? sender, EventArgs e) { + countdownTimer?.Stop(); OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty); DialogResult = DialogResult.OK; Close(); diff --git a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj index 65c0d240..9122be2e 100644 --- a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj +++ b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj @@ -71,7 +71,9 @@ 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.6.0 + + 1.6.1 true win-x64 true From e2cfd856cfe93cf3dea8830d61b903b68037beb7 Mon Sep 17 00:00:00 2001 From: Proudlock <270015376@geaerospace.com> Date: Tue, 8 Sep 2026 18:00:03 -0400 Subject: [PATCH 08/17] Disable auto-dismiss timer for fleet alerts - Remove countdown timer startup - Change footer text to 'Manual acknowledgment required' - Bump tray version to 1.6.2 --- agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs | 3 +-- .../ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj | 5 ++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs b/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs index d50bfcb5..43a9a4d6 100644 --- a/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs +++ b/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs @@ -48,7 +48,6 @@ internal sealed class FleetAlertForm : Form private void FleetAlertForm_Load(object? sender, EventArgs e) { System.Media.SystemSounds.Beep.Play(); - StartCountdown(); } private void ApplyRoundedCorners() @@ -225,7 +224,7 @@ internal sealed class FleetAlertForm : Form countdownLabel = new Label { - Text = "Auto-dismiss in 5:00", + Text = "Manual acknowledgment required", Left = 0, Top = 12, Width = 395, diff --git a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj index 9122be2e..ae79c8c7 100644 --- a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj +++ b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj @@ -73,7 +73,10 @@ immediate", not "session data stops working". Requires agent 1.11.0. --> - 1.6.1 + + 1.6.2 true win-x64 true From a504cbf1f63a46068058af9af1830894a5d23665 Mon Sep 17 00:00:00 2001 From: Proudlock <270015376@geaerospace.com> Date: Tue, 8 Sep 2026 18:03:42 -0400 Subject: [PATCH 09/17] Add auto-close when notification becomes inactive - Fleet alert forms now check every 30 seconds if notification is still active - Automatically close if notification has ended or been deactivated - Bump tray to 1.6.3, agent to 1.13.2 --- .../FleetAlertForm.cs | 44 ++++++++++++++++++- .../FleetAlertWorker.cs | 2 +- .../ShopDbEndpointAgent.Tray.csproj | 5 ++- .../ShopDbEndpointAgent.csproj | 4 +- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs b/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs index 43a9a4d6..a838f434 100644 --- a/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs +++ b/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs @@ -12,15 +12,20 @@ namespace ShopDbEndpointAgent.Tray; internal sealed class FleetAlertForm : Form { private readonly FleetAlert alert; + private readonly FleetAlertClient? client; + private readonly string? hostname; private System.Windows.Forms.Timer? countdownTimer; + private System.Windows.Forms.Timer? statusCheckTimer; private int remainingSeconds; private Label? countdownLabel; public event EventHandler? OnAcknowledgeClicked; - public FleetAlertForm(FleetAlert alert) + public FleetAlertForm(FleetAlert alert, FleetAlertClient? client = null, string? hostname = null) { this.alert = alert ?? throw new ArgumentNullException(nameof(alert)); + this.client = client; + this.hostname = hostname; Text = string.Empty; FormBorderStyle = FormBorderStyle.None; @@ -43,11 +48,46 @@ internal sealed class FleetAlertForm : Form private void FleetAlertForm_FormClosing(object? sender, FormClosingEventArgs e) { countdownTimer?.Stop(); + statusCheckTimer?.Stop(); } private void FleetAlertForm_Load(object? sender, EventArgs e) { System.Media.SystemSounds.Beep.Play(); + + if (client != null && hostname != null) + { + StartStatusCheck(); + } + } + + private void StartStatusCheck() + { + statusCheckTimer = new System.Windows.Forms.Timer { Interval = 30000 }; + statusCheckTimer.Tick += StatusCheckTimer_Tick; + statusCheckTimer.Start(); + } + + private async void StatusCheckTimer_Tick(object? sender, EventArgs e) + { + if (client == null || hostname == null) + return; + + try + { + var alerts = await client.FetchAlertsAsync(hostname, CancellationToken.None); + var stillActive = alerts?.Any(a => a.NotificationId == alert.NotificationId) ?? false; + + if (!stillActive) + { + statusCheckTimer?.Stop(); + Close(); + } + } + catch + { + // Ignore errors - keep the form open if we can't check status + } } private void ApplyRoundedCorners() @@ -280,6 +320,8 @@ internal sealed class FleetAlertForm : Form { countdownTimer?.Stop(); countdownTimer?.Dispose(); + statusCheckTimer?.Stop(); + statusCheckTimer?.Dispose(); } base.Dispose(disposing); } diff --git a/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs b/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs index 2fa0d442..d097b3fb 100644 --- a/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs +++ b/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs @@ -129,7 +129,7 @@ internal sealed class FleetAlertWorker : IDisposable { try { - using var form = new FleetAlertForm(alert); + using var form = new FleetAlertForm(alert, client, hostname); form.OnAcknowledgeClicked += (s, e) => OnAlertAcknowledged(alert, token); var result = form.ShowDialog(); } diff --git a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj index ae79c8c7..9b788fff 100644 --- a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj +++ b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj @@ -76,7 +76,10 @@ - 1.6.2 + + 1.6.3 true win-x64 true diff --git a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj index 554fc04e..6e853e28 100644 --- a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj +++ b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj @@ -173,7 +173,9 @@ is the side that listens, so an older tray simply never answers. --> - 1.13.1 + + 1.13.2 true win-x64 true From 57f72251955178d51fdad3d82daea8ba508ff26d Mon Sep 17 00:00:00 2001 From: Proudlock <270015376@geaerospace.com> Date: Tue, 8 Sep 2026 18:16:27 -0400 Subject: [PATCH 10/17] Fix UI thread deadlock in fleet alert acknowledgment - Acknowledgment now marks locally first, then posts to server in background - Status check timer runs async properly without blocking UI thread - Both changes use Task.Run to avoid .GetAwaiter().GetResult() deadlocks - Bump tray to 1.6.4, agent to 1.13.3 --- .../FleetAlertForm.cs | 33 +++++++++++-------- .../FleetAlertWorker.cs | 31 ++++++++--------- .../ShopDbEndpointAgent.Tray.csproj | 5 ++- .../ShopDbEndpointAgent.csproj | 4 ++- 4 files changed, 43 insertions(+), 30 deletions(-) diff --git a/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs b/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs index a838f434..4b88913d 100644 --- a/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs +++ b/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs @@ -68,26 +68,33 @@ internal sealed class FleetAlertForm : Form statusCheckTimer.Start(); } - private async void StatusCheckTimer_Tick(object? sender, EventArgs e) + private void StatusCheckTimer_Tick(object? sender, EventArgs e) { if (client == null || hostname == null) return; - try + Task.Run(async () => { - var alerts = await client.FetchAlertsAsync(hostname, CancellationToken.None); - var stillActive = alerts?.Any(a => a.NotificationId == alert.NotificationId) ?? false; - - if (!stillActive) + try { - statusCheckTimer?.Stop(); - Close(); + var alerts = await client.FetchAlertsAsync(hostname, CancellationToken.None) + .ConfigureAwait(false); + var stillActive = alerts?.Any(a => a.NotificationId == alert.NotificationId) ?? false; + + if (!stillActive) + { + BeginInvoke(new Action(() => + { + statusCheckTimer?.Stop(); + Close(); + })); + } } - } - catch - { - // Ignore errors - keep the form open if we can't check status - } + catch + { + // Ignore errors - keep the form open if we can't check status + } + }); } private void ApplyRoundedCorners() diff --git a/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs b/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs index d097b3fb..d9ec6a69 100644 --- a/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs +++ b/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs @@ -159,24 +159,25 @@ internal sealed class FleetAlertWorker : IDisposable private void OnAlertAcknowledged(FleetAlert alert, CancellationToken token) { - try - { - logger.LogInformation( - "Fleet alert {NotificationId} acknowledged; posting to server.", alert.NotificationId); + logger.LogInformation( + "Fleet alert {NotificationId} acknowledged; queuing server post.", alert.NotificationId); - client.AcknowledgeAlertAsync(alert.NotificationId, assetId, hostname, token) - .GetAwaiter() - .GetResult(); + store.MarkAcknowledged(alert.NotificationId); - store.MarkAcknowledged(alert.NotificationId); - logger.LogInformation("Fleet alert {NotificationId} recorded.", alert.NotificationId); - } - catch (Exception exception) + Task.Run(async () => { - logger.LogWarning(exception, - "Could not acknowledge fleet alert {NotificationId}; will retry on next poll.", - alert.NotificationId); - } + try + { + await client.AcknowledgeAlertAsync(alert.NotificationId, assetId, hostname, token); + logger.LogInformation("Fleet alert {NotificationId} posted to server.", alert.NotificationId); + } + catch (Exception exception) + { + logger.LogWarning(exception, + "Could not post acknowledgment for fleet alert {NotificationId}; already marked locally.", + alert.NotificationId); + } + }); } public void Dispose() diff --git a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj index 9b788fff..4c2e0398 100644 --- a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj +++ b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj @@ -79,7 +79,10 @@ - 1.6.3 + + 1.6.4 true win-x64 true diff --git a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj index 6e853e28..1400b34b 100644 --- a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj +++ b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj @@ -175,7 +175,9 @@ No agent code changes, but deployed together for version consistency. --> - 1.13.2 + + 1.13.3 true win-x64 true From deb2bbe9df583203fd23e7bb5947d5da614b0a81 Mon Sep 17 00:00:00 2001 From: Proudlock <270015376@geaerospace.com> Date: Tue, 8 Sep 2026 18:22:24 -0400 Subject: [PATCH 11/17] Fix resource leaks and disposal issues in fleet alert tray - Dispose GraphicsPath after creating Region to prevent GDI handle leak - Track and dispose all Font objects to prevent GDI+ resource leak - Join worker thread before disposal to ensure client not used after disposal - Fix Screen.PrimaryScreen null dereference with fallback rectangle - Propagate cancellation token to FetchAlertsAsync to stop orphaned requests - Bump tray to 1.6.5, agent to 1.13.4 --- .../FleetAlertForm.cs | 39 ++++++++++++++----- .../FleetAlertWorker.cs | 9 +++++ .../ShopDbEndpointAgent.Tray.csproj | 6 ++- .../ShopDbEndpointAgent.csproj | 4 +- 4 files changed, 47 insertions(+), 11 deletions(-) diff --git a/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs b/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs index 4b88913d..617a92db 100644 --- a/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs +++ b/agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs @@ -16,8 +16,10 @@ internal sealed class FleetAlertForm : Form private readonly string? hostname; private System.Windows.Forms.Timer? countdownTimer; private System.Windows.Forms.Timer? statusCheckTimer; + private readonly CancellationTokenSource statusCheckCancellation = new(); private int remainingSeconds; private Label? countdownLabel; + private readonly List fonts = new(); public event EventHandler? OnAcknowledgeClicked; @@ -35,7 +37,7 @@ internal sealed class FleetAlertForm : Form ClientSize = new Size(400, 280); BackColor = Color.White; - var screen = Screen.PrimaryScreen?.WorkingArea ?? Screen.PrimaryScreen!.Bounds; + 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(); @@ -49,6 +51,7 @@ internal sealed class FleetAlertForm : Form { countdownTimer?.Stop(); statusCheckTimer?.Stop(); + statusCheckCancellation.Cancel(); } private void FleetAlertForm_Load(object? sender, EventArgs e) @@ -77,7 +80,7 @@ internal sealed class FleetAlertForm : Form { try { - var alerts = await client.FetchAlertsAsync(hostname, CancellationToken.None) + var alerts = await client.FetchAlertsAsync(hostname, statusCheckCancellation.Token) .ConfigureAwait(false); var stillActive = alerts?.Any(a => a.NotificationId == alert.NotificationId) ?? false; @@ -90,6 +93,10 @@ internal sealed class FleetAlertForm : Form })); } } + catch (OperationCanceledException) + { + // Form is closing, ignore + } catch { // Ignore errors - keep the form open if we can't check status @@ -99,7 +106,7 @@ internal sealed class FleetAlertForm : Form private void ApplyRoundedCorners() { - var path = new GraphicsPath(); + using var path = new GraphicsPath(); int radius = 8; var rect = new Rectangle(0, 0, Width, Height); @@ -146,6 +153,13 @@ internal sealed class FleetAlertForm : Form } } + private Font CreateFont(string family, float size, FontStyle style = FontStyle.Regular) + { + var font = new Font(family, size, style); + fonts.Add(font); + return font; + } + private void BuildLayout() { var borderPanel = new Panel @@ -182,7 +196,7 @@ internal sealed class FleetAlertForm : Form Left = 16, Top = 14, Width = 300, - Font = new Font("Segoe UI", 10, FontStyle.Bold), + Font = CreateFont("Segoe UI", 10, FontStyle.Bold), ForeColor = Color.FromArgb(51, 51, 51), }; headerPanel.Controls.Add(typeLabel); @@ -195,7 +209,7 @@ internal sealed class FleetAlertForm : Form Width = 24, Height = 24, FlatStyle = FlatStyle.Flat, - Font = new Font("Segoe UI", 14), + Font = CreateFont("Segoe UI", 14), ForeColor = Color.FromArgb(102, 102, 102), BackColor = Color.Transparent, Cursor = Cursors.Hand, @@ -212,7 +226,7 @@ internal sealed class FleetAlertForm : Form Top = 58, Width = 360, Height = 50, - Font = new Font("Segoe UI", 11, FontStyle.Bold), + Font = CreateFont("Segoe UI", 11, FontStyle.Bold), ForeColor = Color.FromArgb(51, 51, 51), AutoSize = false, }; @@ -225,7 +239,7 @@ internal sealed class FleetAlertForm : Form Top = 115, Width = 360, Height = 80, - Font = new Font("Segoe UI", 9.75f), + Font = CreateFont("Segoe UI", 9.75f), ForeColor = Color.FromArgb(85, 85, 85), AutoSize = false, }; @@ -241,7 +255,7 @@ internal sealed class FleetAlertForm : Form BackColor = Color.FromArgb(76, 175, 80), ForeColor = Color.White, FlatStyle = FlatStyle.Flat, - Font = new Font("Segoe UI", 9, FontStyle.Bold), + Font = CreateFont("Segoe UI", 9, FontStyle.Bold), Cursor = Cursors.Hand, }; acknowledgeButton.FlatAppearance.BorderSize = 0; @@ -277,7 +291,7 @@ internal sealed class FleetAlertForm : Form Width = 395, Height = 20, TextAlign = ContentAlignment.MiddleCenter, - Font = new Font("Segoe UI", 8.25f), + Font = CreateFont("Segoe UI", 8.25f), ForeColor = Color.FromArgb(153, 153, 153), }; footerPanel.Controls.Add(countdownLabel); @@ -329,6 +343,13 @@ internal sealed class FleetAlertForm : Form countdownTimer?.Dispose(); statusCheckTimer?.Stop(); statusCheckTimer?.Dispose(); + statusCheckCancellation.Cancel(); + statusCheckCancellation.Dispose(); + foreach (var font in fonts) + { + font.Dispose(); + } + fonts.Clear(); } base.Dispose(disposing); } diff --git a/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs b/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs index d9ec6a69..37711abb 100644 --- a/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs +++ b/agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs @@ -183,6 +183,15 @@ internal sealed class FleetAlertWorker : IDisposable public void Dispose() { cancellation.Cancel(); + + if (workerThread.IsAlive) + { + if (!workerThread.Join(TimeSpan.FromSeconds(5))) + { + logger.LogWarning("Fleet alert worker thread did not exit within timeout."); + } + } + cancellation.Dispose(); } } diff --git a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj index 4c2e0398..c9223298 100644 --- a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj +++ b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj @@ -82,7 +82,11 @@ - 1.6.4 + + 1.6.5 true win-x64 true diff --git a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj index 1400b34b..bf4f7924 100644 --- a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj +++ b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj @@ -177,7 +177,9 @@ No agent code changes, but deployed together for version consistency. --> - 1.13.3 + + 1.13.4 true win-x64 true From f5d1b12e3fa4cc3d11388fefe74bc4b423b3e6c8 Mon Sep 17 00:00:00 2001 From: Proudlock <270015376@geaerospace.com> Date: Wed, 9 Sep 2026 08:18:44 -0400 Subject: [PATCH 12/17] Add email alerts for operator issue reports - Send alert to default alert recipients when operator submits issue report - Email includes device link, hostname, asset tag, reason, details, and timestamp - Screenshot link included when available - Uses existing send_alert() mailer infrastructure --- plugins/endpoints/api/routes.py | 46 +++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index 470fbf54..9f9fff9d 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -32,6 +32,7 @@ from ..models import ( ) from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title +from shopdb.utils.mailer import send_alert endpoints_bp = Blueprint('endpoints', __name__) @@ -2935,6 +2936,45 @@ def _purge_expired_operator_reports(): db.session.commit() +def _send_operator_report_alert(report, asset_id, hostname): + """Send email alert for new operator issue report to default alert recipients.""" + from flask import url_for + + asset = Asset.query.get(asset_id) + if not asset: + return + + device_url = url_for('assets.detail', asset_id=asset_id, _external=True) + reported_at = report.createdat.strftime('%Y-%m-%d %H:%M:%S UTC') if report.createdat else 'Unknown' + + screenshot_info = '' + if report.imagefilename: + screenshot_url = url_for('endpoints.serve_operator_report_image', + operatorreportid=report.operatorreportid, + _external=True) + screenshot_info = f'

Screenshot: View Screenshot

' + + details_html = f'

{report.details or "No additional details provided."}

' + + html_body = f''' +
+

New Issue Report from Shop Floor

+

Device: {asset.computername or hostname} ({hostname})

+

Asset Tag: {asset.assettag or 'N/A'}

+

Reason: {report.reason}

+

Reported: {reported_at}

+
+

Details:

+{details_html} +{screenshot_info} +

View Device in ShopDB

+''' + + subject = f'Operator Issue Report: {asset.computername or hostname}' + + send_alert(subject, html_body) + + @endpoints_bp.route('/operatorreports', methods=['POST']) def upload_operator_report(): """Agent uploads one operator issue report (collector-token auth). @@ -3031,6 +3071,12 @@ def upload_operator_report(): except Exception: db.session.rollback() + # Send email alert to default alert recipients + try: + _send_operator_report_alert(report, asset_id, hostname) + except Exception as e: + current_app.logger.warning('Failed to send operator report alert email: %s', e) + return success_response(report.to_dict(), message='Operator report received', http_code=201) From 6e497a47517775584d9dcc63548c9ce19e6e61ed Mon Sep 17 00:00:00 2001 From: Proudlock <270015376@geaerospace.com> Date: Wed, 9 Sep 2026 09:02:43 -0400 Subject: [PATCH 13/17] Fix operator report email alerts with proper template and logging - Use render_email() to wrap HTML in proper email template - Add error handling and logging for URL generation - Add log statement before sending to aid debugging - Import render_email at module level --- plugins/endpoints/api/routes.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index 9f9fff9d..533ba1a4 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -32,7 +32,7 @@ from ..models import ( ) from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title -from shopdb.utils.mailer import send_alert +from shopdb.utils.mailer import send_alert, render_email endpoints_bp = Blueprint('endpoints', __name__) @@ -2942,21 +2942,30 @@ def _send_operator_report_alert(report, asset_id, hostname): asset = Asset.query.get(asset_id) if not asset: + current_app.logger.warning('Cannot send operator report alert: asset %d not found', asset_id) return - device_url = url_for('assets.detail', asset_id=asset_id, _external=True) + try: + device_url = url_for('assets.detail', asset_id=asset_id, _external=True) + except Exception as e: + current_app.logger.error('Failed to generate device URL: %s', e) + device_url = f'/shopdb/assets/{asset_id}' + reported_at = report.createdat.strftime('%Y-%m-%d %H:%M:%S UTC') if report.createdat else 'Unknown' screenshot_info = '' if report.imagefilename: - screenshot_url = url_for('endpoints.serve_operator_report_image', - operatorreportid=report.operatorreportid, - _external=True) - screenshot_info = f'

Screenshot: View Screenshot

' + try: + screenshot_url = url_for('endpoints.serve_operator_report_image', + operatorreportid=report.operatorreportid, + _external=True) + screenshot_info = f'

Screenshot: View Screenshot

' + except Exception as e: + current_app.logger.warning('Failed to generate screenshot URL: %s', e) details_html = f'

{report.details or "No additional details provided."}

' - html_body = f''' + body_html = f'''

New Issue Report from Shop Floor

Device: {asset.computername or hostname} ({hostname})

@@ -2971,8 +2980,11 @@ def _send_operator_report_alert(report, asset_id, hostname): ''' subject = f'Operator Issue Report: {asset.computername or hostname}' + html, text = render_email(subject, body_html) - send_alert(subject, html_body) + current_app.logger.info('Sending operator report alert for %s (report %d)', + hostname, report.operatorreportid) + send_alert(subject, html, text=text) @endpoints_bp.route('/operatorreports', methods=['POST']) From 31c616abefaa7317574add9183cfd8e0671cfd88 Mon Sep 17 00:00:00 2001 From: Proudlock <270015376@geaerospace.com> Date: Wed, 9 Sep 2026 09:10:43 -0400 Subject: [PATCH 14/17] Fix critical XSS vulnerabilities in operator report email alerts SECURITY FIXES: - Escape all user-controlled values (hostname, reason, details) to prevent XSS - Fix endpoint name from 'assets.detail' to 'assets.get_asset' - Fix fallback URL to be absolute instead of relative path - Remove render_email() to avoid subject duplication in email body - Check recipients exist before expensive HTML generation - Build email template directly with proper escaping throughout --- plugins/endpoints/api/routes.py | 55 +++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index 533ba1a4..3a0e1a57 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -32,7 +32,8 @@ from ..models import ( ) from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for, PLACE_POLICY_HOLD, ACTIVE_POLICY_HOLDS, HOLDABLE_ACTIONS_FOR, RELEASE_POLICY_HOLD, ACTIVE_LOCK_FOR, PLACE_ENDPOINT_LOCK, RELEASE_ENDPOINT_LOCK from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title -from shopdb.utils.mailer import send_alert, render_email +from shopdb.utils.mailer import send_alert +from markupsafe import escape endpoints_bp = Blueprint('endpoints', __name__) @@ -2938,18 +2939,30 @@ def _purge_expired_operator_reports(): def _send_operator_report_alert(report, asset_id, hostname): """Send email alert for new operator issue report to default alert recipients.""" - from flask import url_for + from flask import url_for, request + from shopdb.utils.mailer import get_smtp_config, _normalize_recipients + + # Check if we have recipients before doing expensive work + config = get_smtp_config() + recipients = _normalize_recipients(config['alert_recipients']) + if not recipients: + current_app.logger.debug('Skipping operator report alert: no alert recipients configured') + return asset = Asset.query.get(asset_id) if not asset: current_app.logger.warning('Cannot send operator report alert: asset %d not found', asset_id) return + # Generate absolute URLs try: - device_url = url_for('assets.detail', asset_id=asset_id, _external=True) + device_url = url_for('assets.get_asset', asset_id=asset_id, _external=True) except Exception as e: current_app.logger.error('Failed to generate device URL: %s', e) - device_url = f'/shopdb/assets/{asset_id}' + # Build absolute URL manually using request context + scheme = request.scheme if request else 'https' + host = request.host if request else 'localhost' + device_url = f'{scheme}://{host}/shopdb/assets/{asset_id}' reported_at = report.createdat.strftime('%Y-%m-%d %H:%M:%S UTC') if report.createdat else 'Unknown' @@ -2959,32 +2972,42 @@ def _send_operator_report_alert(report, asset_id, hostname): screenshot_url = url_for('endpoints.serve_operator_report_image', operatorreportid=report.operatorreportid, _external=True) - screenshot_info = f'

Screenshot: View Screenshot

' + screenshot_info = f'

Screenshot: View Screenshot

' except Exception as e: current_app.logger.warning('Failed to generate screenshot URL: %s', e) - details_html = f'

{report.details or "No additional details provided."}

' + # HTML-escape all user-controlled values to prevent XSS + safe_hostname = escape(hostname) + safe_computername = escape(asset.computername or hostname) + safe_assettag = escape(asset.assettag or 'N/A') + safe_reason = escape(report.reason) + safe_details = escape(report.details or 'No additional details provided.') - body_html = f''' + # Build HTML body (don't use render_email to avoid subject duplication) + html_body = f''' +
+

Operator Issue Report

New Issue Report from Shop Floor

-

Device: {asset.computername or hostname} ({hostname})

-

Asset Tag: {asset.assettag or 'N/A'}

-

Reason: {report.reason}

-

Reported: {reported_at}

+

Device: {safe_computername} ({safe_hostname})

+

Asset Tag: {safe_assettag}

+

Reason: {safe_reason}

+

Reported: {escape(reported_at)}

Details:

-{details_html} +

{safe_details}

{screenshot_info} -

View Device in ShopDB

+

View Device in ShopDB

+
+

Sent by ShopDB.

+
''' - subject = f'Operator Issue Report: {asset.computername or hostname}' - html, text = render_email(subject, body_html) + subject = f'Operator Issue Report: {safe_computername}' current_app.logger.info('Sending operator report alert for %s (report %d)', hostname, report.operatorreportid) - send_alert(subject, html, text=text) + send_alert(subject, html_body) @endpoints_bp.route('/operatorreports', methods=['POST']) From 25b04e79d6748ca846043157988127e714646db3 Mon Sep 17 00:00:00 2001 From: Proudlock <270015376@geaerospace.com> Date: Wed, 9 Sep 2026 09:20:04 -0400 Subject: [PATCH 15/17] Add verbose logging to debug operator report email alerts - Log when function is called with report details - Log SMTP config and recipients being used - Log before and after email send attempt - Change warning to error with full traceback for failures --- plugins/endpoints/api/routes.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index 3a0e1a57..f407e7d8 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -2942,12 +2942,18 @@ def _send_operator_report_alert(report, asset_id, hostname): from flask import url_for, request from shopdb.utils.mailer import get_smtp_config, _normalize_recipients + current_app.logger.info('_send_operator_report_alert called for report %d, asset %d, hostname %s', + report.operatorreportid, asset_id, hostname) + # Check if we have recipients before doing expensive work config = get_smtp_config() + current_app.logger.info('SMTP config: enabled=%s, host=%s, alert_recipients=%s', + config.get('enabled'), config.get('host'), config.get('alert_recipients')) recipients = _normalize_recipients(config['alert_recipients']) if not recipients: - current_app.logger.debug('Skipping operator report alert: no alert recipients configured') + current_app.logger.warning('Skipping operator report alert: no alert recipients configured') return + current_app.logger.info('Will send alert to %d recipient(s): %s', len(recipients), recipients) asset = Asset.query.get(asset_id) if not asset: @@ -3107,10 +3113,12 @@ def upload_operator_report(): db.session.rollback() # Send email alert to default alert recipients + current_app.logger.info('Operator report %d saved, attempting to send email alert', report.operatorreportid) try: _send_operator_report_alert(report, asset_id, hostname) + current_app.logger.info('Email alert function completed for report %d', report.operatorreportid) except Exception as e: - current_app.logger.warning('Failed to send operator report alert email: %s', e) + current_app.logger.error('Failed to send operator report alert email for report %d: %s', report.operatorreportid, e, exc_info=True) return success_response(report.to_dict(), message='Operator report received', http_code=201) From 0ede46de6884f7f369eca1b3414001c39f769a06 Mon Sep 17 00:00:00 2001 From: Proudlock <270015376@geaerospace.com> Date: Wed, 9 Sep 2026 09:26:10 -0400 Subject: [PATCH 16/17] Fix AttributeError in operator report email alerts - Change asset.computername to asset.name (correct attribute) - Change asset.assettag to asset.assetnumber (correct attribute) - Update email label from 'Asset Tag' to 'Asset Number' --- plugins/endpoints/api/routes.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index f407e7d8..f29ab32c 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -2984,8 +2984,8 @@ def _send_operator_report_alert(report, asset_id, hostname): # HTML-escape all user-controlled values to prevent XSS safe_hostname = escape(hostname) - safe_computername = escape(asset.computername or hostname) - safe_assettag = escape(asset.assettag or 'N/A') + safe_name = escape(asset.name or hostname) + safe_assetnumber = escape(asset.assetnumber or 'N/A') safe_reason = escape(report.reason) safe_details = escape(report.details or 'No additional details provided.') @@ -2995,8 +2995,8 @@ def _send_operator_report_alert(report, asset_id, hostname):

Operator Issue Report

New Issue Report from Shop Floor

-

Device: {safe_computername} ({safe_hostname})

-

Asset Tag: {safe_assettag}

+

Device: {safe_name} ({safe_hostname})

+

Asset Number: {safe_assetnumber}

Reason: {safe_reason}

Reported: {escape(reported_at)}

@@ -3009,7 +3009,7 @@ def _send_operator_report_alert(report, asset_id, hostname):
''' - subject = f'Operator Issue Report: {safe_computername}' + subject = f'Operator Issue Report: {safe_name}' current_app.logger.info('Sending operator report alert for %s (report %d)', hostname, report.operatorreportid) From f736089698e2c08e1f40c0fedf6a9b0bc7894ea9 Mon Sep 17 00:00:00 2001 From: Proudlock <270015376@geaerospace.com> Date: Wed, 9 Sep 2026 09:30:33 -0400 Subject: [PATCH 17/17] Make asset number clickable link to device page in email --- plugins/endpoints/api/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index f29ab32c..9cb24db2 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -2996,7 +2996,7 @@ def _send_operator_report_alert(report, asset_id, hostname):

New Issue Report from Shop Floor

Device: {safe_name} ({safe_hostname})

-

Asset Number: {safe_assetnumber}

+

Asset Number: {safe_assetnumber}

Reason: {safe_reason}

Reported: {escape(reported_at)}