Commit Graph

451 Commits

Author SHA1 Message Date
benjamin.baker
aa9dafc5ca Add application-driven policy generation with an acknowledgement gate
Ticking "Required on all PCs" now generates a configuration policy that
installs the application on every non-server endpoint it matches, rather
than only flagging a software-compliance report row.

Because that save is a fleet-wide deployment, it requires explicit
acknowledgement. The gate is enforced in the API, not only in the form:
a confirmation dialog that can be sidestepped by POSTing directly is
decoration, not a safeguard. It fires only on the transition INTO the
required state, since re-saving an already-required application deploys
nothing new.

Generated policies carry a new 'autoapp' source. They stay editable, and
hand-editing one transfers ownership to 'local' so it survives a later
uncheck; 'shipped' remains read-only. Existing required applications are
not migrated retroactively - policies are only created on save.

Also adds the monitorable fields that the shipped-policy breakup will
build on, and repairs a mangled non-ASCII byte in copilot-instructions.md
that was failing the style gate.
2026-09-07 13:27:59 -04:00
benjamin.baker
f73df42110 Show the PC alias as "Alias" in Fleet and PC Details
The asset name field presented as "Name" in PC Details but as "Alias" in Edit
PC, and was absent from Fleet entirely. Three surfaces, two words, one field.

