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.
This commit is contained in:
benjamin.baker
2026-09-02 19:04:02 -04:00
parent 27ccf8962e
commit 51ad18ca81
9 changed files with 99 additions and 2 deletions

View File

@@ -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; }

View File

@@ -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),
]

View File

@@ -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')

View File

@@ -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,

View File

@@ -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: