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.