From 51ad18ca8183b3f43c186ec1034c1a321a3007d0 Mon Sep 17 00:00:00 2001 From: "benjamin.baker" Date: Wed, 2 Sep 2026 19:04:02 -0400 Subject: [PATCH] report and cache the endpoint tray version The agent already reported agentversion but the interactive tray app, which ships and updates independently, had no reported version. The agent now reads the tray exe file version and reports it as trayversion; EndpointState caches it (migration 0011) and the Fleet card surfaces it beside Agent. The tray csproj gains an explicit Version so the file version is stamped. Groundwork for per-PC version surfacing and push-update targeting. --- agent/ShopDbEndpointAgent.Core/Contracts.cs | 1 + .../EndpointCollector.cs | 29 +++++++++++ .../ShopDbEndpointAgent.Tray.csproj | 5 ++ plugins/endpoints/AGENT-CONTRACT.md | 2 + plugins/endpoints/api/routes.py | 1 + .../versions/0011_endpoint_trayversion.py | 49 +++++++++++++++++++ plugins/endpoints/models/endpoint.py | 6 +++ plugins/endpoints/plugin.py | 5 +- tests/test_plugin_migrations.py | 3 +- 9 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 plugins/endpoints/migrations/versions/0011_endpoint_trayversion.py diff --git a/agent/ShopDbEndpointAgent.Core/Contracts.cs b/agent/ShopDbEndpointAgent.Core/Contracts.cs index 37a7f000..d1c18f0f 100644 --- a/agent/ShopDbEndpointAgent.Core/Contracts.cs +++ b/agent/ShopDbEndpointAgent.Core/Contracts.cs @@ -19,6 +19,7 @@ public sealed class EndpointReport [JsonPropertyName("ipaddress")] public string? IpAddress { get; set; } [JsonPropertyName("machinenumber")] public string? MachineNumber { get; set; } [JsonPropertyName("agentversion")] public string? AgentVersion { get; set; } + [JsonPropertyName("trayversion")] public string? TrayVersion { get; set; } [JsonPropertyName("lastboottime")] public DateTimeOffset? LastBootTime { get; set; } [JsonPropertyName("uptimeseconds")] public long? UptimeSeconds { get; set; } [JsonPropertyName("idleseconds")] public long? IdleSeconds { get; set; } diff --git a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs index c230d11b..2486a66e 100644 --- a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs +++ b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs @@ -37,6 +37,7 @@ public sealed class EndpointCollector : ICollector { Hostname = Environment.MachineName, AgentVersion = agentVersion, + TrayVersion = GetTrayVersion(), Adopt = adopt ? true : null, LoggedInUser = GetLoggedInUser(), SerialNumber = GetSerialNumber(), @@ -65,6 +66,34 @@ public sealed class EndpointCollector : ICollector return report; } + // The interactive tray app ships and updates independently of this service, + // so the agent reports the tray's file version as its own field. Resolve the + // tray exe beside the agent first (the normal install layout), then the + // default install dir; degrade to null when it is absent or unreadable so a + // tray-less install simply omits the field rather than failing the report. + private const string TrayExeName = "shopdb-endpoint-tray.exe"; + private const string DefaultInstallDir = @"C:\Program Files\ShopDB\EndpointAgent"; + + private string? GetTrayVersion() + { + try + { + var beside = Path.Combine(AppContext.BaseDirectory, TrayExeName); + var installed = Path.Combine(DefaultInstallDir, TrayExeName); + var trayExe = File.Exists(beside) ? beside + : File.Exists(installed) ? installed + : null; + if (trayExe is null) + return null; + return FileVersionInfo.GetVersionInfo(trayExe).FileVersion; + } + catch (Exception ex) + { + logger.LogDebug(ex, "could not resolve tray version"); + return null; + } + } + // Combined watch list: interactive processes (by image name) and Windows // services (by service key name). Both land in report.Services with a shared // {name, app, kind, state, running, count} shape so the server/UI renders diff --git a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj index 365c1e5f..322eb69c 100644 --- a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj +++ b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj @@ -8,6 +8,11 @@ true ShopDbEndpointAgent.Tray shopdb-endpoint-tray + + 1.0.0 true win-x64 true diff --git a/plugins/endpoints/AGENT-CONTRACT.md b/plugins/endpoints/AGENT-CONTRACT.md index 6c6b2764..ff46608c 100644 --- a/plugins/endpoints/AGENT-CONTRACT.md +++ b/plugins/endpoints/AGENT-CONTRACT.md @@ -32,6 +32,7 @@ arrays. `adopt` is first-contact only. "pctype": "CNC Controller", // adoption subset "machinenumber": "12345", // adoption subset; "" or "9999" == none "agentversion": "1.0.0", + "trayversion": "1.0.0", // file version of the interactive tray exe, omitted if no tray "lastboottime": "2025-01-14T06:12:00Z", // ISO-8601 UTC "uptimeseconds": 86400, "idleseconds": 120, @@ -126,6 +127,7 @@ public sealed class EndpointReport [JsonPropertyName("pctype")] public string? PcType { get; set; } [JsonPropertyName("machinenumber")] public string? MachineNumber { get; set; } [JsonPropertyName("agentversion")] public string? AgentVersion { get; set; } + [JsonPropertyName("trayversion")] public string? TrayVersion { get; set; } [JsonPropertyName("lastboottime")] public DateTimeOffset? LastBootTime { get; set; } [JsonPropertyName("uptimeseconds")] public long? UptimeSeconds { get; set; } [JsonPropertyName("idleseconds")] public long? IdleSeconds { get; set; } diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index 815340d7..58ff0549 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -444,6 +444,7 @@ def panel_status(asset_id: int): field('CPU %', state.cpupercent), field('Memory %', state.memorypercent), field('Agent', state.agentversion, mono=True), + field('Tray', state.trayversion, mono=True), field('Last reported', state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None), ] diff --git a/plugins/endpoints/migrations/versions/0011_endpoint_trayversion.py b/plugins/endpoints/migrations/versions/0011_endpoint_trayversion.py new file mode 100644 index 00000000..e5b557c2 --- /dev/null +++ b/plugins/endpoints/migrations/versions/0011_endpoint_trayversion.py @@ -0,0 +1,49 @@ +"""Cache the interactive tray app version on the endpoint state. + +Adds endpointstates.trayversion: the file version of the ShopDB endpoint tray +app, reported by the agent alongside its own agentversion. The tray and the +agent service ship and update independently (they are separate exes and can be +pushed apart), so the tray version is stored as its own field rather than +assumed equal to the agent (AGENT-CONTRACT.md 2). + +Nullable so an endpoint with no tray installed, or an older agent that predates +the field, reads as "unknown" rather than an empty string. + +Idempotent: the column-add checks the live schema first, so a db.create_all() +site (which built the column from the model already) and a re-run both stay +clean on strict MySQL 8. +""" +from alembic import op + +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'endpoints0011trayversion' +down_revision = 'endpoints0010renamepolicy' +branch_labels = None +depends_on = None + + +def _has_column(table_name, column_name): + from sqlalchemy import inspect + + inspector = inspect(op.get_bind()) + if not inspector.has_table(table_name): + return False + return any(col['name'] == column_name + for col in inspector.get_columns(table_name)) + + +def upgrade(): + if _has_column('endpointstates', 'trayversion'): + return + op.add_column( + 'endpointstates', + sa.Column('trayversion', sa.String(length=40), nullable=True)) + + +def downgrade(): + if not _has_column('endpointstates', 'trayversion'): + return + op.drop_column('endpointstates', 'trayversion') diff --git a/plugins/endpoints/models/endpoint.py b/plugins/endpoints/models/endpoint.py index 445a0a7d..fcd0d733 100644 --- a/plugins/endpoints/models/endpoint.py +++ b/plugins/endpoints/models/endpoint.py @@ -59,6 +59,11 @@ class EndpointState(db.Model): hostname = db.Column(db.String(100), nullable=True) loggedinuser = db.Column(db.String(100), nullable=True) agentversion = db.Column(db.String(40), nullable=True) + # The interactive tray app versions independently of the agent service, so + # it is reported and cached as its own field (AGENT-CONTRACT.md 2). Null + # when the endpoint has no tray installed or an older agent that predates + # the field. + trayversion = db.Column(db.String(40), nullable=True) # Fast-moving metrics lastboottime = db.Column(db.DateTime, nullable=True) @@ -105,6 +110,7 @@ class EndpointState(db.Model): 'hostname': self.hostname, 'loggedinuser': self.loggedinuser, 'agentversion': self.agentversion, + 'trayversion': self.trayversion, 'lastboottime': _utciso(self.lastboottime), 'uptimeseconds': self.uptimeseconds, 'idleseconds': self.idleseconds, diff --git a/plugins/endpoints/plugin.py b/plugins/endpoints/plugin.py index f35182ab..4557ec31 100644 --- a/plugins/endpoints/plugin.py +++ b/plugins/endpoints/plugin.py @@ -49,7 +49,7 @@ CURATED_EVENT_IDS = frozenset(EVENT_CATALOG) KNOWN_PAYLOAD_KEYS = frozenset({ 'hostname', 'adopt', 'loggedinuser', 'serialnumber', 'pctype', 'machinenumber', 'vendorname', 'modelnumber', 'osname', 'ipaddress', - 'agentversion', 'lastboottime', 'uptimeseconds', 'idleseconds', + 'agentversion', 'trayversion', 'lastboottime', 'uptimeseconds', 'idleseconds', 'cpupercent', 'memorypercent', 'services', 'ports', 'interfaces', 'scheduledtasks', 'applications', 'events', }) @@ -575,6 +575,7 @@ class EndpointsPlugin(BasePlugin): }, 'loggedinuser': {'type': 'string'}, 'agentversion': {'type': 'string'}, + 'trayversion': {'type': 'string'}, 'lastboottime': {'type': 'string', 'format': 'date-time'}, 'uptimeseconds': {'type': 'integer'}, 'idleseconds': {'type': 'integer'}, @@ -786,6 +787,8 @@ class EndpointsPlugin(BasePlugin): state.loggedinuser = payload.get('loggedinuser') if 'agentversion' in payload: state.agentversion = payload.get('agentversion') + if 'trayversion' in payload: + state.trayversion = payload.get('trayversion') if 'lastboottime' in payload: state.lastboottime = _parse_dt(payload.get('lastboottime')) if 'uptimeseconds' in payload: diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py index e1be5a7a..e2ce365f 100644 --- a/tests/test_plugin_migrations.py +++ b/tests/test_plugin_migrations.py @@ -99,7 +99,8 @@ EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof' # the operatorreports table for tray issue reports. # 0010 renames the shipped business policy off the originating site's # designator on installs that already stamped 0007 (ADR-015). -EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0010renamepolicy' +# 0011 adds the trayversion cache column to endpointstates. +EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0011trayversion' # Plugins built after the cutover: their 0001 baseline really creates tables the # core chain never owned.