Merge wilm: gevent migration, endpoint telemetry phase 2, backup/restore

Brings in 98 commits from wilm (origin/wilm @ 811e6ca61a):

Major features:
- Move API to gevent workers for better concurrency
- Endpoint agent startup jitter (prevents thundering herd)
- Complete backup/restore with restic
- Endpoint telemetry phase 2 (disk health, mapped drives, user context)
- PC Detail UI rework with new cards and tabs
- Dell Command actions and Corporate Tools tab
- Agent version column in Fleet view

Conflict resolution:
- Backup/restore: Accepted wilm implementation (10 files)
- Agent startup jitter: Accepted wilm v1.12.0
- PC Types list fix: Accepted wilm version
- Endpoint plugin domain clamping: Kept ours (defensive)
- Test files: Accepted wilm versions for consistency

Testing required:
- Verify gevent handles concurrent requests
- Test backup/restore functionality
- Verify agent startup jitter timing
- Test endpoint telemetry collection
- Validate PC Detail UI changes

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Proudlock
2026-09-06 21:23:42 -04:00
32 changed files with 986 additions and 710 deletions

View File

@@ -107,10 +107,9 @@ onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
// listAll resolves to a plain array (fetchAllPages), not an axios
// response. Reading .data.data threw and left the page empty.
const response = await computersApi.types.listAll({ active: false })
pcTypes.value = response || []
// listAll() pages past the server's 100-row cap and returns the ROWS, not
// an axios response - there is no .data to unwrap.
pcTypes.value = await computersApi.types.listAll({ active: false })
} catch (err) {
console.error('Error loading PC types:', err)
} finally {

View File

@@ -1828,6 +1828,25 @@ def revoke_agentkey():
# How long the agent claim poll is held open server-side before returning empty.
# Kept below the agent HttpClient timeout and typical proxy idle timeouts so a
# quiet fleet re-polls cleanly instead of tripping a read timeout.
#
# 25s is the value this endpoint is designed around; it was temporarily dropped
# to 5s under the blocking gthread worker pool, where a held connection pinned
# one of a worker's 8 OS threads. Measured then: 14 agents at hold=25 left a
# single worker with 8 of 9 threads parked in timed sleep and UI requests hashed
# to that worker waited 12-24s. Under gevent a held connection is a greenlet and
# the per-worker ceiling is --worker-connections (2000), not 8 threads, so an
# idle hold no longer costs anything a UI request needs.
#
# Restored to 25 because hold length sets the fleet's REQUEST RATE, not its
# concurrency: the agent re-polls immediately on a 204, so hold=5 is 5x the
# requests of hold=25, and TLS handshake CPU was the measured burst bottleneck
# (devnotes/gevent-sizing-simulation.md). 25 also restores the margin against
# the agent's MinIdlePollDuration=5s floor, which CommandWorker.IsIdlePollClose
# uses to tell a normal hold-window close apart from a transient failure; at
# hold=5 that margin was ~20ms.
#
# Constraint: gunicorn --timeout must stay above this or a healthy long-poll is
# killed mid-hold. docker-compose.yml sets 60.
_CLAIM_HOLD_SECONDS = 25
_CLAIM_POLL_INTERVAL = 1.0

View File

@@ -118,6 +118,7 @@ _BASE_APPSETTINGS = {
'apikey': 'REPLACE_WITH_COLLECTOR_KEY',
'cadenceseconds': 900,
'jitterseconds': 60,
'startupjitterseconds': 30,
'adoptonfirstrun': True,
'utilitiespath': '',
'logpath': '',

View File

@@ -1123,12 +1123,18 @@ def set_asset_printer_assignment(asset_id: int):
', '.join(str(assetid) for assetid in sorted(notprinters))))
typeids = _relationship_typeids(_USES_PRINTER, _DEFAULT_PRINTER)
if not typeids[_USES_PRINTER] or not typeids[_DEFAULT_PRINTER]:
missingtypes = [name for name in (_USES_PRINTER, _DEFAULT_PRINTER)
if not typeids[name]]
if missingtypes:
# Seed data, not a migration. An un-seeded database cannot hold an
# assignment, and saying so beats writing rows nothing can read.
# assignment, and saying so beats writing rows nothing can read. Name
# the types that are actually missing: a partially seeded table is the
# common case, and "types are not seeded" sends someone looking for an
# empty table they will not find.
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Relationship types are not seeded - run: flask seed reference-data',
'Relationship type(s) not seeded: {0} - run: flask seed '
'reference-data'.format(', '.join(missingtypes)),
http_code=500)
_reconcile_edges(asset_id, typeids[_USES_PRINTER][0],
@@ -1784,10 +1790,13 @@ def seed_assignment_from_observed(asset_id: int):
details={'hostname': hostname, 'skipped': candidate['skipped']})
typeids = _relationship_typeids(_USES_PRINTER, _DEFAULT_PRINTER, _CONTROLS)
if not typeids[_USES_PRINTER] or not typeids[_DEFAULT_PRINTER]:
missingtypes = [name for name in (_USES_PRINTER, _DEFAULT_PRINTER)
if not typeids[name]]
if missingtypes:
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Relationship types are not seeded - run: flask seed reference-data',
'Relationship type(s) not seeded: {0} - run: flask seed '
'reference-data'.format(', '.join(missingtypes)),
http_code=500)
warnings = []