Endpoint telemetry, PC Detail rework and a checked backup/restore path
This commit is contained in:
@@ -26,10 +26,18 @@ from shopdb.api import (success_response, error_response, ErrorCodes, AuditLog,
|
||||
from .routes import endpoints_bp
|
||||
from . import agentstore
|
||||
|
||||
# Hard cap on an uploaded agent binary. The real agent is a few MB; this is a
|
||||
# generous ceiling that still refuses an operator who points at a giant wrong
|
||||
# file before it is buffered fully into memory-staged storage.
|
||||
_MAX_UPLOAD_BYTES = 64 * 1024 * 1024
|
||||
# Hard cap on an uploaded agent binary. This is a generous ceiling that still
|
||||
# refuses an operator who points at a giant wrong file before it is buffered
|
||||
# fully into memory-staged storage.
|
||||
#
|
||||
# 128 MB, not 64: the self-contained single-file tray publishes at ~69 MB
|
||||
# (it carries the whole .NET runtime plus WinForms), which the old 64 MB cap
|
||||
# rejected outright. The upload failed on SIZE, but the operator saw only a
|
||||
# version-detection failure, because the size check returns before the PE is
|
||||
# ever parsed. Config.MAX_CONTENT_LENGTH must stay above this - it bounds the
|
||||
# whole multipart envelope, so a cap equal to it would reject a legal file on
|
||||
# the boundary bytes alone.
|
||||
_MAX_UPLOAD_BYTES = 128 * 1024 * 1024
|
||||
|
||||
|
||||
@endpoints_bp.route('/siteagent/binaries/<binkey>', methods=['GET'])
|
||||
@@ -69,11 +77,6 @@ def upload_agent_binary(binkey):
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'binkey must be one of: agent, tray',
|
||||
http_code=400)
|
||||
if not agentstore.is_valid_version(version):
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'version must be a dotted-numeric string such as 1.6.3',
|
||||
http_code=400)
|
||||
|
||||
upload = request.files.get('file')
|
||||
if upload is None or not upload.filename:
|
||||
@@ -94,6 +97,22 @@ def upload_agent_binary(binkey):
|
||||
'uploaded file is not a Windows executable',
|
||||
http_code=400)
|
||||
|
||||
# Gate the version at the PE, not at the raw input. Product versions carry
|
||||
# semver build metadata (1.5.0+abcdef...) that is not a legal directory
|
||||
# name, so the operator's typed string is only a hint. The dotted-numeric
|
||||
# FILE version stamped in the binary's PE resource is the authority; fall
|
||||
# back to it whenever the typed value is missing or not dotted-numeric, and
|
||||
# only reject when neither yields a usable version.
|
||||
if not agentstore.is_valid_version(version):
|
||||
detected = agentstore.read_pe_file_version(data)
|
||||
if agentstore.is_valid_version(detected):
|
||||
version = detected
|
||||
else:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'version must be a dotted-numeric string such as 1.6.3',
|
||||
http_code=400)
|
||||
|
||||
result = agentstore.store_version(binkey, version, data,
|
||||
make_current=makecurrent)
|
||||
|
||||
|
||||
@@ -87,6 +87,81 @@ def _format_uptime(seconds):
|
||||
return ' '.join(parts)
|
||||
|
||||
|
||||
def _state_json(state, attr):
|
||||
"""Parse one of the EndpointState inventory JSON blobs.
|
||||
|
||||
These columns are written straight from the agent snapshot, so a partial
|
||||
or malformed blob from one bad check-in must not take the whole panel
|
||||
down. Anything unparseable reads as absent.
|
||||
"""
|
||||
raw = getattr(state, attr, None) if state else None
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _format_bytes(value):
|
||||
"""Byte count -> 'N.N GB'. Sizes here are disks and RAM, never sub-MB."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
value = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
for unit in ('B', 'KB', 'MB', 'GB', 'TB'):
|
||||
if abs(value) < 1024.0 or unit == 'TB':
|
||||
return '{:.1f} {}'.format(value, unit) if unit != 'B' else '{:.0f} B'.format(value)
|
||||
value /= 1024.0
|
||||
return None
|
||||
|
||||
|
||||
def _format_agent_date(value):
|
||||
"""Agent timestamps are ISO strings, not datetimes; show the date only.
|
||||
|
||||
A BIOS release date or an OS install date is only ever read at day
|
||||
resolution, and the agent sends a full 7-digit-fraction UTC stamp.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
text = str(value)
|
||||
return text[:10] if len(text) >= 10 else text
|
||||
|
||||
|
||||
# A Windows event message runs to several hundred words. It is shown in an
|
||||
# expander that is closed by default, so the cap here only exists to keep one
|
||||
# pathological message from bloating the payload - not to fit a line. Blank
|
||||
# runs are collapsed but real line breaks are kept, because the body is read
|
||||
# as a block once opened.
|
||||
_EVENT_BODY_LIMIT = 4000
|
||||
|
||||
|
||||
def _event_body(event):
|
||||
"""Readable message body for one curated event, bounded and normalized.
|
||||
|
||||
Windows messages arrive padded with long runs of blank lines that render
|
||||
as ragged gaps. Runs of blank lines collapse to one and trailing spaces
|
||||
go, but single newlines survive so the text keeps its shape when opened.
|
||||
"""
|
||||
text = (getattr(event, 'eventmessage', None) or '').strip()
|
||||
if not text:
|
||||
return None
|
||||
lines = [line.rstrip() for line in text.splitlines()]
|
||||
cleaned = []
|
||||
for line in lines:
|
||||
if not line and cleaned and not cleaned[-1]:
|
||||
continue
|
||||
cleaned.append(line)
|
||||
text = '\n'.join(cleaned).strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) <= _EVENT_BODY_LIMIT:
|
||||
return text
|
||||
return text[:_EVENT_BODY_LIMIT].rstrip() + '...'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Endpoint state
|
||||
# =============================================================================
|
||||
@@ -419,6 +494,60 @@ def _drift_for_state(state):
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
|
||||
|
||||
def _dell_updates_summary(state):
|
||||
"""Parse the cached Dell Command | Update summary off an EndpointState.
|
||||
|
||||
dellupdatesjson is written on check-in from the shopdb-dellupdates agent
|
||||
utility, which reads the log the \\ShopDB-DellCommandScan task produces.
|
||||
Null means the endpoint has never reported a scan. Tolerant of a malformed
|
||||
blob so a bad cache never breaks the panel.
|
||||
"""
|
||||
raw = getattr(state, 'dellupdatesjson', None) if state else None
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
|
||||
|
||||
def _dell_updates_value(state):
|
||||
"""Render the informational 'Pending Dell updates' status value.
|
||||
|
||||
Informational only - this row never claims an endpoint needs attention,
|
||||
because a pending optional driver is not a support problem. Three states are
|
||||
kept distinct on purpose, and all three are VISIBLE:
|
||||
|
||||
never scanned -> 'Never scanned'. Deliberately not 'No': those are
|
||||
different facts, and claiming 'No' would tell an operator
|
||||
the machine is current when nobody has ever checked. It
|
||||
is also not hidden - a missing row reads as a broken
|
||||
panel, so the unknown state says so out loud.
|
||||
scanned clean -> 'No'.
|
||||
pending -> 'Yes (n)', with the count because one optional update and
|
||||
fourteen urgent ones warrant different urgency.
|
||||
"""
|
||||
summary = _dell_updates_summary(state)
|
||||
if not summary:
|
||||
return 'Never scanned'
|
||||
status = summary.get('status')
|
||||
if status == 'error':
|
||||
# The scan log exists but could not be read. A different problem from
|
||||
# never having scanned, and worth saying so rather than implying the
|
||||
# endpoint was never checked.
|
||||
return 'Unknown (scan unreadable)'
|
||||
if status != 'ok':
|
||||
return 'Never scanned'
|
||||
try:
|
||||
count = int(summary.get('count') or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 'Unknown (scan unreadable)'
|
||||
if count <= 0:
|
||||
return 'No'
|
||||
return 'Yes ({})'.format(count)
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/status', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_status(asset_id: int):
|
||||
@@ -432,24 +561,34 @@ def panel_status(asset_id: int):
|
||||
if not state:
|
||||
return success_response({'fields': []})
|
||||
|
||||
def field(label, value, mono=False):
|
||||
return {'label': label, 'value': value, 'mono': mono}
|
||||
def field(label, value, mono=False, fmt=None):
|
||||
entry = {'label': label, 'value': value, 'mono': mono}
|
||||
if fmt:
|
||||
entry['format'] = fmt
|
||||
return entry
|
||||
|
||||
fields = [
|
||||
field('Logged in user', state.loggedinuser),
|
||||
field('Uptime', _format_uptime(state.uptimeseconds)),
|
||||
field('Idle', _format_uptime(state.idleseconds)),
|
||||
field('Last boot',
|
||||
state.lastboottime.isoformat() + 'Z' if state.lastboottime else None),
|
||||
state.lastboottime.isoformat() + 'Z' if state.lastboottime else None,
|
||||
fmt='datetime'),
|
||||
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),
|
||||
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None,
|
||||
fmt='datetime'),
|
||||
]
|
||||
fields = [f for f in fields if f['value'] not in (None, '')]
|
||||
|
||||
# Informational only, and appended after the null-filter above because it
|
||||
# always renders - including an explicit "Never scanned", which a hidden row
|
||||
# would leave looking like a broken panel.
|
||||
fields.append(field('Pending Dell updates', _dell_updates_value(state)))
|
||||
|
||||
# Warn-only reconciler summary: monitors site policy expects to be running
|
||||
# that the last check-in did not report as running. Surfaced as a status
|
||||
# field so an operator sees the drift on the panel; nothing is auto-started.
|
||||
@@ -465,6 +604,492 @@ def panel_status(asset_id: int):
|
||||
return success_response({'fields': fields})
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/hardware', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_hardware(asset_id: int):
|
||||
"""Keyvalue panel: processor, memory and system chassis inventory.
|
||||
|
||||
Only reported by agent 1.7.0 and later. An older agent leaves these
|
||||
columns null, so the panel yields no fields and hides itself rather than
|
||||
rendering a card full of blanks.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
if not state:
|
||||
return success_response({'fields': []})
|
||||
|
||||
processor = _state_json(state, 'processorjson') or {}
|
||||
memory = _state_json(state, 'memoryjson') or {}
|
||||
|
||||
fields = [
|
||||
{'label': 'Processor', 'value': processor.get('name')},
|
||||
{'label': 'Cores', 'value': processor.get('cores')},
|
||||
{'label': 'Logical processors', 'value': processor.get('logicalprocessors')},
|
||||
{'label': 'Max clock',
|
||||
'value': '{} MHz'.format(processor['maxclockmhz'])
|
||||
if processor.get('maxclockmhz') else None},
|
||||
{'label': 'Total memory', 'value': _format_bytes(memory.get('totalbytes'))},
|
||||
{'label': 'Free memory', 'value': _format_bytes(memory.get('freebytes'))},
|
||||
]
|
||||
fields = [f for f in fields if f['value'] not in (None, '')]
|
||||
return success_response({'fields': fields})
|
||||
|
||||
|
||||
# Wire values to operator-readable text. The wire keys are stable contract;
|
||||
# these labels are not, so they live here rather than in the agent.
|
||||
_REBOOT_REASON_LABELS = {
|
||||
'componentbasedservicing': 'servicing',
|
||||
'windowsupdate': 'Windows Update',
|
||||
'pendingfilerename': 'files staged for replacement',
|
||||
}
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/osinfo', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_osinfo(asset_id: int):
|
||||
"""Keyvalue panel: operating system, firmware and domain membership.
|
||||
|
||||
OS and BIOS are one card on purpose: both answer "what is this box
|
||||
running", and split across two cards each would be three short rows.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
if not state:
|
||||
return success_response({'fields': []})
|
||||
|
||||
osinfo = _state_json(state, 'osinfojson') or {}
|
||||
bios = _state_json(state, 'biosjson') or {}
|
||||
bootinfo = _state_json(state, 'bootinfojson') or {}
|
||||
|
||||
pendingreboot = None
|
||||
if state.pendingreboot is not None:
|
||||
pendingreboot = 'Yes' if state.pendingreboot else 'No'
|
||||
# Say WHICH signal fired when we know. A staged file rename means the
|
||||
# box is mid-servicing; a lone servicing key can sit set for weeks on a
|
||||
# machine nobody rebooted. Same "Yes", very different urgency.
|
||||
reasons = _state_json(state, 'rebootreasonsjson')
|
||||
if state.pendingreboot and isinstance(reasons, list) and reasons:
|
||||
pendingreboot = '{} ({})'.format(
|
||||
pendingreboot, ', '.join(_REBOOT_REASON_LABELS.get(r, r)
|
||||
for r in reasons if isinstance(r, str)))
|
||||
|
||||
# Only worth a row when fast startup actually applies. On a box with it
|
||||
# disabled and a cold last boot there is nothing to warn about, and a
|
||||
# permanent "Cold" row is noise on a card that is already dense.
|
||||
boottype = bootinfo.get('lastboottype')
|
||||
if boottype in (None, '', 'Cold', 'Unknown'):
|
||||
boottype = None
|
||||
elif boottype == 'Fast startup':
|
||||
# Uptime is measured from the resume, not the last true boot, so it
|
||||
# understates how long the kernel has actually been up. Said plainly
|
||||
# because this is the field that makes uptime read wrong.
|
||||
boottype = 'Fast startup (uptime not since cold boot)'
|
||||
|
||||
fields = [
|
||||
{'label': 'OS version', 'value': osinfo.get('version'), 'mono': True},
|
||||
{'label': 'Build', 'value': osinfo.get('build'), 'mono': True},
|
||||
{'label': 'Architecture', 'value': osinfo.get('architecture')},
|
||||
{'label': 'Installed', 'value': _format_agent_date(osinfo.get('installdate'))},
|
||||
{'label': 'Domain', 'value': state.domain},
|
||||
{'label': 'BIOS vendor', 'value': bios.get('vendor')},
|
||||
{'label': 'BIOS version', 'value': bios.get('version'), 'mono': True},
|
||||
{'label': 'BIOS date', 'value': _format_agent_date(bios.get('releasedate'))},
|
||||
{'label': 'Last boot', 'value': boottype},
|
||||
{'label': 'Pending reboot', 'value': pendingreboot},
|
||||
]
|
||||
fields = [f for f in fields
|
||||
if f['value'] not in (None, '')]
|
||||
return success_response({'fields': fields})
|
||||
|
||||
|
||||
def _storage_rows(disks):
|
||||
"""Turn the agent's volume list into display rows.
|
||||
|
||||
freepercent is computed by the agent from 1.7.0; for a snapshot written
|
||||
by an older agent it is derived here from the byte counts so the column
|
||||
is not empty for the one field an operator actually scans.
|
||||
"""
|
||||
if not isinstance(disks, list):
|
||||
return []
|
||||
|
||||
rows = []
|
||||
for disk in disks:
|
||||
if not isinstance(disk, dict):
|
||||
continue
|
||||
freepercent = disk.get('freepercent')
|
||||
if freepercent is None:
|
||||
size = disk.get('sizebytes') or 0
|
||||
free = disk.get('freebytes')
|
||||
if size and free is not None:
|
||||
freepercent = round(free * 100.0 / size, 1)
|
||||
rows.append({
|
||||
'drive': disk.get('drive'),
|
||||
'label': disk.get('label') or '',
|
||||
'filesystem': disk.get('filesystem'),
|
||||
'size': _format_bytes(disk.get('sizebytes')),
|
||||
'free': _format_bytes(disk.get('freebytes')),
|
||||
'freepercent': '{}%'.format(freepercent) if freepercent is not None else None,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
_STORAGE_COLUMNS = [
|
||||
{'key': 'drive', 'label': 'Drive'},
|
||||
{'key': 'label', 'label': 'Label'},
|
||||
{'key': 'filesystem', 'label': 'Filesystem'},
|
||||
{'key': 'size', 'label': 'Size'},
|
||||
{'key': 'free', 'label': 'Free'},
|
||||
{'key': 'freepercent', 'label': 'Free %'},
|
||||
]
|
||||
|
||||
_DISKHEALTH_COLUMNS = [
|
||||
{'key': 'model', 'label': 'Drive'},
|
||||
{'key': 'mediatype', 'label': 'Media'},
|
||||
{'key': 'bustype', 'label': 'Bus'},
|
||||
{'key': 'healthstatus', 'label': 'Health'},
|
||||
{'key': 'verdict', 'label': 'Verdict'},
|
||||
{'key': 'source', 'label': 'Source'},
|
||||
]
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/storage', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_storage(asset_id: int):
|
||||
"""Tabs panel: volume capacity and physical drive health as two sections.
|
||||
|
||||
One card because an operator looking at a full disk and an operator looking
|
||||
at a dying disk are usually the same person in the same minute. TWO
|
||||
SECTIONS because the two do not share a key: storage is per volume (C:, D:)
|
||||
and health is per physical device, and one drive can back several volumes
|
||||
or none the operator sees. Nothing is reconciled across the sections -
|
||||
this is not the mapped-drive merge, where two sources described the same
|
||||
drive and the worse status won.
|
||||
|
||||
Both sections are emitted even when empty so the section keeps its own
|
||||
empty message; a dropped section would take its tab with it and read as
|
||||
"no disks" rather than "nothing reported".
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
disks = _state_json(state, 'disksjson') if state else None
|
||||
drives = _state_json(state, 'diskhealthjson') if state else None
|
||||
return success_response({'sections': [
|
||||
{
|
||||
'label': 'Volumes',
|
||||
'columns': _STORAGE_COLUMNS,
|
||||
'rows': _storage_rows(disks),
|
||||
'empty': 'No disks reported.',
|
||||
},
|
||||
{
|
||||
'label': 'Disk health',
|
||||
'columns': _DISKHEALTH_COLUMNS,
|
||||
'rows': _diskhealth_rows(drives),
|
||||
'empty': 'No disk health reported.',
|
||||
},
|
||||
]})
|
||||
|
||||
|
||||
def _diskhealth_rows(drives):
|
||||
"""Turn the agent's disk health list into display rows, worst first.
|
||||
|
||||
Both agent sources are kept rather than merged. MSFT_PhysicalDisk is the
|
||||
only source that reports NVMe at all, and Win32_DiskDrive covers hardware
|
||||
and hosts where the Storage namespace is unavailable. When both answer for
|
||||
the same hardware, two rows that disagree is the useful signal; collapsing
|
||||
them would hide exactly the case worth looking at.
|
||||
"""
|
||||
if not isinstance(drives, list):
|
||||
return []
|
||||
|
||||
rows = []
|
||||
for drive in drives:
|
||||
if not isinstance(drive, dict):
|
||||
continue
|
||||
predictfailure = drive.get('predictfailure')
|
||||
if predictfailure is None:
|
||||
verdict = 'Unknown'
|
||||
elif predictfailure:
|
||||
verdict = 'Failure predicted'
|
||||
else:
|
||||
verdict = 'OK'
|
||||
rows.append({
|
||||
'deviceid': drive.get('deviceid'),
|
||||
'model': drive.get('model') or '',
|
||||
'mediatype': drive.get('mediatype') or '',
|
||||
'bustype': drive.get('bustype') or '',
|
||||
'healthstatus': drive.get('healthstatus') or 'Unknown',
|
||||
'verdict': verdict,
|
||||
'source': drive.get('source') or '',
|
||||
})
|
||||
|
||||
# Failure first, then unknown, then OK. Unknown outranks OK because a drive
|
||||
# that stopped answering is a weaker claim of health than one that said OK.
|
||||
# An unrecognised verdict sorts as unknown rather than last, so a future
|
||||
# verdict string cannot bury itself under the healthy drives.
|
||||
order = {'Failure predicted': 0, 'Unknown': 1, 'OK': 2}
|
||||
rows.sort(key=lambda row: (order.get(row['verdict'], 1),
|
||||
row['deviceid'] or ''))
|
||||
return rows
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/diskhealth', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_diskhealth(asset_id: int):
|
||||
"""Physical drive health rows.
|
||||
|
||||
No longer backs a panel of its own - the Storage card renders it as a
|
||||
section. Kept as a route because it is a stable read for callers outside
|
||||
the panel renderer and removing it would break them for a UI-only change.
|
||||
|
||||
Rows are ordered so anything predicting failure sorts first: a failing
|
||||
drive must not be below the fold on a machine with several disks.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
drives = _state_json(state, 'diskhealthjson') if state else None
|
||||
return success_response(_diskhealth_rows(drives))
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/listeners', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_listeners(asset_id: int):
|
||||
"""Table panel: TCP sockets in LISTEN, with the owning process.
|
||||
|
||||
The listening set is the box's exposed surface, so it is sorted by port
|
||||
to make an unexpected one easy to spot. The full connection table is a
|
||||
deliberately on-demand command, not standard telemetry.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
ports = _state_json(state, 'portsjson') if state else None
|
||||
if not isinstance(ports, list):
|
||||
return success_response([])
|
||||
|
||||
rows = []
|
||||
for entry in ports:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
# Sample-data rows predate the agent listener collector and carry a
|
||||
# flat port/process shape; read both rather than showing them blank.
|
||||
port = entry.get('localport', entry.get('port'))
|
||||
rows.append({
|
||||
'localport': port,
|
||||
'localaddress': entry.get('localaddress'),
|
||||
'protocol': (entry.get('protocol') or 'tcp').upper(),
|
||||
'processname': entry.get('processname') or entry.get('process'),
|
||||
'processid': entry.get('processid'),
|
||||
})
|
||||
rows.sort(key=lambda r: (r['localport'] is None, r['localport']))
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
def _drive_letter(value):
|
||||
"""Normalise a drive to its bare letter so the two sources can be paired.
|
||||
|
||||
net use reports 'Z:' and Get-SmbMapping reports 'Z:' or occasionally a
|
||||
trailing separator, so compare on the letter alone.
|
||||
"""
|
||||
text = (value or '').strip().rstrip('\\/')
|
||||
return text.upper() or None
|
||||
|
||||
|
||||
# Worst-first. The panel shows the worse of the two sources for a drive, so a
|
||||
# share that one source calls OK and the other calls Unavailable is reported as
|
||||
# Unavailable: a drive that is broken in either view is broken for the operator.
|
||||
#
|
||||
# WARNING - ranks must stay DISTINCT across statuses that can genuinely differ.
|
||||
# _worse_drive_status resolves by min(), which breaks a tie by argument order,
|
||||
# so two statuses sharing a rank make the verdict depend on which SOURCE
|
||||
# happened to report which - the same drive reads healthy or broken based on
|
||||
# enumeration order. Deliberate synonyms are fine (disconnected/unavailable,
|
||||
# ok/connected) because either answer is the same answer. Adding a NEW status at
|
||||
# an existing rank is not. test_which_source_reported_a_status_does_not_change
|
||||
# _the_verdict in tests/test_plugins/test_endpoints_mapped_drives.py asserts
|
||||
# both directions of each pair and is what catches this; the single-direction
|
||||
# tests stay green through it.
|
||||
_DRIVE_STATUS_RANK = {
|
||||
'unavailable': 0,
|
||||
'disconnected': 0,
|
||||
'paused': 1,
|
||||
'reconnecting': 2,
|
||||
'ok': 3,
|
||||
'connected': 3,
|
||||
}
|
||||
|
||||
|
||||
def _worse_drive_status(left, right):
|
||||
"""Return whichever status is worse, preferring a known one over None.
|
||||
|
||||
An unrecognised status ranks as Reconnecting, deliberately below OK: a
|
||||
status we cannot interpret must not be assumed healthy.
|
||||
"""
|
||||
candidates = [s for s in (left, right) if s]
|
||||
if not candidates:
|
||||
return None
|
||||
unknown_rank = _DRIVE_STATUS_RANK['reconnecting']
|
||||
return min(
|
||||
candidates,
|
||||
key=lambda s: _DRIVE_STATUS_RANK.get(str(s).strip().lower(), unknown_rank)
|
||||
)
|
||||
|
||||
|
||||
def _mapped_drive_rows(context):
|
||||
"""Merge the two mapped-drive views into one structured row per letter.
|
||||
|
||||
Windows exposes mapped drives through Win32_NetworkConnection (the 'net use'
|
||||
table) and through Get-SmbMapping, and they do not always agree - a share can
|
||||
be Unavailable in one and OK or absent in the other. The probe collects both
|
||||
without reconciling them, so the disagreement survives to here.
|
||||
|
||||
A drive present in only one source is still a drive: it is reported with the
|
||||
status that source gave it, rather than dropped for lack of a second opinion.
|
||||
|
||||
WARNING - three behaviours here are load-bearing and easy to break while
|
||||
"simplifying". They are covered by tests/test_plugins/test_endpoints_mapped
|
||||
_drives.py; if you touch this, read those first:
|
||||
|
||||
1. Worse-status-wins. Never report the healthier of the two sources. A
|
||||
drive that either source calls Unavailable IS unavailable to the
|
||||
operator, and rendering it OK sends someone to a share that is down.
|
||||
2. Single-source drives are kept, not dropped. Get-SmbMapping is absent on
|
||||
some builds and session types; requiring agreement would silently hide
|
||||
every mapped drive on those machines.
|
||||
3. Unknown statuses rank as Reconnecting, not as OK. An unrecognised
|
||||
string must never be optimistically treated as healthy.
|
||||
|
||||
Changing any of these makes a broken drive look fine, which is a silent
|
||||
failure - the card stays green and nobody is told.
|
||||
"""
|
||||
drives = {}
|
||||
|
||||
netuse = context.get('netuse')
|
||||
if isinstance(netuse, list):
|
||||
for entry in netuse:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
letter = _drive_letter(entry.get('localname') or entry.get('local'))
|
||||
if not letter:
|
||||
continue
|
||||
drives[letter] = {
|
||||
'remote': entry.get('remotename') or entry.get('remote') or '',
|
||||
'status': entry.get('status'),
|
||||
}
|
||||
|
||||
smbmapping = context.get('smbmapping')
|
||||
if isinstance(smbmapping, list):
|
||||
for entry in smbmapping:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
letter = _drive_letter(entry.get('localpath'))
|
||||
if not letter:
|
||||
continue
|
||||
existing = drives.get(letter)
|
||||
if existing:
|
||||
existing['status'] = _worse_drive_status(
|
||||
existing.get('status'), entry.get('status'))
|
||||
existing['remote'] = existing['remote'] or entry.get('remotepath') or ''
|
||||
else:
|
||||
drives[letter] = {
|
||||
'remote': entry.get('remotepath') or '',
|
||||
'status': entry.get('status'),
|
||||
}
|
||||
|
||||
rows = []
|
||||
for letter in sorted(drives):
|
||||
drive = drives[letter]
|
||||
# A drive neither source gave a status for is Unknown, never blank. An
|
||||
# unannotated row reads as healthy, so a failed check would render as a
|
||||
# working drive - the one outcome this card must never produce.
|
||||
status = (drive.get('status') or '').strip() or 'Unknown'
|
||||
healthy = status.lower() in ('ok', 'connected')
|
||||
rows.append({
|
||||
'drive': letter,
|
||||
'remote': (drive.get('remote') or '').strip(),
|
||||
'statuslabel': status,
|
||||
# Only unhealthy drives are coloured. Green on every working drive
|
||||
# makes the broken one just another badge in a row of badges; the
|
||||
# card's job is to make the exception findable at a glance.
|
||||
'statecolor': 'green' if healthy else 'red',
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _mapped_drives(context):
|
||||
"""The mapped drives as one-line labels, for the user context card.
|
||||
|
||||
Built on _mapped_drive_rows so the summary line and the card can never
|
||||
disagree about a drive's status.
|
||||
"""
|
||||
labels = []
|
||||
for row in _mapped_drive_rows(context):
|
||||
label = '{} {}'.format(row['drive'], row['remote']).strip()
|
||||
# Only the problem states are annotated. Tagging every healthy drive
|
||||
# with "(OK)" would bury the one that is not.
|
||||
if row['statecolor'] != 'green':
|
||||
label = '{} ({})'.format(label, row['statuslabel'])
|
||||
labels.append(label)
|
||||
return labels
|
||||
|
||||
|
||||
def _mapped_drives_unhealthy_first(rows):
|
||||
"""Order drive rows so anything not healthy sorts first.
|
||||
|
||||
A user with a dozen mappings should not have to hunt for the broken one.
|
||||
Sorting only on health keeps it stable: drive-letter order survives within
|
||||
each group, so the list does not reshuffle between refreshes.
|
||||
"""
|
||||
return sorted(rows, key=lambda row: row['statecolor'] == 'green')
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/mappeddrives', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_mappeddrives(asset_id: int):
|
||||
"""List panel: mapped network drives with a health badge each.
|
||||
|
||||
Session-scoped: mapped drives do not exist in the agent's session 0, so
|
||||
these come from the interactive user probe. Nothing reported when nobody
|
||||
is signed in, which is a valid state and not a failure.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
context = _state_json(state, 'usercontextjson') if state else None
|
||||
if not isinstance(context, dict) or not context:
|
||||
return success_response([])
|
||||
return success_response(_mapped_drives_unhealthy_first(_mapped_drive_rows(context)))
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/usercontext', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_usercontext(asset_id: int):
|
||||
"""Keyvalue panel: session-scoped data from the interactive user probe.
|
||||
|
||||
Mapped drives and user-owned listeners do not exist in the agent's
|
||||
session 0, so they are collected by a probe run in the logged-in user's
|
||||
session. Nothing is reported when no one is signed in, which is a valid
|
||||
state and not a failure.
|
||||
|
||||
Mapped drives arrive from two sources that can disagree; the worse of the
|
||||
two statuses is shown so a half-broken share is never rendered as healthy.
|
||||
"""
|
||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||
if not state:
|
||||
return success_response({'fields': []})
|
||||
|
||||
context = _state_json(state, 'usercontextjson')
|
||||
if not isinstance(context, dict) or not context:
|
||||
return success_response({'fields': []})
|
||||
|
||||
drives = _mapped_drives(context)
|
||||
|
||||
listeners = context.get('userlisteners')
|
||||
listenercount = len(listeners) if isinstance(listeners, list) else None
|
||||
|
||||
fields = [
|
||||
{'label': 'Probed user', 'value': context.get('username')},
|
||||
{'label': 'Collected', 'value': context.get('collectedat')},
|
||||
{'label': 'Idle', 'value': _format_uptime(context.get('idleseconds'))},
|
||||
{'label': 'Mapped drives',
|
||||
'value': ', '.join(drives) if drives else None, 'mono': True},
|
||||
{'label': 'User listeners', 'value': listenercount},
|
||||
]
|
||||
fields = [f for f in fields if f['value'] not in (None, '')]
|
||||
return success_response({'fields': fields})
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/telemetrymeta', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_telemetry_meta(asset_id: int):
|
||||
@@ -723,10 +1348,18 @@ def panel_activity(asset_id: int):
|
||||
|
||||
rows = []
|
||||
for event in events:
|
||||
# The provider name is what distinguishes two events sharing an ID,
|
||||
# and the message body is the line an operator actually reads. Both
|
||||
# are already stored; the feed just was not showing them.
|
||||
source = event.eventsource or event.eventchannel
|
||||
detail = 'Windows event {}'.format(event.eventid)
|
||||
if source:
|
||||
detail = '{} - {}'.format(detail, source)
|
||||
rows.append({
|
||||
'title': event_title(event.eventid),
|
||||
'severity': event_severity(event.eventid),
|
||||
'detail': 'Windows event {}'.format(event.eventid),
|
||||
'detail': detail,
|
||||
'body': _event_body(event),
|
||||
'eventtime': event.eventtime,
|
||||
})
|
||||
|
||||
@@ -742,7 +1375,9 @@ def panel_activity(asset_id: int):
|
||||
'title': '{}{}'.format(label, target),
|
||||
# A failed command is the one an operator needs to notice.
|
||||
'severity': 'critical' if command.status == EndpointCommand.STATUS_FAILED else 'info',
|
||||
'detail': '{}{}'.format((command.status or 'queued').title(), who),
|
||||
'detail': '{}{}{}'.format('Command - ', (command.status or 'queued').title(), who),
|
||||
# A failed command's reason is the whole point of noticing it.
|
||||
'body': (command.resultmessage or '').strip() or None,
|
||||
'eventtime': command.createdat,
|
||||
})
|
||||
|
||||
@@ -768,10 +1403,37 @@ def panel_activity(asset_id: int):
|
||||
'severity': r['severity'],
|
||||
'severitycolor': colours.get(r['severity'], 'gray'),
|
||||
'detail': r['detail'],
|
||||
'body': r['body'],
|
||||
'eventtime': r['eventtime'].isoformat() + 'Z' if r['eventtime'] else None,
|
||||
} for r in rows[:15]])
|
||||
|
||||
|
||||
def _link_label(speedbps, fullduplex):
|
||||
"""Render NIC link speed and duplex as one cell.
|
||||
|
||||
Null speed means the agent could not read link state OR the adapter is
|
||||
disconnected - verified on a live box that a disconnected NIC returns empty
|
||||
values. Either way we do not know, so it reads '-' rather than 0 Mb.
|
||||
|
||||
Half duplex is called out explicitly because that is the actionable case: a
|
||||
NIC that negotiated half duplex on a switch port is a cable or port fault
|
||||
that otherwise presents as "the machine is slow sometimes". Full duplex is
|
||||
the normal case and gets no annotation, so the only thing standing out in
|
||||
the column is the thing worth looking at.
|
||||
"""
|
||||
if not speedbps:
|
||||
return '-'
|
||||
|
||||
if speedbps >= 1000000000:
|
||||
speed = '{:g} Gb'.format(speedbps / 1000000000)
|
||||
else:
|
||||
speed = '{:g} Mb'.format(speedbps / 1000000)
|
||||
|
||||
if fullduplex is False:
|
||||
return '{} half duplex'.format(speed)
|
||||
return speed
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/interfaces', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_interfaces(asset_id: int):
|
||||
@@ -809,6 +1471,99 @@ def panel_interfaces(asset_id: int):
|
||||
# A gatewayless NIC with an address is the machine link; the gateway
|
||||
# NIC is the corporate LAN.
|
||||
'role': 'Machine link' if (ipv4 and not gateway) else 'LAN',
|
||||
'link': _link_label(entry.get('linkspeedbps'),
|
||||
entry.get('fullduplex')),
|
||||
})
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
# Columns for the two cards carved out of the old hand-written Network card on
|
||||
# the Details tab. That card listed current addresses with a Primary badge and
|
||||
# nothing else, which undersold what the rows actually hold.
|
||||
_IPHISTORY_COLUMNS = [
|
||||
{'key': 'ipaddress', 'label': 'IP address'},
|
||||
{'key': 'macaddress', 'label': 'MAC'},
|
||||
{'key': 'status', 'label': 'Status'},
|
||||
{'key': 'firstseen', 'label': 'First seen'},
|
||||
{'key': 'lastchanged', 'label': 'Last changed'},
|
||||
]
|
||||
|
||||
|
||||
def _isostamp(value):
|
||||
"""ISO-8601 with a Z suffix, matching every other timestamp this file emits.
|
||||
|
||||
Formatting is left to the client so it can render in the viewer's locale
|
||||
and timezone; returning a preformatted string here would pin every reader
|
||||
to the server's.
|
||||
"""
|
||||
return value.isoformat() + 'Z' if value else None
|
||||
|
||||
|
||||
def _communication_rows(asset_id: int):
|
||||
"""Every Communication row for an asset, newest change first.
|
||||
|
||||
These rows are an ACCIDENTAL history rather than a designed one.
|
||||
_record_interfaces in the computers plugin matches an incoming NIC on MAC
|
||||
then on IP and inserts when neither matches, and it never deletes - so an
|
||||
address the machine has not held for a year is still sitting here next to
|
||||
the current one. That is genuinely useful (it answers "what was this box on
|
||||
before?"), so it is surfaced as history rather than cleaned away.
|
||||
"""
|
||||
from shopdb.api import Communication
|
||||
|
||||
# MySQL has no NULLS LAST, so sort on an explicit "is null" key first.
|
||||
# Rows that never got a modifieddate sink to the bottom on every dialect.
|
||||
return (Communication.query
|
||||
.filter_by(assetid=asset_id)
|
||||
.order_by(Communication.modifieddate.is_(None),
|
||||
Communication.modifieddate.desc(),
|
||||
Communication.communicationid.desc())
|
||||
.all())
|
||||
|
||||
|
||||
def _iphistory_status(comm):
|
||||
"""What this row IS, in the vocabulary somebody troubleshooting would use.
|
||||
|
||||
Primary is the address the collector last picked as this box's main one.
|
||||
APIPA is called out by name because a 169.254.x.x address is not an address
|
||||
the machine was given - it is what Windows assigns when DHCP failed, so its
|
||||
presence in the list is a record of a past network fault, not a past
|
||||
subnet.
|
||||
"""
|
||||
address = (comm.ipaddress or '').strip()
|
||||
if address.startswith('169.254.'):
|
||||
return 'APIPA (no DHCP)'
|
||||
if comm.isprimary:
|
||||
return 'Primary'
|
||||
if comm.ismachinenetwork:
|
||||
return 'Machine network'
|
||||
return 'Previous'
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/iphistory', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_iphistory(asset_id: int):
|
||||
"""Table panel: every address this asset has held, newest change first.
|
||||
|
||||
Replaces the hand-written Network card that used to sit on the Details tab
|
||||
and show only the current addresses. Dates come from the row's own
|
||||
lifecycle columns, so they are labelled for what they are: 'Last changed'
|
||||
is when a field on the row last moved, NOT when the address was last seen.
|
||||
An address the machine still holds and reports unchanged does not update
|
||||
modifieddate, so calling that column 'Last seen' would be a lie the data
|
||||
cannot support.
|
||||
"""
|
||||
rows = []
|
||||
for comm in _communication_rows(asset_id):
|
||||
address = (comm.ipaddress or '').strip()
|
||||
if not address:
|
||||
continue
|
||||
rows.append({
|
||||
'ipaddress': address,
|
||||
'macaddress': (comm.macaddress or '').strip() or '-',
|
||||
'status': _iphistory_status(comm),
|
||||
'firstseen': _isostamp(comm.createddate),
|
||||
'lastchanged': _isostamp(comm.modifieddate),
|
||||
})
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
@@ -136,6 +136,12 @@ _BASE_APPSETTINGS = {
|
||||
'UDC.exe': 'C:\\Program Files\\UDC\\UDC.exe',
|
||||
'agent.exe': 'C:\\ProgramData\\udc\\mtconnect_udc\\agent\\run.bat',
|
||||
},
|
||||
# Approved scheduled task paths for scheduledtask_run. Shipped empty, which
|
||||
# means the agent falls back to ScheduledTaskPolicy.DefaultApprovedTaskPaths
|
||||
# (the Corporate Tools set). Listing entries here REPLACES those defaults, so
|
||||
# a site can narrow or widen the set without an agent rebuild. Anything not
|
||||
# matched fails closed.
|
||||
'approvedtaskpaths': [],
|
||||
}
|
||||
|
||||
_README = """ShopDB endpoint agent (site build)
|
||||
@@ -217,7 +223,19 @@ param(
|
||||
[string] $ServiceName = "ShopDbEndpointAgent",
|
||||
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
|
||||
[string] $CertFileName = {certfile},
|
||||
[switch] $NoTray
|
||||
[switch] $NoTray,
|
||||
|
||||
# Scheduled task that runs the user-context probe INSIDE the operator's own
|
||||
# logon session. Mapped drives are per logon token, so this is the only
|
||||
# reliable way to collect them; see the registration block below.
|
||||
[string] $UserContextTaskName = "ShopDB user-context probe",
|
||||
[int] $UserContextTaskMinutes = 30,
|
||||
|
||||
# The account the probe task runs as. Must match the account whose profile
|
||||
# the agent reads the spool from (FileLoggerProvider.ShopFloorUserName), or
|
||||
# the probe writes session data the service never looks at.
|
||||
[string] $UserContextTaskUser = "ShopFloor",
|
||||
[switch] $NoUserContextTask
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -362,10 +380,175 @@ if (-not $NoTray) {{
|
||||
}}
|
||||
catch {{ Write-Warning "could not create Start Menu shortcut: $($_.Exception.Message)" }}
|
||||
|
||||
# Launch the tray once now so it appears without waiting for the next
|
||||
# logon. Single-instance guard makes a duplicate launch a no-op.
|
||||
try {{ Start-Process -FilePath $installedTray -WorkingDirectory $InstallDir }}
|
||||
catch {{ Write-Warning "could not start tray: $($_.Exception.Message)" }}
|
||||
# Deliberately NOT launched here. This installer runs elevated, so
|
||||
# Start-Process would hand the tray an ADMIN token, and drive letters
|
||||
# are per logon token: the probe would then enumerate the installing
|
||||
# admin's drive table, not the operator's. Measured on a live endpoint,
|
||||
# the same two shares reported Unavailable from the elevated shell and
|
||||
# OK from the operator's own session, in both Win32_NetworkConnection
|
||||
# and Get-SmbMapping. Launching via explorer.exe does not fix this
|
||||
# either - it de-elevates but does NOT change account.
|
||||
#
|
||||
# The Run key above is the correct autostart: it fires at a genuine
|
||||
# logon, in the operator's own session. Collection no longer depends on
|
||||
# the tray at all - the scheduled task below owns it.
|
||||
Write-Host " tray will start at next logon (not launched from this elevated install)."
|
||||
}}
|
||||
}}
|
||||
|
||||
# 2b. Register the user-context probe as a scheduled task owned by the SHOPFLOOR
|
||||
# USER. This is what makes mapped drives, user listeners and idle time
|
||||
# collectable: it is the only mechanism that starts a process in the operator's
|
||||
# own session with the operator's own token.
|
||||
#
|
||||
# RunLevel Limited is load-bearing, not a hardening nicety - the unelevated
|
||||
# token is precisely what sees the operator's mapped drives.
|
||||
#
|
||||
# UserId, NOT GroupId. A group-principal task is registered against the group
|
||||
# rather than against any one account, and a member of that group cannot invoke
|
||||
# it on demand: Start-ScheduledTask from the ShopFloor session returns access
|
||||
# denied, so the task only ever ran on its own trigger. That is exactly the bug
|
||||
# where "Refresh Status" appeared to do nothing. Registering to the specific
|
||||
# account makes the task startable by that account, which is what both the tray
|
||||
# and any manual retry depend on. The group form is kept only as a fallback for
|
||||
# a host where the account cannot be resolved - it still collects on a timer,
|
||||
# it just cannot be kicked on demand.
|
||||
$taskState = "skipped"
|
||||
if (-not $NoUserContextTask) {{
|
||||
Write-Host " registering user-context probe task ..."
|
||||
$taskAgentExe = Join-Path $InstallDir "shopdbagent.exe"
|
||||
$taskRegistered = $false
|
||||
$taskDetail = "no principal accepted"
|
||||
try {{
|
||||
$taskAction = New-ScheduledTaskAction -Execute $taskAgentExe -Argument "--collect-usercontext"
|
||||
# AtLogOn covers the normal case. The repeating trigger matters just as
|
||||
# much: AtLogOn does NOT fire for a session that is already signed in,
|
||||
# which is every install onto a machine someone is currently using.
|
||||
$taskTriggers = @(New-ScheduledTaskTrigger -AtLogOn)
|
||||
if ($UserContextTaskMinutes -gt 0) {{
|
||||
$taskTriggers += New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes $UserContextTaskMinutes)
|
||||
}}
|
||||
$taskSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5)
|
||||
|
||||
# Try the machine-qualified account first, then the bare name. Interactive
|
||||
# LogonType so the task joins the existing interactive session instead of
|
||||
# creating a separate one with its own (empty) drive table.
|
||||
$taskCandidates = @(
|
||||
@{{ Kind = "user"; Id = "$env:COMPUTERNAME\\$UserContextTaskUser" }},
|
||||
@{{ Kind = "user"; Id = $UserContextTaskUser }},
|
||||
@{{ Kind = "group"; Id = "S-1-5-32-545" }},
|
||||
@{{ Kind = "group"; Id = "BUILTIN\\Users" }}
|
||||
)
|
||||
foreach ($candidate in $taskCandidates) {{
|
||||
try {{
|
||||
if ($candidate.Kind -eq "user") {{
|
||||
$taskPrincipal = New-ScheduledTaskPrincipal -UserId $candidate.Id -LogonType Interactive -RunLevel Limited -ErrorAction Stop
|
||||
}}
|
||||
else {{
|
||||
$taskPrincipal = New-ScheduledTaskPrincipal -GroupId $candidate.Id -RunLevel Limited -ErrorAction Stop
|
||||
}}
|
||||
Register-ScheduledTask -TaskName $UserContextTaskName -Action $taskAction -Trigger $taskTriggers -Principal $taskPrincipal -Settings $taskSettings -Force -ErrorAction Stop | Out-Null
|
||||
$taskRegistered = $true
|
||||
$taskDetail = "$($candidate.Kind):$($candidate.Id)"
|
||||
break
|
||||
}}
|
||||
catch {{ $taskDetail = $_.Exception.Message }}
|
||||
}}
|
||||
}}
|
||||
catch {{ $taskDetail = "could not build the task: $($_.Exception.Message)" }}
|
||||
|
||||
if ($taskRegistered) {{
|
||||
# Read the task back rather than trusting Register-ScheduledTask to have
|
||||
# returned cleanly: policy or a management agent can rewrite or drop a
|
||||
# task after registration, and a task that exists but runs elevated
|
||||
# looks healthy in the scheduler UI while producing wrong data.
|
||||
$verified = Get-ScheduledTask -TaskName $UserContextTaskName -ErrorAction SilentlyContinue
|
||||
if (-not $verified) {{
|
||||
$taskState = "UNVERIFIED:task not found after registration"
|
||||
Write-Warning " user-context task registered but cannot be read back."
|
||||
}}
|
||||
elseif ([string] $verified.Principal.RunLevel -eq "Highest") {{
|
||||
$taskState = "UNVERIFIED:elevated"
|
||||
Write-Warning " user-context task runs ELEVATED; mapped drives would be collected from the wrong token."
|
||||
}}
|
||||
elseif ($verified.Principal.GroupId) {{
|
||||
# Registered, will collect on its triggers, but cannot be started on
|
||||
# demand by the operator - so "Refresh Status" will fall back to the
|
||||
# timer. Surfaced rather than reported as ok, because the symptom
|
||||
# (stale session data that never responds to a refresh) is otherwise
|
||||
# indistinguishable from a broken probe.
|
||||
$taskState = "DEGRADED:group principal $($verified.Principal.GroupId)"
|
||||
Write-Warning " user-context task fell back to a GROUP principal; on-demand refresh will not work, only the $UserContextTaskMinutes-minute interval."
|
||||
}}
|
||||
else {{
|
||||
$taskState = "ok:$($verified.Principal.UserId)/$($verified.Principal.RunLevel)"
|
||||
Write-Host " user-context task registered and verified: $taskState"
|
||||
}}
|
||||
|
||||
# AtLogOn will not fire for an already-signed-in session, so kick it once
|
||||
# rather than leaving a fresh install blind until the repeat interval.
|
||||
try {{
|
||||
Start-ScheduledTask -TaskName $UserContextTaskName -ErrorAction Stop
|
||||
Write-Host " user-context task started once for the current session."
|
||||
}}
|
||||
catch {{ Write-Warning " could not start the task now: $($_.Exception.Message); it runs at next logon or interval." }}
|
||||
}}
|
||||
else {{
|
||||
# Not fatal: the service still checks in, only the session-scoped fields
|
||||
# are missing, and the ledger records why.
|
||||
$taskState = "FAILED:$taskDetail"
|
||||
Write-Warning " could not register the user-context task: $taskDetail"
|
||||
Write-Warning " mapped drives and idle time will not be collected."
|
||||
}}
|
||||
}}
|
||||
|
||||
# 2c. Start the tray in the operator's session.
|
||||
#
|
||||
# The Run key registered above only fires at LOGON, so installing onto a machine
|
||||
# someone is already signed in to leaves no tray until they sign out and back in.
|
||||
# The installer cannot start it directly - it is elevated, and the tray must run
|
||||
# unelevated in the user's own session - so we borrow the same mechanism that
|
||||
# solves this for the probe: a transient scheduled task with a Limited group
|
||||
# principal, run once and then deleted.
|
||||
#
|
||||
# Registered without a trigger; it exists only to be invoked immediately. The
|
||||
# tray is single-instance, so if one is somehow already running this is a no-op.
|
||||
if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
|
||||
$launchTaskName = "ShopDB tray first launch"
|
||||
try {{
|
||||
$launchExe = Join-Path $InstallDir $TrayExeName
|
||||
$launchAction = New-ScheduledTaskAction -Execute $launchExe -WorkingDirectory $InstallDir
|
||||
$launchSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
||||
$launched = $false
|
||||
foreach ($groupId in @("S-1-5-32-545", "BUILTIN\\Users")) {{
|
||||
try {{
|
||||
$launchPrincipal = New-ScheduledTaskPrincipal -GroupId $groupId -RunLevel Limited -ErrorAction Stop
|
||||
Register-ScheduledTask -TaskName $launchTaskName -Action $launchAction -Principal $launchPrincipal -Settings $launchSettings -Force -ErrorAction Stop | Out-Null
|
||||
Start-ScheduledTask -TaskName $launchTaskName -ErrorAction Stop
|
||||
$launched = $true
|
||||
break
|
||||
}}
|
||||
catch {{ }}
|
||||
}}
|
||||
|
||||
# Give the task a moment to spawn the tray before removing it: deleting a
|
||||
# task that is still starting can kill the process it just launched.
|
||||
if ($launched) {{
|
||||
Start-Sleep -Seconds 3
|
||||
Write-Host " tray started in the operator's session."
|
||||
}}
|
||||
else {{
|
||||
Write-Warning " could not start the tray now; it will start at next logon via the Run key."
|
||||
}}
|
||||
|
||||
# Transient by design: this is a one-shot launcher, not an autostart.
|
||||
# The Run key owns autostart, and leaving this behind would mean two
|
||||
# things racing to start the same single-instance tray at every logon.
|
||||
try {{ Unregister-ScheduledTask -TaskName $launchTaskName -Confirm:$false -ErrorAction Stop }}
|
||||
catch {{ Write-Warning " could not remove the transient launch task '$launchTaskName'; remove it manually." }}
|
||||
}}
|
||||
catch {{
|
||||
Write-Warning " could not start the tray now: $($_.Exception.Message); it will start at next logon."
|
||||
}}
|
||||
}}
|
||||
|
||||
@@ -397,15 +580,22 @@ Write-Host " service is running." -ForegroundColor Green
|
||||
|
||||
$trayBase2 = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
|
||||
if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
|
||||
$trayOk = $false
|
||||
for ($attempt = 1; $attempt -le 5; $attempt++) {{
|
||||
if (Get-Process -Name $trayBase2 -ErrorAction SilentlyContinue) {{ $trayOk = $true; break }}
|
||||
Write-Host " tray not running yet (attempt $attempt/5); relaunching ..."
|
||||
try {{ Start-Process -FilePath (Join-Path $InstallDir $TrayExeName) -WorkingDirectory $InstallDir }} catch {{ }}
|
||||
# Report only; never relaunch from here. This block used to Start-Process the
|
||||
# tray when it was not found running, which would hand it this installer's
|
||||
# ADMIN token and reintroduce the wrong-drive-table bug. The launch task
|
||||
# above is the only place the tray is started during install, and it starts
|
||||
# it correctly - unelevated, in the operator's session.
|
||||
$trayFound = $false
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {{
|
||||
if (Get-Process -Name $trayBase2 -ErrorAction SilentlyContinue) {{ $trayFound = $true; break }}
|
||||
Start-Sleep -Seconds 2
|
||||
}}
|
||||
if ($trayOk) {{ Write-Host " tray is running." -ForegroundColor Green }}
|
||||
else {{ Write-Warning "tray did not confirm running; it will retry at next logon via the Run key." }}
|
||||
if ($trayFound) {{
|
||||
Write-Host " tray is running." -ForegroundColor Green
|
||||
}}
|
||||
else {{
|
||||
Write-Warning "tray did not confirm running; it will start at next logon via the Run key."
|
||||
}}
|
||||
}}
|
||||
|
||||
# 5. Preserve the install artifacts (minus the secret-bearing install.ps1) under
|
||||
@@ -432,6 +622,29 @@ if (Test-Path $installSelf) {{
|
||||
}}
|
||||
|
||||
Write-Host "[6/6] Done." -ForegroundColor Green
|
||||
|
||||
# Append one line per install, including reinstalls and repeat installs of the
|
||||
# same version. The user-context task state is recorded because it is the single
|
||||
# point of failure for session-scoped collection: if mapped drives go missing
|
||||
# later, this answers "was the task ever registered on this host, and as what"
|
||||
# without needing the endpoint back. The ledger is a record, not a gate - a
|
||||
# failure to append must never fail an otherwise-good install.
|
||||
try {{
|
||||
$ledgerDir = Join-Path $env:ProgramData "ShopDB\\Logs"
|
||||
New-Item -ItemType Directory -Force -Path $ledgerDir | Out-Null
|
||||
$ledgerPath = Join-Path $ledgerDir "shopdbagent-installs.log"
|
||||
$ledgerAgentVersion = "unknown"
|
||||
if (Test-Path $installedAgent) {{ $ledgerAgentVersion = (Get-Item $installedAgent).VersionInfo.FileVersion }}
|
||||
$ledgerTrayVersion = "not-installed"
|
||||
$ledgerTrayPath = Join-Path $InstallDir $TrayExeName
|
||||
if (Test-Path $ledgerTrayPath) {{ $ledgerTrayVersion = (Get-Item $ledgerTrayPath).VersionInfo.FileVersion }}
|
||||
$ledgerLine = "{{0}} host={{1}} installer={{2}} service={{3}} tray={{4}} dir={{5}} usercontexttask={{6}}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz"), $env:COMPUTERNAME, $env:USERNAME, $ledgerAgentVersion, $ledgerTrayVersion, $InstallDir, $taskState
|
||||
Add-Content -Path $ledgerPath -Value $ledgerLine -Encoding UTF8
|
||||
Write-Host "Install ledger: $ledgerPath"
|
||||
Write-Host " $ledgerLine" -ForegroundColor DarkGray
|
||||
}}
|
||||
catch {{ Write-Warning "could not append to the install ledger: $($_.Exception.Message)" }}
|
||||
|
||||
if (Test-Path $uninstaller) {{
|
||||
Write-Host "Uninstaller: $uninstaller" -ForegroundColor Cyan
|
||||
Write-Host " To remove the agent, run that script from an elevated PowerShell."
|
||||
|
||||
Reference in New Issue
Block a user