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.