Adds an Alias column to the Fleet table (positioned after Asset Tag to mirror
PC Details' ordering) and renames the PC Details label to "Alias". Edit PC
already used "Alias", so it is unchanged.

Label and presentation only: the field stays `name` on the asset and the API
payload is untouched, so no backend contract moves.
2026-09-07 13:03:31 -04:00
benjamin.baker
31dc9d49e1 Add an All PCs policy scope that excludes servers
The policy builder only offered "Matches everything" (the `true` scope node),
which also targets servers. Authoring an estate-wide policy almost always means
"every PC an operator sits at", and a reboot or script push against a server is
a materially different event.

Adds an `allpcs` scope node that matches every endpoint except those whose
computer type is server-designated. Server designation reads the computer type's
ICON slug, which is nominally a UI hint and is now load-bearing; it was chosen
over matching the type NAME (a site can rename "Server") and over a new isserver
column (needs a migration). It is isolated behind SERVER_ICON so promoting it to
a real flag later is a one-place change.

The `true` node is retained and still matches servers, so existing policies keep
their exact meaning rather than silently re-scoping. It stays visible in the
dropdown only when editing a policy that already uses it; new policies default
to `allpcs`.

Covered by tests/test_plugins/test_computers_policy_allpcs.py, verified against
a planted canary so the server-exclusion assertions are known to bite.
2026-09-07 13:03:07 -04:00
benjamin.baker
3626d7dd2e Add Report a Bug link to the sidebar
Sits in the sidebar footer below Settings and above the Light/Dark toggle, visible to everyone including guests, and opens the team's SharePoint bug list in a new tab with rel=noopener.

The URL is a development placeholder for the wilm pre-production window, marked as such in a comment next to the constant so it is not mistaken for a shipped integration. Styling matches .theme-toggle exactly and uses fixed white alphas rather than theme tokens, because the sidebar is dark in both themes - a var(--text-*) here would resolve to a dark colour on a dark sidebar in light mode and vanish, which is the same trap the lock modal hit earlier.
2026-09-07 12:48:15 -04:00
benjamin.baker
5de6a2b170 Seed plugin permissions and relink scopes on plugin upgrade
on_install/on_enable fire only on a state transition, so a permission declared by a later plugin version never reached an already-installed site through them. Until someone ran flask seed permissions by hand the name did not exist, and any route guarding on it refused every caller. Settings already had sync_all_plugin_settings() on the upgrade path for exactly this reason; permissions now have the counterpart.

The scope relink is the half that actually bites. Creating the Permission row only makes the name exist - the prebuilt scopes derive their grants from the catalog, so a permission seeded without a following Role.seedscopes() belongs to nobody but admin. The plugin installs clean and its UI is invisible to every non-admin, which reads as a broken plugin rather than a seeding gap. Ordering matches flask seed permissions: seedscopes only links rows that already exist, so it runs last. Additive, so a site's deliberate role edits survive the deploy, and best-effort per plugin so one bad plugin cannot abort a deploy.

Test deletes an existing plugin permission, runs upgrade-all, and asserts both that the row returns and that it is granted to the technician scope. Verified it fails without the fix rather than passing vacuously.
2026-09-07 12:43:39 -04:00
benjamin.baker
90dc6eb832 Record Windows tooling rules in copilot instructions
Three rules earned by losing time to them this session. Long-running commands (pytest suites, npm builds, container rolls) must be redirected to a log under devnotes/ and launched non-blocking, never polled or tailed - the user watches the file. Warn before modifying files when a test run or build may be in progress, so discussion can continue without altering files underneath a running suite. Invoke shell scripts with the explicit Git Bash path, because bare bash in PowerShell resolves to WSL, which has no /bin/bash here and fails in a way that looks like the script itself is broken.
2026-09-07 12:40:18 -04:00
benjamin.baker
4c1d96b6ba Bump plugin contract to 0.32.0 and register new plugin tables
Exports caller_is_role on the plugin authorization surface. Additive name, so a minor bump. Scoped personal tokens are always refused by it, matching require_role, which stops a plugin from accidentally letting a scoped token inherit role authority from its owner.

Registers the tables added by the endpoint lock and policy hold work (endpointlocks, policyactionholds) plus the endpoint health tables in the alembic template, so plugin migrations see them and the ownership guard does not report them as unclaimed.
2026-09-07 12:40:12 -04:00
benjamin.baker
0d5b9730e3 Add load test harness for pool and fleet sizing
Adds the scripts used to size the connection pool and validate fleet polling under load: burst arrival and mixed workload drivers, pool and resource samplers, a UI probe, host seed/drop SQL, and a gevent Dockerfile. Hosts are addressed by compose service name (api:5001) rather than a local path, so the harness runs against any stack rather than one machine.

loadtest_token.py mints a scoped collector token named LOADTEST and prints the secret once, then revokes only that token by name so a real collector token is never touched. envkeyfallback is false, so the harness needs a genuine managed token rather than an env key.
2026-09-07 12:39:53 -04:00
benjamin.baker
187d371453 Remove the delivery test bench
The delivery test bench and its notes were scaffolding for validating the software-delivery path during development. That work is complete and covered by tests, so the bench is removed along with its route registration rather than left in the shipped UI where it would read as a supported feature.
2026-09-07 12:39:46 -04:00
benjamin.baker
ea3739262b Fix style gate: file-level --exclude was cancelling every --include
The gate reported 2277 lines of findings and had become unreadable, which read as accumulated debt. It was a tooling bug. In this grep build a single file-level --exclude= silently cancels every --include= on the same command; --exclude-dir= is unaffected. Two innocuous entries, --exclude=findings.sarif and --exclude=report.md, had therefore been disabling the include filters on every check that used the EXCLUDES array. That is why a check whose flags say .py/.vue/.js/.ts was reporting .md, .ps1, .json, .gitignore and vendored DLLs: it was scanning the whole tree including build artifacts.

Removes all file-level excludes and reproduces their intent with a drop_generated post-filter, adds grep -I so binaries can never be reported (there is no line to fix in a DLL), excludes bin/obj/publish/dev-bin and exports/backups, and adds the drop_scratch the shorthand loop was missing while every other check had it. Findings drop from 2277 lines to 11 with all ten checks passing. Verified with a planted canary file that the gate still catches cfg, a snake_case __tablename__ and a snake_case db.Column - a green gate immediately after a filtering change is worth distrusting until shown to still fail. Do not re-add --exclude=<file> to that array.
2026-09-07 12:39:40 -04:00
benjamin.baker
63ce75f211 Add break-glass override plumbing for policy-held actions
Adds caller_is_role(), the body-answering counterpart to require_role, for routes that serve everyone but treat one class of caller differently. A scoped personal token is always refused here, matching require_role: scopes gate individual permissions, not roles. That invariant stops a leaked scoped agent token from inheriting override authority just because its owner is an admin - a scoped PAT is a narrower caller than its owner, admin included.

Frontend enqueueCommand now carries policyoverride with overridereason. Both are required together server-side and an override without a stated reason is rejected, so the caller must collect the reason before sending rather than discovering the refusal after the fact.
2026-09-07 12:39:31 -04:00
benjamin.baker
0e43ac6b8d Add prebuilt user scopes and guard invalid Reporter grants
A newly created user held no roles and no permissions, so the sidebar filtered every nav item away and the account appeared broken. Adds three prebuilt scopes (Reporter, Operator, Technician) derived from the live permission catalog by verb rather than hand-listed, so a new plugin permission lands in the right scope automatically instead of silently going ungranted. users.view is granted to all three since a user who cannot see the directory cannot collaborate; users.create/edit/delete stay admin-only. Role.seedscopes() is additive and idempotent, seeded via flask seed permissions, and preserves custom grants on rerun.

Reporter guard: the auth model is additive across admin, direct grants and roles, so Reporter cannot be expressed as merely fewer grants - a direct endpoint-action grant would silently re-arm a read-only account. Rather than change the global permission predicate and alter every route's semantics, _check_scope_grants() rejects the invalid combination at write time on both create and update. The update path validates the effective post-update state, so demoting a Technician to Reporter is refused while a stale endpoint grant remains rather than quietly leaving a read-only user with write authority.

Validation runs before the User object is constructed. Assigning user.roles first put the relationship into the session autoflush path while the User was still unattached; nothing broke because the refusal returns before db.session.add(), but it left a trap where any query added below that point would autoflush a half-built user and surface later as an orphan row. Verified with -W error::sqlalchemy.exc.SAWarning: 25 passed.
2026-09-07 12:39:24 -04:00
benjamin.baker
52c9d2b009 Add multi-action policy holds and exempt collect_now from policy
Adds PolicyActionHold so an admin can suspend specific actions on a computer without editing the policy itself, covering the break-glass case where a machine must be quiesced immediately. Holds cover multiple actions in one atomic submission rather than one row per action, so a partial failure cannot leave a half-applied hold. Duration is free-form with 0 meaning indefinite (migration 0006 makes expiresat nullable); the prior fixed ceiling was removed because an admin quiescing a misbehaving machine does not know in advance how long it needs, and a hold silently expiring is worse than one that must be cleared deliberately. The What This Does explainer reflects indefinite holds.

collect_now exemption: refreshing status was denied on any endpoint with a non-empty effective policy even when the caller held the matching grant, because policy intersection silently removed the action. collect_now is a read-only status refresh with no side effects, so it now sits in POLICY_EXEMPT_ACTIONS alongside the other routine actions. Verified against a synthetic non-empty policy: operators can refresh status again while every write action stays gated.
2026-09-07 12:38:55 -04:00
benjamin.baker
627d3f5ff5 Add endpoint locks and split endpoints.deploy into three grants
Endpoint locks
--------------
Adds EndpointLock so an admin can hold an endpoint against automated and
operator action, with migration 0018 and enforcement in the enqueue path
rather than only in the UI, so a lock cannot be bypassed by calling the
API directly.

Split endpoints.deploy
----------------------
endpoints.deploy bundled three actions of very different risk behind a
single name:

	scheduledtask_run -> starts a task already registered and allowlisted
	update_app        -> runs a pinned installer
	run_script        -> arbitrary code as SYSTEM unless the descriptor
						 pins a user context

Because they shared one grant there was no way to let an operator push an
application update without also handing them arbitrary SYSTEM code
execution on every endpoint they could reach. The permission name
described the lightest of the three while silently carrying the heaviest.

commands.ELEVATED_ACTION_PERMISSIONS now maps each action to its own
permission (endpoints.corptools, endpoints.updateapp,
endpoints.runscript). The enqueue route reads that table, so no route
logic changed. Migration 7d42 preserves existing access exactly: anyone
holding endpoints.deploy receives all three replacements.
2026-09-07 12:36:45 -04:00
benjamin.baker
879451b689 Merge remote-tracking branch 'origin/wj' into wilm 2026-09-07 05:17:24 -04:00
benjamin.baker
04616fce8b Cache endpoint health as a ranked rollup
Fleet ranked endpoints by raw critical-event counts, so a machine with a
noisy but harmless event log outranked one with a mapped drive down or a
blocking service stopped. Volume is not severity, and the aggregation ran
across the whole fleet on every page load.

Rank operational blockers above degraded conditions above event noise, and
cache the verdict per asset so Fleet reads a row instead of recomputing 48h
of history. Transitions are written only when a verdict changes, capped at
50 rows per asset.

Absence of a policy binding must never suppress a verdict: drives and events
are not policy-gated, and services fall back to a built-in default impact
when no mapping applies. Event 1001 drops to informational because the WER
bucket pairs with 1000 and double-counted every crash.

Parallelize the Fleet first-paint loaders that were awaiting serially.
2026-09-07 05:17:19 -04:00
Proudlock
d40f1a6a92 fix: replace unicode characters and fix finalize-restore endpoint
Style check fixes:
- Replace unicode box-drawing characters with ASCII (|-- instead of ├──)
- Replace emoji status markers with [OK]/[FAIL]/[WARN] tags
- Replace arrows (→ with ->, ↔ with <->) and em-dashes (— with --)
- Add venv-quick to excluded directories in check script
- Exclude darkwing report files (findings.sarif, report.md)

Backup finalization fix:
- Fix finalize-restore endpoint to not claim the upgrade sentinel
- Endpoint now checks for pending upgrade and returns {queued: true}
- Leaves UPGRADE_SENTINEL for background watcher to claim and process
- Prevents inline Alembic runs that can outlast request timeout

Claude Code context:
- Add .claude/ directory with INDEX.md and QUICK-REFERENCE.md
- Add CLAUDE.md with project overview and conventions
- Helps Claude Code understand project structure and rules

Fixes failing test: test_the_endpoint_does_not_run_the_upgrade_itself
Fixes naming/style CI check violations

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-07 03:48:11 -04:00
Proudlock
397c02611a security: fix admin bootstrap race and KB article disclosure
Address two security findings from darkwing scan:

Finding 1 [High]: Unauthenticated admin bootstrap race
- Require SETUP_TOKEN header or loopback origin for POST /api/setup/create-admin
- Enforce 12-character minimum password for bootstrap admin
- Cross-check setup_complete setting to prevent re-arming
- Log token to container logs on first startup for operator retrieval
- Add flask seed show-setup-token CLI helper
- Add audit log entry when first admin is created
- Preserve existing flask seed admin workflow (uses loopback)

Finding 2 [Medium]: Unauthenticated KB article disclosure
- Gate POST /api/knowledgebase/<id>/click on kb.view permission
- Remove linkurl from response (caller already has it from GET)
- Apply _visible_articles() filter to respect retired applications
- Prevent anonymous link enumeration and counter manipulation

Recovery paths if token is lost:
1. docker logs shopdb-api | grep "Setup token"
2. docker exec shopdb-api flask seed show-setup-token
3. docker exec shopdb-api flask seed admin (unchanged, always works)

Zero breaking changes. CLI and existing workflows unaffected.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-07 03:37:22 -04:00
Proudlock
75fe3dd3f0 fix(backups): restore atomic claim and plugin revision checks
Restore backup finalization functionality that was simplified in commit
b973561cb7:

- Add UPGRADE_SENTINEL, UPGRADE_CLAIM, UPGRADE_FAILED constants
- Restore _claim_upgrade() for atomic sentinel claim via os.rename()
- Restore _known_plugin_revisions() for plugin chain compatibility checks
- Restore _finalize_claimed() for upgrade execution with failure handling
- Update finalize-restore endpoint to use claim mechanism
- Update _is_compatible() to check plugin revisions and reject empty datarev

These changes prevent race conditions when multiple workers attempt to
finalize the same restore, and add compatibility validation for plugin
migration chains during restore operations.

Fixes 13 failing tests in test_shopdbbackups_finalize.py

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-07 03:36:45 -04:00
Proudlock
96baeb4167 Fix backup finalize tests: add missing upgrade claim mechanism
The test_shopdbbackups_finalize.py tests were failing because the backup
finalization code was missing the atomic claim/lock mechanism to prevent
race conditions when multiple workers try to run the post-restore schema
upgrade simultaneously.

Added missing constants and functions:
- UPGRADE_SENTINEL: .upgrade-requested marker from backup sidecar
- UPGRADE_CLAIM: .upgrade-inprogress lock to prevent races
- UPGRADE_FAILED: .upgrade-failed marker for failed upgrades
- _claim_upgrade(): Atomically claim upgrade using filesystem rename
- _finalize_claimed(): Run upgrade and handle success/failure
- _known_plugin_revisions(): Walk plugin migration histories

Updated finalize-restore endpoint:
- Uses atomic claim mechanism to prevent concurrent upgrades
- Supports re-arming failed upgrades for retry
- Returns proper response format (queued/retried flags)
- Checks if backup store is configured (404 if missing)

Updated _is_compatible():
- Checks plugin revisions when present in manifest
- Refuses sets from newer plugin chains
- Handles empty datarev as error case (not legacy fallback)
- Maintains backward compatibility with pre-datarev sets

These changes restore the functionality that was present in commit 993353d
but was simplified/removed in later refactors. The tests document the
critical race-prevention behavior that production backups need.

Fixes 13 failing tests in test_shopdbbackups_finalize.py

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-07 03:24:29 -04:00
Proudlock
6d8c03bf6a Fix: Hide kiosk links for plugins that aren't installed
The sidebar was showing USB Kiosk and Parts Kiosk links even when those
plugins were not installed, because isPluginEnabled() fails open (returns
true before the plugin list loads).

Changes:
- Load enabled plugins list during AppLayout mount
- Make kiosk visibility reactive with computed properties
- Check pluginsLoaded flag before showing kiosk links
- showUsbKiosk and showPartsKiosk now wait for real plugin state

Before: isPluginEnabled('usb') returns true immediately (enabledSet null)
After: Waits for plugin list, correctly returns false when not installed

Behavior:
- USB Kiosk: Hidden if usb plugin not installed/enabled
- Parts Kiosk: Hidden if printedparts plugin not installed/enabled
- Shopfloor Dashboard: Still shows (core feature)
- TV Slideshow: Still shows if slides plugin exists

User reported: USB Kiosk showing when USB plugin not installed
Fixed: Kiosk links now correctly gate on plugin installation status

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-07 03:06:07 -04:00
Proudlock
2ce4fa4e5c Security: Add path traversal protection to plugin operations
Add defense-in-depth validation to prevent path traversal attacks when
enabling/installing plugins via the UI or CLI.

Layer 1 - Input Validation (enable_plugin):
- Validate plugin names with regex ^[a-zA-Z0-9_-]+$
- Reject reserved names (., .., empty string)
- Blocks attempts like ../../../etc/passwd at entry point

Layer 2 - Path Boundary Check (load_manifest):
- Verify resolved manifest path stays within plugins_dir
- Defense in depth even if name validation is bypassed
- Compatible with Python 3.8+ (fallback for < 3.9)

Risk Assessment: LOW
- Requires admin role (already trusted)
- Multiple existing validation layers
- No known exploitation in the wild

Rationale: Defense in depth best practice. Admins are trusted, but input
validation should not rely solely on authorization checks.

Tested:
- Valid names pass (backups, my-plugin, plugin-name)
- Path traversal blocked (../../../etc/passwd)
- Reserved names blocked (..)
- Both validation layers confirmed working

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-07 03:01:09 -04:00
Proudlock
62a07ebcdd UX: auto-install plugins when enabling from UI
enable_plugin() now auto-installs available plugins before enabling them.
This fixes the UX issue where clicking Enable on an AVAILABLE plugin would
fail with 'Plugin is not installed'. Now it installs automatically (runs
migrations, seeds permissions) then enables it.

Before: User had to manually install via CLI first
After: Enable button works on AVAILABLE plugins (one-click install+enable)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-07 02:42:53 -04:00
Proudlock
5767765ada Fix Docker deployment: restore HTTPS, auto-run plugin migrations, install core plugins only
- Restore original HTTPS config in docker-compose files (gunicorn + gevent DOES support SSL)
- Add plugin migration step to docker-entrypoint.sh (fixes missing plugin tables on first deploy)
- Install only core plugins on bootstrap (backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty)
- Set FLASK_ENV=production to disable Werkzeug debugger

Bug: bootstrap-plugins.sh installed ALL plugins but didn't run migrations, causing
'Table does not exist' errors. Now installs core plugins only and runs 'flask plugin
upgrade-all' after bootstrap.

Optional plugins (employees, geenforce, measuringtools, printedparts, usb) can be
installed later via Settings > Plugins.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-07 02:32:14 -04:00
Proudlock
d59cc89c48 Add VM connectivity troubleshooting and HTTP workaround
VM cannot connect via HTTPS due to self-signed certificate errors.

Added:
- test-vm-connectivity.ps1: 5-step diagnostic for VM
- agent/QUICK-FIX-VM.md: Use HTTP for Hyper-V local network
- open-firewall-for-vm.ps1: Open Windows Firewall

Since VM and host are on same physical machine (Hyper-V internal
network), HTTP is acceptable for local monitoring.

For production: Use proper TLS certificates.
2026-09-07 01:26:16 -04:00
Proudlock
21fe05f621 Add agent installation guide for Hyper-V VM
Configuration for deploying fleet agent on VM that reports to host PC:
- Host IP: 172.31.96.1
- Collector API key included
- Step-by-step build and install instructions
- Network and SSL troubleshooting

Completes the Docker deployment with endpoint monitoring capability.
2026-09-07 01:18:32 -04:00
Proudlock
c206195675 Add one-click deployment batch file
DEPLOY-NOW.bat - Double-click to deploy on VM
- Checks prerequisites
- Runs deploy-to-vm.ps1 automatically
- No command-line needed

Usage: Copy repo to VM, double-click DEPLOY-NOW.bat
2026-09-07 00:53:34 -04:00
Proudlock
bdc32fd134 Add VM deployment automation
Created turnkey deployment for Hyper-V VMs and fresh Windows installs.

FILES:
- deploy-to-vm.ps1: Automated PowerShell deployment script
  - Checks prerequisites
  - Builds images (offline packages)
  - Starts all services
  - Initializes database
  - Sets admin password
  - One command deployment

- DEPLOY-TO-VM.md: Complete deployment guide
  - Multiple copy methods
  - Manual deployment steps
  - Troubleshooting
  - Offline deployment notes

USAGE:
  .\deploy-to-vm.ps1 -SiteName 'west-jefferson' -AdminPassword 'SecurePass!'

TIMELINE: 5 minutes from repo copy to running system
2026-09-07 00:50:10 -04:00
Proudlock
783b5d3806 Fix backup script - remove all Python dependencies
Backup was mostly working but failing at the end due to Python calls.

CHANGES:
- Made manifest generation work without Python (bash JSON)
- Made check verification recording work without Python
- All Python calls now wrapped in 'command -v python3' checks
- Falls back to bash sed for JSON updates

RESULT: Backup completes successfully without Python
- Database dump: SUCCESS (24K)
- Minimal manifest: SUCCESS (bash-generated JSON)
- Restic snapshot: SUCCESS
- Repository verification: SUCCESS
- Script exit: SUCCESS (no more Python errors)

Backups now work in offline/corporate proxy environments with
zero downloads during Docker build.
2026-09-07 00:42:10 -04:00
Proudlock
f1ed2b19cd Fix backup system - offline restic package approach
PROBLEM: Corporate proxy (Zscaler) blocked ALL package downloads during
Docker build - Oracle Linux yum, Alpine apk, even with SSL certs.

SOLUTION: Offline package approach
- Pre-download restic binary on host (works outside Docker)
- Bake binary into repo at scripts/backup/offline-packages/
- Updated Dockerfile.backup to use offline binary (no downloads)
- Simplified: mysql:8.4 base + offline restic only (no Python/alembic)

RESULT: Backup containers build successfully behind corporate proxy

FILES:
- scripts/backup/offline-packages/restic_0.17.3_linux_amd64.bz2 (8.6MB)
- scripts/backup/Dockerfile.backup: Use offline package, no downloads

VERIFICATION:
- docker compose build backup: SUCCESS
- Backup watcher running: Polling every 30s
- Backup store running: restic REST server on port 8000

WORKING:
- Click 'Back Up Now' in UI
- Watcher detects sentinel file within 30s
- Backup executes automatically

The offline package can be checked into git for easy deployment at
other sites behind similar proxies.
2026-09-07 00:37:54 -04:00
Proudlock
7ddba868d0 Docker deployment working - core system operational
Successfully deployed ShopDB in Docker with pre-built frontend approach.

WORKING:
- MySQL 8.4 database (healthy)
- Flask API with HTTPS on port 5001
- Gunicorn with 4 gevent workers
- All 17 plugins installed, 12 enabled
- Frontend built and served via Vue 3 SPA
- Auto-bootstrap on first deployment

BUILD APPROACH:
- Build frontend on host (bypasses Zscaler npm issues)
- Copy pre-built dist/ into Docker image
- Simple Python-only Dockerfile (no npm in container)
- Build time: 2-3 minutes (vs 15+ for failed multi-stage)

NOT WORKING YET:
- Backup watcher service (Oracle Linux SSL cert issues with Zscaler)
- Manual backups still functional via one-shot container

VERIFICATION:
- curl -k https://localhost:5001/ returns HTML
- curl -k https://localhost:5001/api/settings returns JSON
- 4 gunicorn workers running
- Database migrations applied

Files:
- DOCKER-DEPLOYMENT-COMPLETE.md: Full deployment guide
- scripts/backup/zscaler-root-ca.pem: Zscaler cert for backup containers
- .dockerignore: Updated to allow frontend/dist

For production: Set up CI/CD to build images and push to registry.
2026-09-07 00:31:24 -04:00
Proudlock
67579f13aa Add pre-built frontend Docker approach - workaround for Zscaler proxy
Corporate proxy (Zscaler) causes npm to fail inside Docker builds with
'Exit handler never called' error. This approach builds frontend on host
where npm works, then copies pre-built dist/ into Docker image.

New files:
- Dockerfile.prebuilt: Single-stage build, copies frontend/dist
- docker-compose.prebuilt.yml: Uses Dockerfile.prebuilt
- .dockerignore.prebuilt: Allows frontend/dist to be included
- start-docker-quick.sh: Quick restart without rebuild
- DOCKER-PREBUILT-APPROACH.md: Documents workaround and comparison

Benefits:
- Builds in 2-3 minutes (not 15+)
- Works behind corporate proxy
- Simpler to debug
- Faster iteration

Trade-off: Requires npm on host, two-step build process

For production: CI/CD builds frontend + image, pushes to registry
2026-09-07 00:26:48 -04:00
Proudlock
128bdabcb4 Add production readiness assessment - honest evaluation
ASSESSMENT: Current Docker source build is NOT production-ready.

Too complex:
- 10+ manual setup steps
- Zscaler cert extraction required
- 15+ minute build times
- 3-container backup orchestration
- Manual plugin installation
- Scattered documentation

RECOMMENDATIONS:
- West Jefferson: Use IIS + Windows Services (simplest)
- New sites: Pre-built Docker images from registry (needs CI/CD)
- Source build: Development only, not for operations

Code is solid. Deployment automation needs 2-3 weeks investment.

Created honest assessment document explaining why current approach
is too complex and what's needed for true production readiness.
2026-09-07 00:05:37 -04:00
Proudlock
88d89f4540 Add complete Docker deployment setup and documentation
- DOCKER-SETUP.md: comprehensive Docker deployment guide
- .env.docker-dev: complete Docker environment configuration
- certs/: self-signed TLS certificates for HTTPS
- Document first-time setup, plugin installation, database import
- Troubleshooting guide for common Docker issues
- Production deployment notes
- Architecture explanation (gevent workers, plugin bootstrap)

Addresses incomplete Docker setup - full stack deployment now documented and functional.
2026-09-07 00:02:17 -04:00
Proudlock
18e581b13a Add comprehensive local development setup documentation
- Complete Windows dev environment setup guide
- Database configuration (port 3307 explained)
- Plugin installation and enabling process
- Admin password reset procedures
- Troubleshooting common issues
- Zscaler proxy certificate extraction
- run_dev.py usage and why it's needed
- Common development tasks
- Architecture notes and file structure
- Git workflow documentation
2026-09-06 23:59:27 -04:00
Proudlock
3e36b54101 Fix backup feature for local Windows development
- Add --no-reload flag to run_dev.py to prevent env var loss
- Configure SHOPDB_BACKUPS_ROOT in .env for Windows path
- Create local backups directory
- Document local dev setup in backup README
- Fix: Flask auto-reloader was spawning process without env vars
2026-09-06 23:57:54 -04:00
Proudlock
5eb5c83b23 Document backup feature in scripts/backup/README.md
- Quick start guide for backup/restore
- Configuration options (local, SMB, S3)
- Troubleshooting common issues
- API endpoints reference
- Clarify empty list on first deployment is normal
2026-09-06 23:54:11 -04:00
Proudlock
316bc81a4c Add automatic plugin installation on first Docker deployment
- Add docker-entrypoint.sh: runs migrations and bootstraps plugins
- Add bootstrap-plugins.sh: installs all plugins (disabled by default)
- Update Dockerfile to use entrypoint
- Plugins can now be enabled via Settings UI after first deployment
- Idempotent: safe to run multiple times
- Solves issue where imported prod databases had tables but no plugin registry
2026-09-06 23:01:06 -04:00
Proudlock
8a1fe0ada8 Fix backup import mismatch after wilm merge
wilm's backup implementation doesn't expose _backups_configured or
start_restore_finalizer - those were wj-specific. Since we took wilm's
complete backup system, remove the initialization code.
2026-09-06 21:45:11 -04:00
Proudlock
a6af09858c 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>
2026-09-06 21:23:42 -04:00
95abe72729 Endpoint telemetry, PC Detail rework, a checked backup/restore path, and setup-wizard fixes 2026-09-06 19:25:02 -04:00
993353dfc0 Endpoint telemetry, PC Detail rework and a checked backup/restore path 2026-09-06 18:53:11 -04:00
benjamin.baker
811e6ca61a Name the missing relationship types in the seed error
The printer assignment routes checked usesprinter and defaultprinter
together and reported one generic string: "Relationship types are not
seeded". A partially seeded table is the common case, so that message
sends someone looking for an empty table they will not find.

Found on this stack: relationshiptypes held 9 rows including
defaultprinter and controls, but usesprinter had never been inserted -
the id sequence shows it was never created rather than created and
deleted, since defaultprinter took the id usesprinter would have used.
The missing type also silently cost defaultprinter its propagation rail,
because _seed_propagation returns without comment when either end is
absent, so one gap broke assignment in two places.

Now reports which types are actually missing, e.g. "Relationship type(s)
not seeded: usesprinter - run: flask seed reference-data". Same check,
same 500, same remedy; it just says what is wrong.

Present on main, wj and dev-be as well.
2026-09-06 18:39:12 -04:00
benjamin.baker
7d3ddba61e Fix PC Types list rendering empty
listAll() pages past the server's 100-row cap and returns the ROWS, not
an axios response - there is no .data to unwrap. PCTypesList still read
response.data.data, which threw, and the || [] fallback swallowed it and
rendered an empty list every time regardless of what was in the table.

The call was switched from list() to listAll() in fd513c0 to page past
the cap, but the line below it kept the unwrap that was correct for
list(). Half a change.

The add/edit dropdown was unaffected because PCForm calls list() with
perpage, so a newly added type appeared there while the management
screen stayed blank - which is what made this look like a write problem
rather than a read one.

Every other listAll() call site in the tree already assigns the array
directly; this was the only outlier. NotificationForm deliberately
re-wraps to match its .list() siblings in the same destructure and is
left alone.

Inactive types now render too, which is what active=false was always
asking for.
2026-09-06 18:39:00 -04:00
benjamin.baker
c078369bb3 Spread agent first contact with startup jitter
Both agent loops made their first request the instant the service
started, and the service is installed start=auto - so a patch reboot put
the whole fleet on the wire together, twice per machine (a telemetry
report and a command poll). The existing jitterseconds does not help: it
spreads the gap between runs, not first contact.

Measured on a 700-endpoint burst: simultaneous arrival drove TLS
handshakes to 1482ms worst case and pinned CPU at 305% while the
database sat idle at 3 active queries. Spreading the same arrivals over
30s cut worst-case handshake to 63.7ms with no server-side change. The
server was never the constraint; simultaneity was. Raising the
SQLAlchemy pool was tried and rejected - it moved the queue from
SQLAlchemy into MySQL without adding throughput.

StartupJitter is shared by both workers so the clamp and the
randomisation cannot drift apart between them. Randomised in
milliseconds rather than whole seconds because a 30s window at second
granularity re-clumps a large fleet into 30 buckets of simultaneous
arrivals - the same pile-up in miniature. Clamped to 300s and warned
about when out of range: the command channel is how an operator would
correct a bad value, so an unbounded delay would remove the means of
recovery.

The cost is up to startupjitterseconds of extra latency before a
rebooted endpoint picks up a queued command, on top of a reboot the
machine was already unreachable for. The once-only console switch
bypasses the delay entirely, so smoke tests stay instant.

Agent 1.12.0. Minor because the appsettings schema gains a key; packages
without it fall back to the AgentConfig default. The shipped installer
template in siteagent.py carries the new key, so the containers need
rolling before newly built packages pick it up.
2026-09-06 18:08:46 -04:00
benjamin.baker
6ae251d2ca Move API to gevent and restore the claim hold to 25s
The claim poll holds a connection open server-side. Under gthread that
pinned one of a worker's 8 OS threads, so 14 agents at hold=25 left a
worker with 8 of 9 threads parked in timed sleep and UI requests hashed
to that worker waiting 12-24s. Hold was cut to 5s as an interim fix.

gevent removes the constraint rather than working around it: 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 a UI request
anything. Measured at 700 simulated agents plus UI, integration and kiosk
traffic: zero errors, UI median 7.5ms, peak RSS 479MB. CPU is the binding
resource, not memory or DB connections.

With that gone the 5s hold is a liability. Hold length sets the fleet's
request rate, not its concurrency, because the agent re-polls immediately
on a 204 - so hold=5 is 5x the requests of hold=25, against a burst
bottleneck that measured as TLS handshake CPU. 25 also restores the
margin against the agent's MinIdlePollDuration=5s floor used to tell a
normal hold-window close apart from a transient failure; at 5 it was
~20ms.

Timeout raised to 60 so gunicorn cannot kill a healthy long-poll
mid-hold, and the access log now carries request duration - without it a
slow request is indistinguishable from a fast one and latency has to be
reconstructed from outside the container. Healthcheck moved off
POST /api/auth/login, which logged a 400 every 30s and exercised the
credential path from a probe.
2026-09-06 18:08:29 -04:00
benjamin.baker
7905917cd4 Green Up: scope panel-group check to panel-backed tabs
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled
Corporate Tools is the first PCDetail tab that mounts no PluginAssetPanels: it is permission-gated and renders a fixed action list, so no panel can land in it. The vocabulary check asserted every tabs.push key appears in RENDERED_GROUPS, which cd70556 broke by adding that tab.

Adding 'corptools' to the vocabulary would have made it a legal panel group with no panel host - fetched, held, rendered nowhere - the silent-drop failure this file exists to catch, and it would also have failed the server-vocabulary check against ASSET_PANEL_GROUPS. So the invariant is narrowed to panel-backed tabs instead, with a mirror check that any tab mounting a PluginAssetPanels must have its group rendered.
2026-09-06 05:38:44 -04:00
benjamin.baker
cd705563d0 Add Corporate Tools tab, Dell Command actions, and user-context refresh
Corporate Tools tab on PC Details, gated by endpoints.deploy and liveness.
Starts already-registered scheduled tasks; the agent's ScheduledTaskPolicy
allowlist remains the authority on what may actually run.

Dell Command | Update split into two actions (Scan, Apply Updates) because
they have very different consequences. Apply Updates carries a persistent
breakglass warning in the card naming Tanium as the primary update utility,
rendered between the row title and description so it is visible at all times
rather than only in the confirm dialog. Destructive actions keep the typed
RUN confirmation.

Fix scheduledtask_run being stripped by Configuration Policy: the
intersection in ALLOWED_ACTIONS_FOR dropped it on any endpoint whose
effective policy was non-empty, so every Corporate Tools click failed with
"not permitted by policy" regardless of the caller's role. Corporate tools
are role-gated only, via POLICY_EXEMPT_ACTIONS; policy keeps authority over
monitors and service/process primitives.

Fix blank PC Details page: watch(detailtabs) evaluates its source during
setup and read canDeploy before its const declaration, throwing a TDZ
ReferenceError that aborted setup with no API request made.

Mapped-drive refresh moves to the tray. SYSTEM cannot probe user-scoped
drives, so the service requests a re-probe over a named event and waits for
the spool collectedat stamp to advance before collecting. The probe task is
registered as a ShopFloor user principal; the Users-group principal remains
only as a degraded fallback since a group member cannot start it.
2026-09-06 05:22:05 -04:00
benjamin.baker
f832c376f1 Add an Agent Version column to the Fleet view
Placed between Health and Logged-in User on /endpoints. The main agent
version, not the tray: both ship side by side in the payload as
agentversion and trayversion, so the wrong one is an easy pick.

Frontend only. EndpointState.to_dict() has carried agentversion all
along, so the fleet list API was already sending the field and nothing
rendered it.

Also bumped the empty-state colspan from 9/8 to 10/9. It is hardcoded
rather than derived from the header count, so it does not track a new
column on its own and silently under-spans the "No endpoints found" row
if missed.
2026-09-05 18:20:45 -04:00
benjamin.baker
82d9dbae8a Fix CI: untrack docs prose, correct two stale frontend specs
wilm has been red for eight consecutive runs across backend, gates and
frontend. Three failures, two unrelated causes, neither introduced by the
endpoint agent work.

docs/ prose (backend + gates):
The docs gates detect a published checkout by the ABSENCE of docs/*.md,
because docs/ becomes the wiki on that side. DEV-BUILD-TEST.md and
PLUGIN-HOOKS.md were committed in unrelated changes, which made CI look
like an internal checkout carrying almost no prose. test_there_are_docs_
to_check then failed on `assert 3 > 20`, test_the_map_itself_is_current
failed on a generated PROJECT-MAP.md that is correctly untracked, and
check-gates-ran.sh failed on the resulting skip - a skipped gate reports
green and checks nothing, so it refuses to accept one.

The existing .gitignore comment predicted this exactly and was defeated by
naming files individually. Untracked both (kept on disk) and replaced the
per-file list with docs/*.md so the next authored doc cannot reintroduce
it. Scoped to the top level: docs/notes/ prose and docs/openapi.json stay
tracked, and the gates only glob docs/*.md.

Frontend specs:
getEntraDeviceUrls() gained lapsUrl and loaded, but two siteSettings specs
still asserted the old two-key shape with toEqual, which is exact-match.
The implementation is correct - loaded is what lets a caller distinguish a
failed fetch from an unconfigured site, since both read as empty strings -
so the specs were brought up to the current shape rather than the return
value narrowed.

pinnedPanels.spec.js sliced 420 characters from getEntraDeviceUrls() and
asserted the finally block appeared inside it. Adding the retry logic
pushed finally past that offset, so a correct implementation reported as
"the finally is missing". Rebounded the slice to the function's own extent,
matching the structural approach already used elsewhere in this file for
the same reason. Verified by mutation: renaming the flag in the source
fails the test, which also caught that the spec reads
plugins/computers/frontend/views/PCDetail.vue and not the .plugins-staged
build copy.
2026-09-05 17:50:02 -04:00