Bring the wilm branch into main: endpoints plugin, agent, policies, CSV import

Ports the corporate-GitHub wilm branch onto main, keeping the two branches
separate. Thirteen upstream commits arrive with their authorship intact; the
review fixes and the portability work below are on top.

What arrives:

  endpoints plugin  - a ShopDB agent reporting endpoint state, telemetry,
                      services and interfaces, with a command channel the agent
                      long-polls. Ingest stays on the generic collector route
                      (ADR-006); the agent key is a UI-managed single-scope
                      token whose revocation is an instant kill switch.
  computers policies - Configuration and Business Policies (RMM-style), scope
                      trees validated at write time, actions checked against
                      the endpoints command allowlist. Feature-gated: every
                      route 404s while the setting is off, so enabling it can
                      never half-expose the surface.
  asset CSV import  - templates, a collector script carrying the site path, and
                      a validate-then-commit import. A plugin can own a virtual
                      column plus a post-write row hook, which is how a PC's
                      machine number becomes a controls link without a
                      hand-built relationships sheet.
  agent/            - the Windows agent and its installer.

Contract 0.21.0 -> 0.24.0, additive throughout: register_import_hook (0.22),
mint/list/revoke_service_token (0.23), collector_authorized (0.24). The stability
table had no rows for any of them and now does.

MySQL 5.6 portability. The branch stored three policy columns as sa.JSON, the
only JSON columns in the product, and 5.6 has no JSON type: the migration failed
with a bare syntax error near the word JSON. They are TEXT now, through a
JSONText type decorator, so all seventeen call sites still read and write dicts.
The convention elsewhere is already TEXT (guides.blocksjson,
geenforce.manifestjson).

Review fixes carried on top:

  - six policy GETs were jwt_required(optional=True), which authenticates a
    caller without checking anything; they require computers.view now, and six
    redundant jwt_required decorators are gone
  - the agent installer took a default BaseUrl and CollectorKey, so a mistyped
    run silently pointed an endpoint at the wrong instance; both are mandatory
    and a non-https BaseUrl throws unless -AllowInsecureHttp is passed
  - a committing CSV import wrote no audit row

Documentation: the 34 new routes are described in docs/api-inventory.json, which
is hand-maintained on purpose, and the generated spec now covers 331 paths and
477 operations across 22 surfaces. DEVELOPMENT-SETUP's 1071 troubleshooting row
said "use MySQL 8 for dev" and now gives the actual fix, including the trap that
5.6 downgrades ROW_FORMAT=DYNAMIC to Compact with a warning instead of an error.

Full suite 2123 passed, vitest 260 passed, all seventeen plugin chains migrate
clean on MySQL 5.6 and the naming, docs, parity and read-surface gates are green.
This commit is contained in:
cproudlock
2026-08-28 16:17:17 -04:00
parent 93ca7587ce
commit 784964c907
149 changed files with 17545 additions and 627 deletions

23
.dockerignore Normal file
View File

@@ -0,0 +1,23 @@
# Keep the build context lean and prevent host artifacts from clobbering
# in-image installs. node_modules is platform-specific: copying the host's
# (Windows) tree over the container's `npm ci` breaks rollup's native binary.
**/node_modules
frontend/dist
frontend/src/.plugins-staged
frontend/src/router/routes.gen.js
# Python caches and virtualenvs
**/__pycache__
**/*.pyc
.venv
venv
# Local dev and VCS noise
.git
.gitignore
*.log
.env.local
# Installer build artifacts (not part of the app image)
installer-arch-dev/build
installer-arch-dev/dist

View File

@@ -4,7 +4,7 @@
# refuses to boot when SECRET_KEY, JWT_SECRET_KEY, DATABASE_URL, or # refuses to boot when SECRET_KEY, JWT_SECRET_KEY, DATABASE_URL, or
# CORS_ORIGINS are missing or use the dev defaults. # CORS_ORIGINS are missing or use the dev defaults.
# #
# See docs/DEPLOY.md for the full per-site deployment runbook. # See the DEPLOY wiki page for the full per-site deployment runbook.
# ---- Flask ---- # ---- Flask ----
FLASK_APP=wsgi.py FLASK_APP=wsgi.py
@@ -25,7 +25,7 @@ JWT_SECRET_KEY=change-this-to-another-secure-random-string
# Format: mysql+pymysql://<user>:<password>@<host>:<port>/<database>?charset=utf8mb4 # Format: mysql+pymysql://<user>:<password>@<host>:<port>/<database>?charset=utf8mb4
# In docker-compose, host is `db` (the service name). # In docker-compose, host is `db` (the service name).
# The ?charset=utf8mb4 keeps the connection on utf8mb4; create the database as # The ?charset=utf8mb4 keeps the connection on utf8mb4; create the database as
# utf8mb4 too (see docs/DEPLOY.md). Both must be utf8mb4 to match the schema. # utf8mb4 too (see the DEPLOY wiki page). Both must be utf8mb4 to match the schema.
DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@db:3306/shopdb_flask?charset=utf8mb4 DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@db:3306/shopdb_flask?charset=utf8mb4
# ---- CORS (required, no wildcards in production) ---- # ---- CORS (required, no wildcards in production) ----
@@ -64,6 +64,7 @@ ZABBIX_TOKEN=
# COLLECTOR_API_KEY_<PLUGINNAME> first, then COLLECTOR_API_KEY as fallback. # COLLECTOR_API_KEY_<PLUGINNAME> first, then COLLECTOR_API_KEY as fallback.
# COLLECTOR_API_KEY= # COLLECTOR_API_KEY=
# COLLECTOR_API_KEY_COMPUTERS= # COLLECTOR_API_KEY_COMPUTERS=
# COLLECTOR_API_KEY_ENDPOINTS=
# ---- Trusted plugin publisher keys (ADR-013, optional) ---- # ---- Trusted plugin publisher keys (ADR-013, optional) ----
# Public-key PEM paths (OS path separator: ':' on Linux, ';' on Windows) used # Public-key PEM paths (OS path separator: ':' on Linux, ';' on Windows) used
@@ -107,5 +108,5 @@ ZABBIX_TOKEN=
# Serve the app under a URL prefix instead of the server root, e.g. as an IIS # Serve the app under a URL prefix instead of the server root, e.g. as an IIS
# Application at /ops under an existing site. The frontend must be rebuilt with # Application at /ops under an existing site. The frontend must be rebuilt with
# the matching base: VITE_BASE_PATH=/ops/ npm run build. Leave unset when the # the matching base: VITE_BASE_PATH=/ops/ npm run build. Leave unset when the
# app owns its own site/port (the default). See docs/INSTALL-WINDOWS-IIS.md. # app owns its own site/port (the default). See the INSTALL-WINDOWS-IIS wiki page.
# MOUNT_PATH=/ops # MOUNT_PATH=/ops

86
.github/agent-workflow.md vendored Normal file
View File

@@ -0,0 +1,86 @@
# Agent workflow rules
Companion to `copilot-instructions.md` (which owns naming/style/architecture).
These cut wasted turns. All are lessons from real sessions.
## Environment - know these, do not rediscover
- Shell is **PowerShell**: use `;` not `&&`, `2>$null` not `2>/dev/null`.
- API container `shopdb-flask-wilm-api-1` (Flask, port `5001`).
- DB container `shopdb-flask-wilm-db-1` (MySQL 8, root pw `root_password_123`).
- SPA is **baked into the image** (not bind-mounted). Plugin code is
**bind-mounted read-only**.
- `docs/` is stripped to a public wiki mirror - keep working docs out of it.
- `requirements.txt` is hash-checked - never hand-edit.
## Verify, don't react
- The terminal "Command failed" banner fires on any non-zero exit or stderr
output and is **usually a false negative** (`curl --fail` on a 400,
pytest/npm/git writing to stderr). Judge success from real output.
- Read container health via
`docker inspect --format '{{.State.Health.Status}}'` plus a probe **without**
`--fail` - never eyeball the exit code.
## Container runs the image, not your edits
Before running anything in the container that must reflect a change:
1. Frontend: `npm run build`, then
`docker cp frontend/dist/. shopdb-flask-wilm-api-1:/app/frontend/dist`.
2. Backend/scripts/templates: `docker cp` the files (or rebuild) first.
If a result contradicts a change you just made, suspect stale container code
before re-deriving the fix.
## Testing
- Confirm the working directory once, up front.
- Run the smallest test that proves the point; find the minimal polluter for
isolation bugs. Run the full suite only at the end.
- Clear stale local state before plugin/migration tests:
`if (Test-Path instance/plugins.json) { Remove-Item instance/plugins.json -Force }`.
- Naming-gate hits inside local venvs (`.ci-venv/`, `env/`) are not repo code -
do not "fix" vendored libraries.
- Final gates, exactly as CI: `python -m pytest tests/ -q`;
`cd frontend; npx vitest run; npm run build; cd ..`;
`bash scripts/check-naming-and-style.sh`.
## Migrations and imports
- Every migration: idempotent guard + real downgrade; must run clean on strict
MySQL 8. If you rename indexes in `upgrade()`, update `downgrade()` to match.
- Avoid duplicate indexes: `index=True` plus an explicit `db.Index` on the same
column creates two.
- Importers/seeders match by natural key and must be idempotent - prove it by
running twice (expect `updated`, not `created`). Watch all-or-nothing writes.
## Don't offer rollbacks you don't need
State current state plainly; surface a rollback path only for a real regression.
## Wilmington (wilm) local-run known failures - do not re-investigate
Running the full `python -m pytest tests/ -q` on a Windows dev box (not CI)
produces a stable set of environmental failures that are NOT code regressions.
Treat these as expected locally; judge your change against CI/Linux instead:
- `test_core/test_security_headers.py::test_a_served_upload_is_sandboxed` -
`PermissionError [WinError 32]` (served upload file still locked by another
process). Windows file-lock timing, not a headers bug.
- `test_plugin_contract.py::test_import_scan_covers_symlinked_plugins` -
`OSError [WinError 1314]` (symlink privilege not held). Needs admin/Developer
Mode to create symlinks; irrelevant to plugin logic.
- `test_plugin_importguard.py::test_verified_source_signed_ok_tampered_raises` -
CRLF vs LF (`b'# plugin\r\n' == b'# plugin\n'`). Git autocrlf checkout, not a
signing bug.
- `test_docs_publishable.py` and `test_docs_versions.py` - the local `docs/`
tree is not the stripped public wiki mirror, so doc-count / PROJECT-MAP
assertions fail. Expected off-CI.
- `test_core/test_public_read_surface.py::test_every_get_route_is_gated_or_named_public`
- pre-existing ungated-GET inventory drift; only act on it if YOUR change
added a new GET route.
If a run shows ONLY these, the suite is green for our purposes. Anything outside
this list is real - investigate it.

View File

@@ -72,6 +72,7 @@ name. Spell out: `config context manager request response environment utilities`
with quantities are standalone tables, not assets. with quantities are standalone tables, not assets.
- **Ledger pattern**: a cached `quantityonhand` moves in the SAME commit as the - **Ledger pattern**: a cached `quantityonhand` moves in the SAME commit as the
signed transaction row it reflects. signed transaction row it reflects.
- **Configuration for endpoint agent**: The endpoint agent's live collector key and base URL are provisioned in the Windows registry `HKLM\SOFTWARE\GE\ShopDB` (values `CollectorKey`, `BaseUrl`), not committed in `appsettings.json`; `appsettings.json` holds only non-secret runtime settings.
## Before you finish a change ## Before you finish a change

13
.github/workflows/smoke.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
name: Runner smoke
on:
workflow_dispatch:
jobs:
hello:
runs-on: arc-runner-set
steps:
- uses: actions/checkout@v4
- run: |
echo "runner: $RUNNER_NAME os: $RUNNER_OS"
python3 --version || echo "no python3 on PATH"
node --version || echo "no node on PATH"
bash --version | head -1

9
.gitignore vendored
View File

@@ -83,3 +83,12 @@ scripts/site_imports/wjf/idmap.json
# work-PC publication clone: bundle drop folder for the transfer pipeline # work-PC publication clone: bundle drop folder for the transfer pipeline
_transfer/ _transfer/
# Local developer scratch notes and chat handoffs - never committed
devnotes/
# docs/ is generated/published to the wiki, not tracked in the product repo.
# A single tracked file here makes docs/ exist in the CI checkout, which
# defeats the intentional skip in the docs publish/version tests. Keep this
# local-only guide out of version control so those tests skip as designed.
docs/CSV-IMPORT-GUIDE.md

View File

@@ -1920,7 +1920,7 @@ letting other GE Aerospace sites stand up their own self-hosted instance
- Documentation overhaul: new CONFIG, UPGRADE, and BACKUP-RESTORE guides; - Documentation overhaul: new CONFIG, UPGRADE, and BACKUP-RESTORE guides;
reconciled README, DEPLOY, status docs, and ROADMAP. reconciled README, DEPLOY, status docs, and ROADMAP.
- ADR-007 (product versioning and releases), CHANGELOG, and best-effort - ADR-007 (product versioning and releases), CHANGELOG, and best-effort
Gitea Actions CI (backend tests, naming/style gate, frontend build). CI (backend tests, naming/style gate, frontend build).
### Changed ### Changed
@@ -1951,11 +1951,3 @@ letting other GE Aerospace sites stand up their own self-hosted instance
integration that passed the key as a query parameter. See integration that passed the key as a query parameter. See
`docs/COLLECTOR-INTEGRATION.md`. `docs/COLLECTOR-INTEGRATION.md`.
[Unreleased]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.10.0...HEAD
[0.10.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.9.0...v0.10.0
[0.9.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.8.1...v0.9.0
[0.8.1]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.8.0...v0.8.1
[0.8.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.7.0...v0.8.0
[0.7.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.6.0...v0.7.0
[0.6.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/compare/v0.5.0...v0.6.0
[0.5.0]: https://gitea.proudtech.net/ge-aerospace/shopdb-flask/releases/tag/v0.5.0

View File

@@ -136,7 +136,7 @@ When creating a new plugin:
4. Follow naming conventions above. 4. Follow naming conventions above.
5. Run `flask plugin install <name>` to install. 5. Run `flask plugin install <name>` to install.
See `docs/PLUGIN-QUICKSTART.md` (when published) for the 30-minute walkthrough. See the PLUGIN-QUICKSTART wiki page (when published) for the 30-minute walkthrough.
## Testing ## Testing

View File

@@ -23,6 +23,23 @@ FROM node:20-slim AS frontendbuild
WORKDIR /build WORKDIR /build
# Optionally trust the site's corporate root CA so npm's TLS to the registry
# validates behind an inspecting proxy. The .pem is gitignored and absent from a
# clean checkout, so the copy is made optional by pairing it with a file that
# always exists; the cert is installed only when actually provided. Without it
# npm 10.x behind an inspecting proxy fails silently and leaves an incomplete
# node_modules; a clean build with no proxy is unaffected.
COPY corp-root-ca.pem* frontend/package.json /tmp/corpca/
# node:20-slim ships without the ca-certificates package (no update-ca-certificates
# and no /usr/local/share/ca-certificates), so install it before trusting the cert.
RUN if [ -f /tmp/corpca/corp-root-ca.pem ]; then \
apt-get update && apt-get install -y --no-install-recommends ca-certificates && \
rm -rf /var/lib/apt/lists/* && \
cp /tmp/corpca/corp-root-ca.pem /usr/local/share/ca-certificates/corp-root-ca.crt && \
update-ca-certificates; \
fi
ENV NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/corp-root-ca.crt
# Copy only the manifests first so `npm ci` caches on dependency changes. # Copy only the manifests first so `npm ci` caches on dependency changes.
COPY frontend/package*.json ./frontend/ COPY frontend/package*.json ./frontend/
RUN cd frontend && npm ci RUN cd frontend && npm ci
@@ -86,7 +103,7 @@ USER shopdb
EXPOSE 5001 EXPOSE 5001
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl --fail --silent http://localhost:5001/api/auth/login -X POST \ CMD curl --silent http://localhost:5001/api/auth/login -X POST \
-H "Content-Type: application/json" -d '{}' \ -H "Content-Type: application/json" -d '{}' \
| grep -q "VALIDATION_ERROR" || exit 1 | grep -q "VALIDATION_ERROR" || exit 1

View File

@@ -22,7 +22,7 @@ ShopDB tracks and manages:
**Backend:** **Backend:**
- Python 3.14 with Flask - Python 3.14 with Flask
- SQLAlchemy ORM - SQLAlchemy ORM
- MySQL 5.7+ database (5.6 works with extra utf8mb4 config; see docs/DEPLOY.md) - MySQL 5.7+ database (5.6 works with extra utf8mb4 config; see the DEPLOY wiki page)
- JWT authentication - JWT authentication
- Plugin architecture for extensibility - Plugin architecture for extensibility
@@ -100,7 +100,7 @@ To maintain consistency with the legacy ShopDB database and codebase, the follow
- Python 3.14 - Python 3.14
- Node.js 18+ - Node.js 18+
- MySQL 5.7+ (5.6 works with extra utf8mb4 config; see docs/DEPLOY.md) - MySQL 5.7+ (5.6 works with extra utf8mb4 config; see the DEPLOY wiki page)
ShopDB Flask uses MySQL as the canonical database. SQLite is used only for the ShopDB Flask uses MySQL as the canonical database. SQLite is used only for the
test suite (`TestingConfig` in `shopdb/config.py` points at an in-memory test suite (`TestingConfig` in `shopdb/config.py` points at an in-memory
@@ -121,7 +121,7 @@ so a container deploy needs no separate Node build step.
```bash ```bash
cp .env.example .env cp .env.example .env
# Edit .env: set SECRET_KEY, JWT_SECRET_KEY, DATABASE_URL, CORS_ORIGINS, # Edit .env: set SECRET_KEY, JWT_SECRET_KEY, DATABASE_URL, CORS_ORIGINS,
# and the MYSQL_* passwords (see docs/CONFIG.md for every variable). # and the MYSQL_* passwords (see the CONFIG wiki page for every variable).
docker compose up -d --build docker compose up -d --build
@@ -172,7 +172,7 @@ including VS Code and troubleshooting, is the DEVELOPMENT-SETUP page in the
project wiki. project wiki.
To import a site's legacy data, use the HTTP import surface: an admin API To import a site's legacy data, use the HTTP import surface: an admin API
token plus [docs/IMPORT-API.md](docs/IMPORT-API.md) drive the whole migration token plus the IMPORT-API page in the project wiki drive the whole migration
through documented endpoints (`X-Import-Mode` preserves original timestamps). through documented endpoints (`X-Import-Mode` preserves original timestamps).
`scripts/site_imports/wjf/` is the reference site's loader, kept as a worked `scripts/site_imports/wjf/` is the reference site's loader, kept as a worked
example of the whole sequence. example of the whole sequence.
@@ -181,11 +181,11 @@ example of the whole sequence.
| Target | Use | | Target | Use |
|---|---| |---|---|
| **Windows Server + IIS** (how sister sites run) | **[docs/INSTALL-WINDOWS.md](docs/INSTALL-WINDOWS.md)** - one installer `.exe`, offline, no manual IIS work. Day 2: [docs/OPERATE-WINDOWS.md](docs/OPERATE-WINDOWS.md) | | **Windows Server + IIS** (how sister sites run) | **the INSTALL-WINDOWS page in the project wiki** - one installer `.exe`, offline, no manual IIS work. Day 2: the OPERATE-WINDOWS page in the project wiki |
| Linux / Docker, air-gapped | [docs/DEPLOY-AIRGAP.md](docs/DEPLOY-AIRGAP.md) | | Linux / Docker, air-gapped | the DEPLOY-AIRGAP page in the project wiki |
| Linux / Docker, connected | [docs/DEPLOY.md](docs/DEPLOY.md) | | Linux / Docker, connected | the DEPLOY page in the project wiki |
For every environment variable and Setting key see [docs/CONFIG.md](docs/CONFIG.md). For every environment variable and Setting key see the CONFIG page in the project wiki.
### What the Windows installer requires ### What the Windows installer requires
@@ -205,7 +205,7 @@ The binding constraint is the operating system rather than IIS: the payload is
Python 3.14 plus HttpPlatformHandler and URL Rewrite, and the handler itself runs Python 3.14 plus HttpPlatformHandler and URL Rewrite, and the handler itself runs
on older IIS. An older host is untested rather than known-broken. on older IIS. An older host is untested rather than known-broken.
If you must deploy onto something older, [docs/INSTALL-WINDOWS-IIS.md](docs/INSTALL-WINDOWS-IIS.md) If you must deploy onto something older, the INSTALL-WINDOWS-IIS page in the project wiki
is the manual procedure and has no OS gate - it even documents MySQL 5.6, which is is the manual procedure and has no OS gate - it even documents MySQL 5.6, which is
that era of machine. The trade is stated there: it produces a server the that era of machine. The trade is stated there: it produces a server the
installer will not subsequently upgrade. installer will not subsequently upgrade.
@@ -248,7 +248,7 @@ Query parameters for list endpoints:
## Where to start ## Where to start
[docs/START-HERE.md](docs/START-HERE.md) routes by what you are here to do - the START-HERE page in the project wiki routes by what you are here to do -
standing up a site, deploying the shop-floor tools, writing a plugin, standing up a site, deploying the shop-floor tools, writing a plugin,
integrating with the API, or diagnosing something. On the published mirror the integrating with the API, or diagnosing something. On the published mirror the
same page is the wiki's front door. same page is the wiki's front door.
@@ -259,7 +259,7 @@ ShopDB supports plugins for extending functionality. See `CONTRIBUTING.md` for p
The image bundles these plugins; only the ones a site installs are loaded. The The image bundles these plugins; only the ones a site installs are loaded. The
current count and each plugin's version and migration head are in current count and each plugin's version and migration head are in
[docs/PROJECT-MAP.md](docs/PROJECT-MAP.md), which is generated: the PROJECT-MAP page in the project wiki, which is generated:
- **backups** - Machine configuration backups (NTLARS/DNC, CMM, part marker, UDC) with revision history and diffs - **backups** - Machine configuration backups (NTLARS/DNC, CMM, part marker, UDC) with revision history and diffs
- **computers** - Shopfloor PCs and workstations, collector fleet ingest - **computers** - Shopfloor PCs and workstations, collector fleet ingest

7
agent/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# .NET build output - never tracked; produced by dotnet build/publish.
bin/
obj/
publish/
# Local publish scratch and command-output captures.
*.user

166
agent/README.md Normal file
View File

@@ -0,0 +1,166 @@
# ShopDB Endpoint Agent
A Windows telemetry agent that reports endpoint state to a ShopDB server's
`endpoints` plugin. It runs as a Windows service in production and as a console
app for development and testing.
## Projects
- `ShopDbEndpointAgent.Core` - contracts, config, logging, collection, and the
resilient collector HTTP client. No host concerns; unit-testable.
- `ShopDbEndpointAgent` - the service host (generic host + Windows service
lifetime). Composes Core through DI. This is the telemetry runtime.
- `ShopDbEndpointAgent.Tray` - interactive user-session tray app: a system-tray
icon with "Send Status now" (on-demand report) and the 5-click issue reporter
(ServiceNow flow is still a scaffold). Separate process from the service,
because a SYSTEM service cannot draw a tray icon in a user session.
## Configuration
Runtime config is layered. Non-secret defaults live in `appsettings.json`
(shipped with the build). The collector key and base URL are provisioned out of
band in the registry and win over `appsettings.json`, so a distributed build
never carries the secret and the site can rotate the key without a rebuild.
### Registry (production)
Create these values under `HKLM\SOFTWARE\GE\ShopDB` (64-bit view):
| Value | Type | Meaning |
|----------------|--------|--------------------------------------------------|
| `BaseUrl` | String | `https://shopdb.example.net` (server root) |
| `CollectorKey` | String | The endpoint agent key from Settings -> Endpoints -> Key management |
Example (elevated PowerShell):
```
New-Item -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Force | Out-Null
Set-ItemProperty -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Name 'BaseUrl' -Value 'https://shopdb.example.net'
Set-ItemProperty -Path 'HKLM:\SOFTWARE\GE\ShopDB' -Name 'CollectorKey' -Value '<paste the generated key>'
```
### appsettings.json (all environments)
| Key | Default | Meaning |
|-------------------|-------------|------------------------------------------------|
| `baseurl` | "" | Dev fallback for the server root (registry wins). |
| `apikey` | placeholder | Dev fallback for the collector key (registry wins). |
| `cadenceseconds` | 900 | Report interval (15 min). |
| `jitterseconds` | 60 | Random +/- spread on the interval (anti-herd). |
| `adoptonfirstrun` | true | Send `adopt=true` on first contact only. |
| `utilitiespath` | "" | Directory of site PowerShell "agent utilities". |
| `logpath` | "" | Override the rolling log directory. |
For a dev box, set `baseurl` and `apikey` here and skip the registry.
## Running
Console (dev), one report then exit:
```
dotnet run --project ShopDbEndpointAgent -- --once
```
Console (dev), continuous cadence loop:
```
dotnet run --project ShopDbEndpointAgent -- --console
```
## Install as a Windows service
Publish self-contained, then register with `sc.exe` (elevated):
```
dotnet publish ShopDbEndpointAgent -c Release -r win-x64 --self-contained -o C:\ProgramData\ShopDB\EndpointAgent\bin
sc.exe create "ShopDBEndpointAgent" binPath= "C:\ProgramData\ShopDB\EndpointAgent\bin\shopdb-endpoint-agent.exe" start= auto DisplayName= "ShopDB Endpoint Agent"
sc.exe description "ShopDBEndpointAgent" "Reports endpoint telemetry to ShopDB."
sc.exe start "ShopDBEndpointAgent"
```
The service runs as LocalSystem by default, which it needs to read the Security
event channel and system WMI.
## Tray app (system-tray icon)
The tray is a per-user process, not a service, so it starts at sign-in rather
than under `sc.exe`. `install-shopdbagent.ps1` handles it: it copies
`shopdb-endpoint-tray.exe` next to the agent, registers an HKCU logon autostart
(`Software\Microsoft\Windows\CurrentVersion\Run\ShopDbEndpointTray`), and
launches it for the current user. Run the installer from the operator's own
session so the autostart lands in the right user hive. Pass `-NoTray` for a
headless, service-only install.
Right-click the tray icon for:
- `Send Status now` - reports this PC to ShopDB immediately so the monitor page
refreshes without waiting for the next cadence tick. It shells out to
`shopdbagent.exe --once`, reusing the exact production collection path; the
tray holds no collector key. A balloon tip reflects success or failure (on
failure, see the rolling agent log).
- `Report an issue...` - the ServiceNow ticket flow (scaffold; submission path
is an open decision).
A desktop shortcut to `shopdb-endpoint-tray.exe` can be added later for an
explicitly launchable ServiceNow surface.
## WebUI command execution model (security context)
The WebUI can send an endpoint an allowlisted command (server source of truth:
`plugins/endpoints/commands.py`; agent handler: `CommandExecutor`). The agent
service runs as LocalSystem, but not every command runs in that context. The
launch context is chosen per action so processes surface to the operator while
privileged operations keep the token they need:
- Generic `process_start` (for example DncMain, UDC) launches into the ACTIVE
CONSOLE USER's session at medium integrity, so the app is visible on the
operator's desktop. CyberArk/EPM brokers any elevation afterward.
- Generic `process_stop` / `process_status` run as SYSTEM (kill and probe).
- `service_start` / `service_stop` / `service_status` run as SYSTEM, because
service control requires the admin/SYSTEM token.
- `mtconnect_start` / `mtconnect_stop` / `mtconnect_status` all run as SYSTEM,
because MTConnect is the "UDC MTConnect Agent" Windows service and service
control requires the admin/SYSTEM token. `mtconnect_start` runs `install.bat`
(`Agent.exe install`) to install or repair the service, then ensures it is
running; `mtconnect_stop` stops the service; `mtconnect_status` queries it.
`run.bat` (`agent.exe run agent.cfg`) is a foreground TESTING launcher and is
deliberately NOT used by the agent.
- `system_restart` runs as SYSTEM and reboots the machine immediately via
`shutdown.exe /r /t 0` (no grace timer). It is a fixed action with no target,
gated server-side by the dedicated `endpoints.restart` permission on top of
`endpoints.command`, and fronted by a WebUI confirmation gate.
Do not route MTConnect through the generic `process_start` user-session path or
service control breaks. MTConnect is intentionally absent from the
`process_start` launch map for the same reason; the fixed `mtconnect_*` handler
owns it.
Behavior note for redeploys: the MTConnect service-control model is a runtime
change, so the agent must be rebuilt and redeployed for it to take effect.
Server-side command/allowlist/RBAC changes are picked up by the container
rebuild; rebuild and redeploy the agent whenever the runtime launch/control
logic itself changes.
## Diagnostics
- Rolling file log: `%ProgramData%\ShopDB\EndpointAgent\logs\agent-YYYYMMDD.log`
(14-day retention).
- Windows Event Log: agent runtime failures and unhandled exceptions are written
to the `Application` log under source `ShopDB Endpoint Agent`. The source is
created on first elevated run (or by the installer). Filter Event Viewer by
that source to see agent/system interop failures.
## Agent utilities (PowerShell seam)
If `utilitiespath` points at a directory, the runtime runs each `*.ps1` there
per report, parses its JSON stdout, and merges the result into the report. This
is the seam the future server-side "Agent configuration" settings page uses to
push site-authored collectors down. Scripts must be code-signed and
allow-listed before this is enabled in the field (rollout concern).
## Logging extensibility
All runtime code logs through `Microsoft.Extensions.Logging` (`ILogger`). Adding
a sink later (a ShopDB HTTP sink, Seq, syslog) is a host-builder change in
`Program.cs::ConfigureLogging` with no changes to the worker, collector, or
client.

View File

@@ -0,0 +1,109 @@
using System.Text.Json.Serialization;
namespace ShopDbEndpointAgent.Core;
// Runtime configuration for the agent. Non-secret values come from
// appsettings.json; the collector key and base URL are provisioned out of band
// in the registry (HKLM:\SOFTWARE\GE\ShopDB) and layered on top - see
// RegistryConfigProvider. The registry wins so a distributed appsettings.json
// never carries the secret and the site can rotate the key without a rebuild.
public sealed class AgentConfig
{
[JsonPropertyName("baseurl")] public string BaseUrl { get; set; } = "";
[JsonPropertyName("apikey")] public string ApiKey { get; set; } = "";
[JsonPropertyName("cadenceseconds")] public int CadenceSeconds { get; set; } = 900;
// Jitter spreads a fleet's reports so they do not arrive in lockstep
// (thundering herd). Actual delay = cadence +/- up to jitterseconds.
[JsonPropertyName("jitterseconds")] public int JitterSeconds { get; set; } = 60;
[JsonPropertyName("adoptonfirstrun")] public bool AdoptOnFirstRun { get; set; } = true;
// Directory holding site-provided PowerShell "agent utilities" the runtime
// may invoke through the documented collection seam. Empty = none loaded.
[JsonPropertyName("utilitiespath")] public string UtilitiesPath { get; set; } = "";
// Absolute path for the rolling diagnostics log. Empty = the default under
// %ProgramData%\ShopDB\EndpointAgent\logs.
[JsonPropertyName("logpath")] public string LogPath { get; set; } = "";
// What this endpoint watches. Shipped in appsettings.json (non-secret) so a
// site can retarget monitors without a rebuild. Processes are interactive
// apps watched by image name; services are Windows services watched by their
// service key name. Empty lists = nothing watched.
[JsonPropertyName("monitors")] public MonitorConfig Monitors { get; set; } = new();
// Per-image launchable paths for the generic process_start action. The wire
// never carries a path (that would be a code-execution hole); Start sends
// only the image name and the agent maps it here to a known-good executable
// (or a .bat launcher). Anything not in the resolved map fails closed. A
// site overrides or extends the shipped defaults in appsettings.json without
// a rebuild. Keyed by process image name (case-insensitive, .exe optional).
[JsonPropertyName("launchpaths")]
public Dictionary<string, string> LaunchPaths { get; set; } = new();
// Shipped defaults for the org's common shop-floor apps. A site's
// appsettings.json LaunchPaths entries win over these on a per-image basis.
// MTConnect is intentionally absent here: it is a Windows service controlled
// by the fixed mtconnect_* handler (install.bat + service control), not the
// generic process_start path. eDNC and UDC launch their runtime executables
// directly.
public static readonly IReadOnlyDictionary<string, string> DefaultLaunchPaths =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["DncMain.exe"] = @"C:\Program Files (x86)\Dnc\bin\DncMain.exe",
["UDC.exe"] = @"C:\Program Files\UDC\UDC.exe",
};
// The effective launch map: shipped defaults overlaid with appsettings.json
// entries (site wins). Keys are normalized to bare image names so a lookup
// tolerates 'DncMain' or 'DncMain.exe'.
public IReadOnlyDictionary<string, string> ResolveLaunchPaths()
{
var resolved = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in DefaultLaunchPaths)
resolved[NormalizeImage(pair.Key)] = pair.Value;
foreach (var pair in LaunchPaths)
{
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
continue;
resolved[NormalizeImage(pair.Key)] = pair.Value;
}
return resolved;
}
private static string NormalizeImage(string image)
{
var bare = (image ?? "").Trim();
if (bare.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
bare = bare[..^4];
return bare;
}
public bool IsUsable()
=> !string.IsNullOrWhiteSpace(BaseUrl)
&& !string.IsNullOrWhiteSpace(ApiKey)
&& ApiKey != "REPLACE_WITH_COLLECTOR_KEY";
}
// Watched targets for this endpoint. Both lists carry entries with a wire name
// (process image name or Windows service key name) and an optional friendly app
// label the server/UI can show (for example "eDNC" for dncMain.exe).
public sealed class MonitorConfig
{
[JsonPropertyName("processes")] public MonitorTarget[] Processes { get; set; }
= Array.Empty<MonitorTarget>();
[JsonPropertyName("services")] public MonitorTarget[] Services { get; set; }
= Array.Empty<MonitorTarget>();
}
public sealed class MonitorTarget
{
// Process image name (for example "udc.exe") or Windows service key name
// (for example "MTConnectAgent"). Matched case-insensitively.
[JsonPropertyName("name")] public string Name { get; set; } = "";
// Human-facing application label, optional. Falls back to Name when empty.
[JsonPropertyName("app")] public string App { get; set; } = "";
}

View File

@@ -0,0 +1,110 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
namespace ShopDbEndpointAgent.Core;
// Builds the effective AgentConfig by layering two sources:
// 1. appsettings.json - non-secret runtime defaults, shipped with the build.
// 2. HKLM\SOFTWARE\GE\ShopDB - the collector key and base URL, provisioned
// out of band (Intune/DSC/GE-Enforce). Registry values WIN so a
// distributed appsettings.json never carries the secret and the site can
// rotate the key without a rebuild.
//
// Reading the registry never throws out to the caller: a missing hive or value
// simply leaves the appsettings value in place, which is what makes dev boxes
// (appsettings only) and fielded machines (registry provisioned) both work.
public static class AgentConfigProvider
{
public const string RegistryPath = @"SOFTWARE\GE\ShopDB";
// Registry value names. These mirror the names the computers reporter uses
// (see plugins/endpoints/DESIGN-DECISIONS-agent.md section 1).
public const string BaseUrlValue = "BaseUrl";
public const string CollectorKeyValue = "CollectorKey";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public static AgentConfig Load(string? appSettingsPath = null,
Microsoft.Extensions.Logging.ILogger? logger = null)
{
var config = LoadAppSettings(appSettingsPath) ?? new AgentConfig();
ApplyRegistryOverrides(config, logger);
return config;
}
private static AgentConfig? LoadAppSettings(string? path)
{
path ??= Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (!File.Exists(path))
return null;
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<AgentConfig>(json, JsonOptions);
}
catch
{
// Malformed appsettings: fall back to defaults + registry so the
// agent can still run if the secret is provisioned in the registry.
return null;
}
}
private static void ApplyRegistryOverrides(AgentConfig config,
Microsoft.Extensions.Logging.ILogger? logger = null)
{
// Try 64-bit view first, then the default/32-bit view. On a locked-down
// box (CyberArk/EPM gating regedit and hive reads) the read can throw
// SecurityException/UnauthorizedAccessException even when the values
// exist. We log the exact outcome per view instead of swallowing it, so
// "Config incomplete" can be told apart from "access denied".
RegistryView[] views = { RegistryView.Registry64, RegistryView.Registry32 };
foreach (var view in views)
{
try
{
using var machine = RegistryKey.OpenBaseKey(
RegistryHive.LocalMachine, view);
using var key = machine.OpenSubKey(RegistryPath);
if (key is null)
{
logger?.LogWarning(
"Registry key HKLM\\{Path} not found in {View} view.",
RegistryPath, view);
continue;
}
var baseUrl = key.GetValue(BaseUrlValue) as string;
if (!string.IsNullOrWhiteSpace(baseUrl))
config.BaseUrl = baseUrl.Trim();
var collectorKey = key.GetValue(CollectorKeyValue) as string;
if (!string.IsNullOrWhiteSpace(collectorKey))
config.ApiKey = collectorKey.Trim();
logger?.LogInformation(
"Registry {View}: BaseUrl {BaseUrlState}, CollectorKey {KeyState}.",
view,
string.IsNullOrWhiteSpace(baseUrl) ? "MISSING" : "present",
string.IsNullOrWhiteSpace(collectorKey) ? "MISSING" : "present");
// Success reading this view; stop unless nothing was found.
if (!string.IsNullOrWhiteSpace(baseUrl) ||
!string.IsNullOrWhiteSpace(collectorKey))
return;
}
catch (Exception error)
{
logger?.LogError(error,
"Registry read of HKLM\\{Path} in {View} view was blocked " +
"({ErrorType}). If CyberArk/EPM is gating the hive, grant the " +
"agent read access or use appsettings.json for this test.",
RegistryPath, view, error.GetType().Name);
}
}
}
}

View File

@@ -0,0 +1,91 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Agent self-monitoring (WER-style). Routes otherwise-unobserved failures -
// unhandled exceptions on any thread and faulted tasks nobody awaited - to the
// ILogger (so they land in the rolling file) AND directly to a dedicated
// Windows Event Log source so agent runtime failures and agent/system interop
// errors are visible in Event Viewer alongside the OS's own records.
//
// The Event Log source is created on first use; creating a source needs admin,
// so if it is absent and cannot be created we degrade to the file log only and
// never throw from a fault handler (that would mask the original failure).
public sealed class AgentFaultMonitor
{
public const string EventLogSource = "ShopDB Endpoint Agent";
public const string EventLogName = "Application";
private readonly ILogger logger;
private readonly bool eventLogReady;
public AgentFaultMonitor(ILogger logger)
{
this.logger = logger;
eventLogReady = EnsureEventLogSource();
}
// Wire the process-wide fault hooks. Call once at startup.
public void Install()
{
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
}
public void ReportFatal(string context, Exception error)
=> Report(EventLogEntryType.Error, 1000, $"{context}: {error}");
private void OnUnhandledException(object sender, UnhandledExceptionEventArgs args)
{
var error = args.ExceptionObject as Exception;
var message = error?.ToString() ?? "unknown unhandled exception";
logger.LogCritical(error, "Unhandled exception (terminating={Terminating})",
args.IsTerminating);
Report(EventLogEntryType.Error, 1001,
$"Unhandled exception (terminating={args.IsTerminating}): {message}");
}
private void OnUnobservedTaskException(object? sender,
UnobservedTaskExceptionEventArgs args)
{
logger.LogError(args.Exception, "Unobserved task exception");
Report(EventLogEntryType.Warning, 1002,
$"Unobserved task exception: {args.Exception}");
// Mark observed so it does not escalate to a process-level failure.
args.SetObserved();
}
private void Report(EventLogEntryType type, int eventId, string message)
{
if (!eventLogReady)
return;
try
{
EventLog.WriteEntry(EventLogSource, message, type, eventId);
}
catch
{
// Event Log write failed (source removed, log full): the file log
// already has this entry; a fault handler must never throw.
}
}
private static bool EnsureEventLogSource()
{
try
{
if (EventLog.SourceExists(EventLogSource))
return true;
EventLog.CreateEventSource(new EventSourceCreationData(
EventLogSource, EventLogName));
return true;
}
catch
{
// Needs admin the first time; the installer creates the source, so
// in the field this succeeds. In dev without admin we log to file.
return false;
}
}
}

View File

@@ -0,0 +1,26 @@
namespace ShopDbEndpointAgent.Core;
// A one-shot wake signal shared between the command worker and the telemetry
// worker. After a command runs (e.g. MTConnect start/stop), the command worker
// calls Request() so the telemetry loop collects immediately instead of waiting
// out the remaining cadence, and the WebUI card reflects the new state within a
// single report cycle rather than up to 15 minutes later.
public sealed class CollectSignal
{
private readonly SemaphoreSlim gate = new(0, 1);
// Wake the telemetry loop for one off-cadence collection. Extra calls while
// one is already pending collapse into a single wake (the gate caps at 1).
public void Request()
{
try { gate.Release(); }
catch (SemaphoreFullException) { /* a wake is already pending */ }
}
// Wait until either the cadence delay elapses or a collect is requested.
// Returns true when woken by a request, false on a normal cadence timeout.
public async Task<bool> WaitAsync(TimeSpan delay, CancellationToken token)
{
return await gate.WaitAsync(delay, token);
}
}

View File

@@ -0,0 +1,118 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Posts one EndpointReport to POST {baseurl}/api/collector/endpoints with the
// X-API-Key header (plugins/endpoints/AGENT-CONTRACT.md section 1), unwrapping
// the ShopDB envelope { status, data, meta } to return the CollectorResponse.
//
// Resilience: transient failures (network errors, timeouts, 5xx, 429) are
// retried with exponential backoff plus jitter, bounded by maxAttempts. A 4xx
// other than 429 is a permanent error (bad key, unknown host in strict mode)
// and is surfaced immediately without retry - hammering the server changes
// nothing.
public sealed class CollectorClient : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
DefaultIgnoreCondition = System.Text.Json.Serialization
.JsonIgnoreCondition.WhenWritingNull,
};
private readonly HttpClient http;
private readonly ILogger<CollectorClient> logger;
private readonly int maxAttempts;
private readonly Random jitter = new();
public CollectorClient(AgentConfig config, ILogger<CollectorClient> logger,
int maxAttempts = 4)
{
this.logger = logger;
this.maxAttempts = Math.Max(1, maxAttempts);
http = new HttpClient
{
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
Timeout = TimeSpan.FromSeconds(30),
};
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
}
public async Task<CollectorResponse> SendAsync(
EndpointReport report, CancellationToken token)
{
Exception? last = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
token.ThrowIfCancellationRequested();
try
{
return await SendOnceAsync(report, token);
}
catch (CollectorException error) when (error.IsPermanent)
{
// Bad credential or rejected payload: no retry.
throw;
}
catch (Exception error) when (
error is CollectorException or HttpRequestException or TaskCanceledException
&& !token.IsCancellationRequested)
{
last = error;
if (attempt == maxAttempts)
break;
var delay = BackoffDelay(attempt);
logger.LogWarning(
"Collector send attempt {Attempt}/{Max} failed: {Message}; retrying in {Delay}s",
attempt, maxAttempts, error.Message, delay.TotalSeconds);
await Task.Delay(delay, token);
}
}
throw last ?? new CollectorException(0, "collector send failed");
}
private async Task<CollectorResponse> SendOnceAsync(
EndpointReport report, CancellationToken token)
{
using var response = await http.PostAsJsonAsync(
"api/collector/endpoints", report, JsonOptions, token);
var body = await response.Content.ReadAsStringAsync(token);
if (!response.IsSuccessStatusCode)
throw new CollectorException((int)response.StatusCode, body);
var envelope = JsonSerializer.Deserialize<CollectorEnvelope>(body, JsonOptions);
if (envelope?.Data is null)
throw new CollectorException((int)response.StatusCode, "empty response body");
return envelope.Data;
}
private TimeSpan BackoffDelay(int attempt)
{
// 2^(attempt-1) seconds, capped, plus up to 1s jitter.
double seconds = Math.Min(30, Math.Pow(2, attempt - 1));
return TimeSpan.FromSeconds(seconds + jitter.NextDouble());
}
public void Dispose() => http.Dispose();
}
public sealed class CollectorException : Exception
{
public int StatusCode { get; }
// A permanent failure is a 4xx that is not 429 (rate limit): retrying with
// the same key and payload cannot succeed, so the worker should back off to
// its normal cadence rather than fast-retry.
public bool IsPermanent =>
StatusCode >= 400 && StatusCode < 500
&& StatusCode != (int)HttpStatusCode.TooManyRequests;
public CollectorException(int statusCode, string body)
: base($"collector returned HTTP {statusCode}: {body}")
{
StatusCode = statusCode;
}
}

View File

@@ -0,0 +1,83 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Talks to the endpoint command channel (plugins/endpoints/AGENT-CONTRACT.md
// section 7). The agent opens no inbound port: it long-polls
// GET /api/endpoints/commands/next, which the server holds open until a command
// is queued (HTTP 200) or the hold window elapses (HTTP 204). After running the
// claimed command locally the agent posts the outcome to
// POST /api/endpoints/commands/{id}/result. Both share the endpoints collector
// key via X-API-Key, so the command channel rides the same kill switch as
// telemetry and adds no second secret.
public sealed class CommandClient : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
DefaultIgnoreCondition = System.Text.Json.Serialization
.JsonIgnoreCondition.WhenWritingNull,
};
private readonly HttpClient http;
private readonly ILogger<CommandClient> logger;
public CommandClient(AgentConfig config, ILogger<CommandClient> logger)
{
this.logger = logger;
http = new HttpClient
{
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
// Longer than the collector client: the claim request is held open
// server-side (~25s), so the read timeout must clear that plus slack.
Timeout = TimeSpan.FromSeconds(60),
};
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
}
// Claim the next pending command for this hostname, or null when the server
// returned 204 (nothing queued within the hold window). A 4xx other than 429
// is surfaced so the worker can back off instead of hot-looping a bad key or
// an unknown host.
public async Task<CommandClaim?> ClaimNextAsync(
string hostname, CancellationToken token)
{
var url = "api/endpoints/commands/next?hostname="
+ Uri.EscapeDataString(hostname);
using var response = await http.GetAsync(url, token);
if (response.StatusCode == HttpStatusCode.NoContent)
return null;
var body = await response.Content.ReadAsStringAsync(token);
if (!response.IsSuccessStatusCode)
throw new CollectorException((int)response.StatusCode, body);
var envelope = JsonSerializer.Deserialize<CommandClaimEnvelope>(
body, JsonOptions);
return envelope?.Data;
}
// Report a claimed command's outcome. Best-effort: a failure to post the
// result is logged and swallowed so a transient network blip does not crash
// the worker; the command simply stays 'running' server-side until a later
// operator retry supersedes it.
public async Task ReportResultAsync(
int commandId, CommandResult result, CancellationToken token)
{
var url = $"api/endpoints/commands/{commandId}/result";
using var response = await http.PostAsJsonAsync(
url, result, JsonOptions, token);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(token);
logger.LogWarning(
"Reporting result for command {CommandId} returned HTTP {Status}: {Body}",
commandId, (int)response.StatusCode, body);
}
}
public void Dispose() => http.Dispose();
}

View File

@@ -0,0 +1,581 @@
using System.Diagnostics;
using System.ServiceProcess;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Runs an allowlisted endpoint command locally and returns the result to post
// back. The allowlist here mirrors the server's (plugins/endpoints/commands.py):
// the wire carries an action NAME plus, for generic actions, a TARGET, and this
// is the only place that maps a name to concrete behavior. An unknown name
// fails closed - it is never treated as a command string to execute.
//
// Two families:
// Generic primitives (data-driven; NO agent rebuild for a new app/policy):
// service_start / service_stop / service_status - act on claim.Target, a
// Windows service name.
// process_stop / process_status - act on claim.Target, a process image name
// (for example 'DncMain.exe'). process_start also acts on the image name
// but launches ONLY through a per-image allowlist of known-good paths
// (AgentConfig.LaunchPaths, shipped defaults overridable per site); an
// unmapped target fails closed so the wire can never launch an arbitrary
// binary. process_start launches into the ACTIVE CONSOLE USER'S session
// (not session 0) at the user's medium integrity so the app is visible
// on their desktop; CyberArk/EPM brokers any elevation afterward. Stop
// and status stay as SYSTEM.
// Fixed MTConnect actions (target pinned in the agent, never on the wire):
// mtconnect_start / mtconnect_stop / mtconnect_status.
// MTConnect is a Windows service ("UDC MTConnect Agent"), so all three run
// as SYSTEM (service control needs the admin/SYSTEM token). Start runs
// install.bat (Agent.exe install) to install or repair the service, then
// ensures it is running; stop stops the service; status queries it. NOTE:
// run.bat (agent.exe run agent.cfg) is a foreground TESTING launcher and is
// deliberately NOT used - do not route MTConnect through the generic
// process_start user-session path or the service control breaks.
//
// Every action probes and reports the resulting run state (MtConnectRunning for
// MTConnect; the generic probes report via Message/Detail) so the WebUI toggle
// reflects reality rather than the action's intent.
public sealed class CommandExecutor
{
// The UDC MTConnect agent is a Windows service. Its name is fixed here (not
// sent over the wire) so a compromised WebUI cannot redirect service control.
// A later per-site override belongs in AgentConfig, not the command payload.
private const string MtConnectServiceName = "UDC MTConnect Agent";
// The MTConnect install/repair seam: Agent.exe install. run.bat (agent.exe
// run agent.cfg) is a foreground TESTING launcher and is deliberately NOT
// used here. install.bat registers or repairs the service; the start handler
// runs it, then ensures the service is running.
private const string MtConnectInstallBat =
@"C:\ProgramData\UDC\MTConnect_UDC\Agent\install.bat";
private readonly ILogger<CommandExecutor> logger;
private readonly TimeSpan startTimeout;
// Resolved per-image launchable paths for process_start (shipped defaults
// overlaid with any appsettings.json overrides). A target absent from this
// map cannot be started - process_start fails closed.
private readonly IReadOnlyDictionary<string, string> launchPaths;
public CommandExecutor(ILogger<CommandExecutor> logger,
AgentConfig config, TimeSpan? startTimeout = null)
{
this.logger = logger;
this.startTimeout = startTimeout ?? TimeSpan.FromSeconds(30);
this.launchPaths = (config ?? new AgentConfig()).ResolveLaunchPaths();
}
public async Task<CommandResult> ExecuteAsync(
CommandClaim claim, CancellationToken token)
{
var action = claim.Action;
var target = (claim.Target ?? "").Trim();
switch (action)
{
// Fixed MTConnect actions: target is pinned in the agent.
case "mtconnect_start":
return await MtConnectStartAsync(token);
case "mtconnect_stop":
return MtConnectStop();
case "mtconnect_status":
return MtConnectStatus();
// Generic service primitives: act on the wire target.
case "service_start":
return RequireTarget(action, target)
?? ServiceControl(target, ServiceControllerStatus.Running);
case "service_stop":
return RequireTarget(action, target)
?? ServiceControl(target, ServiceControllerStatus.Stopped);
case "service_status":
return RequireTarget(action, target)
?? ServiceStatus(target);
// Generic process primitives: act on the wire target image name.
case "process_stop":
return RequireTarget(action, target)
?? ProcessStop(target);
case "process_status":
return RequireTarget(action, target)
?? ProcessStatus(target);
case "process_start":
return RequireTarget(action, target)
?? await ProcessStart(target, token);
// Fixed destructive action: reboot this endpoint immediately. Runs
// as the SYSTEM agent, so shutdown does not need a user session.
case "system_restart":
return SystemRestart();
// Force an immediate off-cadence telemetry report. The handler is a
// no-op success: CommandWorker fires collectSignal.Request() after
// every command, which wakes the telemetry loop to collect now, so
// the WebUI reflects live state instead of waiting for cadence.
case "collect_now":
logger.LogInformation("collect_now requested: forcing off-cadence telemetry");
return new CommandResult
{
Succeeded = true,
Message = "Fresh telemetry collection requested",
};
default:
logger.LogWarning("Rejected unknown command action {Action}", action);
return new CommandResult
{
Succeeded = false,
Message = $"Unknown or disallowed action: {action}",
};
}
}
// Guard: a generic action with no target has nothing to act on. Returns a
// failed result to short-circuit, or null when the target is present.
private CommandResult? RequireTarget(string action, string target)
{
if (!string.IsNullOrWhiteSpace(target))
return null;
logger.LogWarning("Action {Action} arrived with no target", action);
return new CommandResult
{
Succeeded = false,
Message = $"Action {action} requires a target",
};
}
// ---- Fixed destructive actions ----
// Reboot the machine now. shutdown.exe with /t 0 gives no grace timer; the
// WebUI confirmation gate is the safety, not a countdown. As SYSTEM the
// agent already holds the privilege, so no session or elevation dance.
private CommandResult SystemRestart()
{
try
{
var restart = new ProcessStartInfo
{
FileName = "shutdown.exe",
Arguments = "/r /t 0",
UseShellExecute = false,
CreateNoWindow = true,
};
using var process = Process.Start(restart);
logger.LogWarning("system_restart issued: rebooting now");
return new CommandResult
{
Succeeded = true,
Message = "Restart initiated",
};
}
catch (Exception error)
{
logger.LogError(error, "system_restart failed");
return new CommandResult
{
Succeeded = false,
Message = $"Failed to initiate restart: {error.Message}",
};
}
}
// ---- Generic service primitives ----
private CommandResult ServiceControl(
string serviceName, ServiceControllerStatus desired)
{
try
{
using var controller = new ServiceController(serviceName);
if (controller.Status == desired)
{
return new CommandResult
{
Succeeded = true,
Message = $"Service {serviceName} already {desired}",
Detail = ServiceDetail(controller),
};
}
if (desired == ServiceControllerStatus.Running)
controller.Start();
else
controller.Stop();
controller.WaitForStatus(desired, startTimeout);
controller.Refresh();
var reached = controller.Status == desired;
return new CommandResult
{
Succeeded = reached,
Message = reached
? $"Service {serviceName} {desired}"
: $"Service {serviceName} did not reach {desired} within timeout",
Detail = ServiceDetail(controller),
};
}
catch (Exception error)
{
logger.LogWarning(error, "Failed to control service {Service}", serviceName);
return new CommandResult
{
Succeeded = false,
Message = $"Failed to control service {serviceName}: {error.Message}",
};
}
}
private CommandResult ServiceStatus(string serviceName)
{
try
{
using var controller = new ServiceController(serviceName);
return new CommandResult
{
Succeeded = true,
Message = $"Service {serviceName} is {controller.Status}",
Detail = ServiceDetail(controller),
};
}
catch (Exception error)
{
logger.LogWarning(error, "Failed to read service {Service}", serviceName);
return new CommandResult
{
Succeeded = false,
Message = $"Failed to read service {serviceName}: {error.Message}",
};
}
}
private static Dictionary<string, object> ServiceDetail(ServiceController controller)
{
controller.Refresh();
return new Dictionary<string, object>
{
["kind"] = "service",
["name"] = controller.ServiceName,
["status"] = controller.Status.ToString(),
["running"] = controller.Status == ServiceControllerStatus.Running,
};
}
// ---- Generic process primitives ----
private CommandResult ProcessStop(string image)
{
var processes = FindProcessesByImage(image);
if (processes.Count == 0)
{
return new CommandResult
{
Succeeded = true,
Message = $"Process {image} already stopped",
Detail = ProcessDetail(image, false),
};
}
var errors = new List<string>();
foreach (var process in processes)
{
try
{
process.Kill(entireProcessTree: true);
process.WaitForExit(10000);
}
catch (Exception error)
{
errors.Add(error.Message);
logger.LogWarning(error, "Failed to stop process {Pid}", process.Id);
}
finally
{
process.Dispose();
}
}
var running = IsProcessRunning(image);
return new CommandResult
{
Succeeded = !running,
Message = running
? $"Process {image} still running after stop: {string.Join("; ", errors)}"
: $"Process {image} stopped",
Detail = ProcessDetail(image, running),
};
}
private CommandResult ProcessStatus(string image)
{
var running = IsProcessRunning(image);
return new CommandResult
{
Succeeded = true,
Message = running ? $"Process {image} running" : $"Process {image} stopped",
Detail = ProcessDetail(image, running),
};
}
private static Dictionary<string, object> ProcessDetail(string image, bool running)
=> new()
{
["kind"] = "process",
["name"] = image,
["running"] = running,
};
// Start a process by image name, but ONLY if it resolves to a known-good
// launchable path (shipped defaults + appsettings overrides). The wire never
// carries a path, so a compromised WebUI cannot launch an arbitrary binary:
// an unmapped target fails closed. A mapped .bat is launched from its own
// directory; a mapped .exe is launched
// directly. Either way we do not wait for the launcher to exit - we poll for
// the image to appear and report the resulting run state.
private async Task<CommandResult> ProcessStart(string image, CancellationToken token)
{
if (IsProcessRunning(image))
{
return new CommandResult
{
Succeeded = true,
Message = $"Process {image} already running",
Detail = ProcessDetail(image, true),
};
}
var bare = image;
if (bare.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
bare = bare[..^4];
if (!launchPaths.TryGetValue(bare, out var launchPath)
|| string.IsNullOrWhiteSpace(launchPath))
{
logger.LogWarning(
"Rejected process_start for {Image}: no launchable path configured",
image);
return new CommandResult
{
Succeeded = false,
Message = $"process_start is not permitted for {image}: no launch path configured",
Detail = ProcessDetail(image, false),
};
}
if (!File.Exists(launchPath))
{
logger.LogWarning(
"process_start for {Image}: configured launch path does not exist: {Path}",
image, launchPath);
return new CommandResult
{
Succeeded = false,
Message = $"Launch path not found for {image}: {launchPath}",
Detail = ProcessDetail(image, false),
};
}
try
{
// Launch into the ACTIVE CONSOLE USER'S session, not session 0. The
// agent is a SYSTEM service; a plain Process.Start here would put the
// GUI app on the hidden session-0 desktop where the operator never
// sees it. The app starts at the user's medium integrity and
// CyberArk/EPM brokers any elevation afterward.
var workingDirectory = Path.GetDirectoryName(launchPath) ?? "";
var outcome = InteractiveProcessLauncher.LaunchInActiveSession(
launchPath, workingDirectory);
if (!outcome.Launched)
{
logger.LogWarning(
"process_start could not launch {Image} in the user session: {Error}",
image, outcome.Error);
return new CommandResult
{
Succeeded = false,
Message = $"Failed to start {image}: {outcome.Error}",
Detail = ProcessDetail(image, false),
};
}
// The launcher (an .exe or a possibly long-lived run.bat) may not be
// the watched image itself, so poll for the target image to appear
// rather than waiting on the launcher to exit.
var deadline = DateTime.UtcNow + startTimeout;
while (DateTime.UtcNow < deadline)
{
if (IsProcessRunning(image))
return new CommandResult
{
Succeeded = true,
Message = $"Process {image} started",
Detail = ProcessDetail(image, true),
};
await Task.Delay(1000, token);
}
return new CommandResult
{
Succeeded = false,
Message = $"Process {image} did not report running within the timeout",
Detail = ProcessDetail(image, IsProcessRunning(image)),
};
}
catch (Exception ex)
{
logger.LogError(ex, "process_start failed for {Image} via {Path}",
image, launchPath);
return new CommandResult
{
Succeeded = false,
Message = $"Failed to start {image}: {ex.Message}",
Detail = ProcessDetail(image, false),
};
}
}
private bool IsProcessRunning(string image)
{
var processes = FindProcessesByImage(image);
try
{
return processes.Count > 0;
}
finally
{
foreach (var process in processes)
process.Dispose();
}
}
// Match a target image name against running processes. Accepts 'DncMain.exe'
// or 'DncMain' - Process.GetProcessesByName wants the bare name, so strip a
// trailing .exe before matching.
private static List<Process> FindProcessesByImage(string image)
{
var bare = image;
if (bare.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
bare = bare[..^4];
var found = new List<Process>();
try
{
found.AddRange(Process.GetProcessesByName(bare));
}
catch
{
// Access-denied on a foreign session's process: not our agent's to
// see, so treat it as absent rather than failing the probe.
}
return found;
}
// ---- Fixed MTConnect actions ----
private async Task<CommandResult> MtConnectStartAsync(CancellationToken token)
{
if (MtConnectServiceRunning())
{
return new CommandResult
{
Succeeded = true,
Message = "MTConnect already running",
MtConnectRunning = true,
};
}
// install.bat (Agent.exe install) installs or repairs the service. Run it
// as SYSTEM and wait for it to exit - unlike the long-lived run.bat, the
// installer is a quick one-shot. Missing installer is not fatal on its
// own: the service may already be registered, so fall through to start.
if (File.Exists(MtConnectInstallBat))
{
try
{
var install = new ProcessStartInfo
{
FileName = MtConnectInstallBat,
WorkingDirectory = Path.GetDirectoryName(MtConnectInstallBat) ?? "",
UseShellExecute = false,
CreateNoWindow = true,
};
using var installer = Process.Start(install);
if (installer is not null)
{
try
{
await installer.WaitForExitAsync(token);
}
catch (OperationCanceledException)
{
// Shutting down; leave the service in whatever state it
// reached and report the current status below.
}
}
}
catch (Exception error)
{
logger.LogWarning(error, "MTConnect install.bat failed");
}
}
else
{
logger.LogWarning(
"MTConnect installer not found: {Installer}; attempting service start anyway",
MtConnectInstallBat);
}
// install.bat may or may not leave the service started; ensure it is
// running via the same service-control path the generic primitives use.
var result = ServiceControl(MtConnectServiceName, ServiceControllerStatus.Running);
return new CommandResult
{
Succeeded = result.Succeeded,
Message = result.Succeeded ? "MTConnect started" : result.Message,
MtConnectRunning = MtConnectServiceRunning(),
Detail = result.Detail,
};
}
private CommandResult MtConnectStop()
{
var result = ServiceControl(MtConnectServiceName, ServiceControllerStatus.Stopped);
var running = MtConnectServiceRunning();
return new CommandResult
{
Succeeded = result.Succeeded,
Message = running ? result.Message : "MTConnect stopped",
MtConnectRunning = running,
Detail = result.Detail,
};
}
private CommandResult MtConnectStatus()
{
var result = ServiceStatus(MtConnectServiceName);
var running = MtConnectServiceRunning();
return new CommandResult
{
Succeeded = result.Succeeded,
Message = running ? "MTConnect running" : "MTConnect stopped",
MtConnectRunning = running,
Detail = result.Detail,
};
}
// Probe the MTConnect Windows service state. A missing/unqueryable service
// (not installed, access denied on a foreign session) reports not running
// rather than throwing, so the WebUI toggle degrades to "stopped".
private bool MtConnectServiceRunning()
{
try
{
using var controller = new ServiceController(MtConnectServiceName);
return controller.Status == ServiceControllerStatus.Running;
}
catch
{
return false;
}
}
}

View File

@@ -0,0 +1,143 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// The command loop as a hosted BackgroundService, separate from TelemetryWorker
// so a slow or held-open command poll never delays a telemetry report and vice
// versa. It long-polls the server for an allowlisted command
// (plugins/endpoints/AGENT-CONTRACT.md section 7), runs it through
// CommandExecutor, and posts the result back. The server holds each poll open
// briefly, so this loop is effectively "wait for work, do it, immediately wait
// again" rather than a fixed-cadence poll.
//
// Like TelemetryWorker, one bad iteration never kills the loop: a permanent
// error (bad key, unknown host) backs off to a slow retry so it does not hammer
// the server, and any other failure is logged and retried after a short pause.
public sealed class CommandWorker : BackgroundService
{
// Pause after a transient failure before re-polling, so a flapping network
// or a briefly-down server does not spin this loop.
private static readonly TimeSpan TransientRetryDelay = TimeSpan.FromSeconds(10);
// Longer back-off after a permanent error (bad key, host removed): the
// operator has to fix something, so re-polling fast changes nothing.
private static readonly TimeSpan PermanentRetryDelay = TimeSpan.FromSeconds(300);
private readonly CommandClient client;
private readonly CommandExecutor executor;
private readonly ILogger<CommandWorker> logger;
private readonly CollectSignal? collectSignal;
private readonly string hostname;
public CommandWorker(CommandClient client, CommandExecutor executor,
ILogger<CommandWorker> logger, CollectSignal? collectSignal = null)
{
this.client = client;
this.executor = executor;
this.logger = logger;
this.collectSignal = collectSignal;
hostname = Environment.MachineName;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Command channel started for host {Host}", hostname);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var claim = await client.ClaimNextAsync(hostname, stoppingToken);
if (claim is not null)
await RunClaimAsync(claim, stoppingToken);
// On a 204 (no work) the server already consumed the hold
// window, so re-poll immediately.
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (CollectorException error) when (error.IsPermanent)
{
logger.LogError(error,
"Permanent command-channel error; backing off");
await SafeDelay(PermanentRetryDelay, stoppingToken);
}
catch (Exception error) when (IsIdlePollClose(error))
{
// The server (or a proxy) closed the held-open long poll without
// a command. That is the normal end of a hold window, not a
// failure: log quietly at debug and re-poll immediately.
logger.LogDebug(error, "Command poll connection closed; re-polling");
}
catch (Exception error)
{
logger.LogWarning(error, "Command poll failed; retrying shortly");
await SafeDelay(TransientRetryDelay, stoppingToken);
}
}
logger.LogInformation("Command channel stopped");
}
private async Task RunClaimAsync(CommandClaim claim, CancellationToken token)
{
logger.LogInformation("Running command {Id} action={Action}",
claim.EndpointCommandId, claim.Action);
CommandResult result;
try
{
result = await executor.ExecuteAsync(claim, token);
}
catch (Exception error)
{
logger.LogError(error, "Command {Id} action={Action} threw",
claim.EndpointCommandId, claim.Action);
result = new CommandResult
{
Succeeded = false,
Message = $"Command failed: {error.Message}",
};
}
await client.ReportResultAsync(claim.EndpointCommandId, result, token);
logger.LogInformation("Command {Id} finished succeeded={Succeeded}",
claim.EndpointCommandId, result.Succeeded);
// A command changed something on the endpoint (e.g. MTConnect start/stop);
// wake the telemetry loop to collect now so the WebUI card reflects the
// new state within one report cycle instead of after the full cadence.
collectSignal?.Request();
}
// A long poll that the server or an intermediary closes without sending a
// command surfaces as an HttpRequestException wrapping an HttpIOException
// (ResponseEnded), or a bare IOException. That is the expected end of a hold
// window, so it should not be treated as a transient failure.
private static bool IsIdlePollClose(Exception error)
{
for (Exception? current = error; current is not null; current = current.InnerException)
{
if (current is System.Net.Http.HttpIOException httpIo
&& httpIo.HttpRequestError == System.Net.Http.HttpRequestError.ResponseEnded)
return true;
if (current is System.IO.IOException)
return true;
}
return false;
}
private static async Task SafeDelay(TimeSpan delay, CancellationToken token)
{
try
{
await Task.Delay(delay, token);
}
catch (OperationCanceledException)
{
// Shutting down: let the loop condition end it.
}
}
}

View File

@@ -0,0 +1,92 @@
using System.Text.Json.Serialization;
namespace ShopDbEndpointAgent.Core;
// Wire keys MUST stay lowercase/no-underscore to match the ShopDB collector
// schema (plugins/endpoints/AGENT-CONTRACT.md). PascalCase C# properties are
// mapped back with JsonPropertyName so the serializer does not rename them.
public sealed class EndpointReport
{
[JsonPropertyName("hostname")] public string Hostname { get; set; } = "";
[JsonPropertyName("adopt")] public bool? Adopt { get; set; }
[JsonPropertyName("loggedinuser")] public string? LoggedInUser { get; set; }
[JsonPropertyName("serialnumber")] public string? SerialNumber { get; set; }
[JsonPropertyName("pctype")] public string? PcType { get; set; }
[JsonPropertyName("vendorname")] public string? VendorName { get; set; }
[JsonPropertyName("modelnumber")] public string? ModelNumber { get; set; }
[JsonPropertyName("osname")] public string? OsName { get; set; }
[JsonPropertyName("ipaddress")] public string? IpAddress { get; set; }
[JsonPropertyName("machinenumber")] public string? MachineNumber { get; set; }
[JsonPropertyName("agentversion")] public string? AgentVersion { get; set; }
[JsonPropertyName("lastboottime")] public DateTimeOffset? LastBootTime { get; set; }
[JsonPropertyName("uptimeseconds")] public long? UptimeSeconds { get; set; }
[JsonPropertyName("idleseconds")] public long? IdleSeconds { get; set; }
[JsonPropertyName("cpupercent")] public double? CpuPercent { get; set; }
[JsonPropertyName("memorypercent")] public double? MemoryPercent { get; set; }
[JsonPropertyName("services")] public object[]? Services { get; set; }
[JsonPropertyName("ports")] public object[]? Ports { get; set; }
[JsonPropertyName("interfaces")] public object[]? Interfaces { get; set; }
[JsonPropertyName("scheduledtasks")] public object[]? ScheduledTasks { get; set; }
[JsonPropertyName("events")] public EndpointEventRecord[]? Events { get; set; }
}
public sealed class EndpointEventRecord
{
[JsonPropertyName("eventid")] public int EventId { get; set; }
[JsonPropertyName("eventchannel")] public string? EventChannel { get; set; }
[JsonPropertyName("eventsource")] public string? EventSource { get; set; }
[JsonPropertyName("eventtime")] public DateTimeOffset? EventTime { get; set; }
[JsonPropertyName("eventmessage")] public string? EventMessage { get; set; }
[JsonPropertyName("fields")] public Dictionary<string, object>? Fields { get; set; }
}
public sealed class CollectorResponse
{
[JsonPropertyName("status")] public string Status { get; set; } = "";
[JsonPropertyName("action")] public string? Action { get; set; }
[JsonPropertyName("assetid")] public int? AssetId { get; set; }
[JsonPropertyName("identityvalue")] public string? IdentityValue { get; set; }
[JsonPropertyName("warnings")] public string[]? Warnings { get; set; }
}
// The collector wraps the plugin payload in the ShopDB envelope
// { status, data: { action, assetid, identityvalue, warnings }, meta }.
public sealed class CollectorEnvelope
{
[JsonPropertyName("status")] public string Status { get; set; } = "";
[JsonPropertyName("data")] public CollectorResponse? Data { get; set; }
}
// A command the server claimed for this endpoint on the long-poll. The
// allowlisted action name crosses the wire (see AGENT-CONTRACT.md section 7);
// generic actions (service_* / process_*) also carry the subject to act on -
// Target is a Windows service name or a process image (for example
// 'DncMain.exe') and TargetKind is 'service' or 'process'. Fixed-target actions
// (mtconnect_*) leave both null. Carrying the target on the wire is what lets a
// site build the agent once and drive every future policy/app from server data.
public sealed class CommandClaim
{
[JsonPropertyName("endpointcommandid")] public int EndpointCommandId { get; set; }
[JsonPropertyName("action")] public string Action { get; set; } = "";
[JsonPropertyName("target")] public string? Target { get; set; }
[JsonPropertyName("targetkind")] public string? TargetKind { get; set; }
}
// Envelope for the claim long-poll: { status, data: { endpointcommandid, action } }.
public sealed class CommandClaimEnvelope
{
[JsonPropertyName("status")] public string Status { get; set; } = "";
[JsonPropertyName("data")] public CommandClaim? Data { get; set; }
}
// The outcome the agent posts back after running a claimed command. When the
// action probed MTConnect, MtConnectRunning carries the observed state so the
// server can cache it for the WebUI toggle.
public sealed class CommandResult
{
[JsonPropertyName("succeeded")] public bool Succeeded { get; set; }
[JsonPropertyName("message")] public string? Message { get; set; }
[JsonPropertyName("mtconnectrunning")] public bool? MtConnectRunning { get; set; }
[JsonPropertyName("detail")] public Dictionary<string, object>? Detail { get; set; }
}

View File

@@ -0,0 +1,44 @@
namespace ShopDbEndpointAgent.Core;
// The curated Windows Event catalog, LOCKED to the server list in
// plugins/endpoints/plugin.py (CURATED_EVENT_IDS) and documented in
// plugins/endpoints/DESIGN-DECISIONS-agent.md section 4. The server stores ONLY
// these IDs; anything else in a report's `events` is ignored (not an error).
// Sending only these keeps the payload small and mirrors the catalog intent.
//
// Channels matter: an ID is read from its own log, so the collector reads
// Security, System, and Application - not System alone. IDs are namespaced by
// channel here because the same numeric ID can mean different things across
// logs; we only harvest the (channel, id) pairs the server curates.
public static class CuratedEventCatalog
{
public sealed record Entry(int EventId, string Channel, string Meaning);
public static readonly IReadOnlyList<Entry> Entries = new List<Entry>
{
new(4624, "Security", "Successful logon"),
new(4625, "Security", "Failed logon"),
new(4634, "Security", "Logoff"),
new(4672, "Security", "Special privileges assigned to new logon"),
new(4697, "Security", "Service installed"),
new(4698, "Security", "Scheduled task created"),
new(4618, "Security", "Monitored security event pattern"),
new(41, "System", "Kernel-Power, unexpected shutdown"),
new(55, "System", "NTFS file system corruption"),
new(7011, "System", "Service Control Manager timeout"),
new(10016, "System", "DCOM permission error"),
new(1001, "Application", "Application error / bugcheck report"),
};
// The distinct channels the collector must read.
public static readonly IReadOnlyList<string> Channels =
Entries.Select(entry => entry.Channel).Distinct().ToList();
// The set of curated IDs for a given channel, for a fast membership check
// while walking that channel's log newest-first.
public static IReadOnlySet<int> IdsForChannel(string channel)
=> Entries.Where(entry =>
string.Equals(entry.Channel, channel, StringComparison.OrdinalIgnoreCase))
.Select(entry => entry.EventId)
.ToHashSet();
}

View File

@@ -0,0 +1,489 @@
using System.Diagnostics;
using System.Management;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
namespace ShopDbEndpointAgent.Core;
// Pure-C# endpoint collector implementing ICollector. Mirrors the ShopDB
// PowerShell asset-collector data set (minus the CSV emit): hostname, logged-in
// user, serial number, pc type, boot time, monitored processes, plus a curated
// Windows Event Log tail across the Security/System/Application channels the
// server curates (CuratedEventCatalog). WMI/registry/event access can throw or
// return null on a locked-down box, so every probe is defensive and never
// aborts the report.
public sealed class EndpointCollector : ICollector
{
// Max curated events harvested per channel per report, newest-first, to
// keep the payload bounded on a chatty Security log.
private const int MaxEventsPerChannel = 25;
private readonly ILogger<EndpointCollector> logger;
private readonly AgentConfig config;
private readonly IAgentUtilityRunner? utilities;
public EndpointCollector(ILogger<EndpointCollector> logger, AgentConfig config,
IAgentUtilityRunner? utilities = null)
{
this.logger = logger;
this.config = config;
this.utilities = utilities;
}
public EndpointReport Collect(string agentVersion, bool adopt)
{
var report = new EndpointReport
{
Hostname = Environment.MachineName,
AgentVersion = agentVersion,
Adopt = adopt ? true : null,
LoggedInUser = GetLoggedInUser(),
SerialNumber = GetSerialNumber(),
PcType = GetPcType(),
VendorName = GetVendor(),
ModelNumber = GetModelNumber(),
OsName = GetOsName(),
IpAddress = GetPrimaryIpAddress(),
MachineNumber = GetMachineNumber(),
LastBootTime = GetLastBootTime(),
Services = GetMonitoredTargets(),
Interfaces = GetInterfaces(),
Events = GetCuratedEvents(),
};
if (report.LastBootTime is DateTimeOffset boot)
report.UptimeSeconds = (long)(DateTimeOffset.UtcNow - boot).TotalSeconds;
return report;
}
// Combined watch list: interactive processes (by image name) and Windows
// services (by service key name). Both land in report.Services with a shared
// {name, app, kind, state, running, count} shape so the server/UI renders
// one list of health badges.
private object[] GetMonitoredTargets()
{
var result = new List<object>();
result.AddRange(GetMonitoredProcesses());
result.AddRange(GetMonitoredServices());
return result.ToArray();
}
private IEnumerable<object> GetMonitoredProcesses()
{
var targets = config.Monitors?.Processes ?? Array.Empty<MonitorTarget>();
if (targets.Length == 0)
return Array.Empty<object>();
var running = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
try
{
foreach (var proc in Process.GetProcesses())
{
string image;
try { image = proc.ProcessName + ".exe"; }
catch { continue; }
finally { proc.Dispose(); }
if (Array.FindIndex(targets, t =>
string.Equals(t.Name, image, StringComparison.OrdinalIgnoreCase)) < 0)
continue;
running.TryGetValue(image, out int count);
running[image] = count + 1;
}
}
catch (Exception error)
{
logger.LogDebug(error, "Process enumeration blocked");
}
var result = new List<object>(targets.Length);
foreach (var target in targets)
{
bool found = running.TryGetValue(target.Name, out int count);
result.Add(new
{
name = target.Name,
app = string.IsNullOrWhiteSpace(target.App) ? target.Name : target.App,
kind = "process",
// state string so the UI badge can color it; missing = unknown.
state = found ? "running" : "stopped",
running = found,
count = found ? count : 0,
});
}
return result;
}
// Windows services watched by service key name. Reports the reported state
// (running/stopped/etc.) from Win32_Service so the server can flag a
// collector service (MTConnect and the like) that has stopped or gone
// missing. Never aborts: a locked-down WMI hive leaves entries "unknown".
private IEnumerable<object> GetMonitoredServices()
{
var targets = config.Monitors?.Services ?? Array.Empty<MonitorTarget>();
if (targets.Length == 0)
return Array.Empty<object>();
var states = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
using var searcher = new ManagementObjectSearcher(
"SELECT Name, DisplayName, State FROM Win32_Service");
foreach (var mo in searcher.Get())
{
var state = mo["State"]?.ToString()?.Trim() ?? "";
// Index by both service key name and display name so a config
// that used either one still matches.
var name = mo["Name"]?.ToString();
if (!string.IsNullOrWhiteSpace(name))
states[name.Trim()] = state;
var display = mo["DisplayName"]?.ToString();
if (!string.IsNullOrWhiteSpace(display))
states[display.Trim()] = state;
}
}
catch (Exception error)
{
logger.LogDebug(error, "Service enumeration blocked");
}
var result = new List<object>(targets.Length);
foreach (var target in targets)
{
bool found = states.TryGetValue(target.Name, out var raw);
bool running = string.Equals(raw, "Running", StringComparison.OrdinalIgnoreCase);
result.Add(new
{
name = target.Name,
app = string.IsNullOrWhiteSpace(target.App) ? target.Name : target.App,
kind = "service",
// "unknown" = not installed / WMI unreadable; else lowercased
// Win32_Service state (running, stopped, start pending, ...).
state = found ? (raw ?? "").ToLowerInvariant() : "unknown",
running,
count = running ? 1 : 0,
});
}
return result;
}
private string? GetLoggedInUser()
{
var user = QueryFirst("SELECT UserName FROM Win32_ComputerSystem", "UserName");
return string.IsNullOrWhiteSpace(user)
? $"{Environment.UserDomainName}\\{Environment.UserName}"
: user;
}
private string? GetSerialNumber()
=> QueryFirst("SELECT SerialNumber FROM Win32_BIOS", "SerialNumber");
private string? GetPcType()
{
// Raw WMI model; the ShopDB pctype map normalizes it server-side, so the
// current site's catalog stays the single source of that mapping.
var model = QueryFirst("SELECT Model FROM Win32_ComputerSystem", "Model");
return string.IsNullOrWhiteSpace(model) ? null : model;
}
// Manufacturer for the Asset vendor/model rails (Win32_ComputerSystem
// Manufacturer, for example "Dell Inc.", "HP"). Server upserts Vendor.
private string? GetVendor()
=> QueryFirst("SELECT Manufacturer FROM Win32_ComputerSystem", "Manufacturer");
// Hardware model number. Prefer the human-readable Win32_ComputerSystem
// Model (for example "OptiPlex 7090", "Latitude 5540") - the same value
// pctype classifies on. SystemSKUNumber is an OEM SKU code (for example
// Dell's "0BCC"), not a model name, so it is only a last resort when Model
// is blank.
private string? GetModelNumber()
{
var model = QueryFirst("SELECT Model FROM Win32_ComputerSystem", "Model");
if (!string.IsNullOrWhiteSpace(model))
return model;
return QueryFirst("SELECT SystemSKUNumber FROM Win32_ComputerSystem",
"SystemSKUNumber");
}
// OS caption, for example "Microsoft Windows 10 Enterprise". The server
// matches this against its controlled OperatingSystem vocab (warns if the
// exact string is unknown), so it may need a catalog entry to bind.
private string? GetOsName()
=> QueryFirst("SELECT Caption FROM Win32_OperatingSystem", "Caption");
// Primary IPv4 address of the first enabled, non-loopback adapter with a
// default gateway. Feeds the Communication (IP) row server-side.
private string? GetPrimaryIpAddress()
{
try
{
using var searcher = new ManagementObjectSearcher(
"SELECT IPAddress, DefaultIPGateway, IPEnabled " +
"FROM Win32_NetworkAdapterConfiguration WHERE IPEnabled = True");
string? firstEnabled = null;
foreach (var mo in searcher.Get())
{
var addresses = mo["IPAddress"] as string[];
if (addresses is null)
continue;
var ipv4 = Array.Find(addresses, a =>
!string.IsNullOrWhiteSpace(a) && a.IndexOf(':') < 0);
if (string.IsNullOrWhiteSpace(ipv4))
continue;
firstEnabled ??= ipv4;
// Prefer an adapter that actually has a default gateway (the
// real LAN NIC, not a virtual/VPN interface).
if (mo["DefaultIPGateway"] is string[] gw && gw.Length > 0)
return ipv4;
}
return firstEnabled;
}
catch (Exception error)
{
logger.LogDebug(error, "IP address query failed");
return null;
}
}
// All hardware NICs, not just the primary. DESIGN-DECISIONS-agent.md 9.3:
// upwards of two-thirds of shop-floor PCs carry a SECOND, static, gatewayless
// NIC whose only job is to talk to the machine's onboard IPS. That interface
// is the physical PC-to-machine link and is exactly the one a single
// primary-IP probe throws away, so we report every enabled adapter with its
// alias/ipv4/mac/gateway/subnet and let the server keep the whole picture.
// Stored verbatim as JSON server-side (AGENT-CONTRACT.md), so keys are
// lowercase/no-underscore.
private object[] GetInterfaces()
{
var result = new List<object>();
try
{
using var searcher = new ManagementObjectSearcher(
"SELECT Description, IPAddress, IPSubnet, DefaultIPGateway, " +
"MACAddress, DHCPEnabled FROM Win32_NetworkAdapterConfiguration " +
"WHERE IPEnabled = True");
foreach (var mo in searcher.Get())
{
var mac = mo["MACAddress"] as string;
// No MAC = not a real hardware adapter (some tunnels/virtuals).
if (string.IsNullOrWhiteSpace(mac))
continue;
var addresses = mo["IPAddress"] as string[];
var ipv4 = addresses is null ? null : Array.Find(addresses,
a => !string.IsNullOrWhiteSpace(a) && a.IndexOf(':') < 0);
var subnets = mo["IPSubnet"] as string[];
var subnet = subnets is null ? null : Array.Find(subnets,
s => !string.IsNullOrWhiteSpace(s) && s.IndexOf(':') < 0);
var gateways = mo["DefaultIPGateway"] as string[];
var gateway = gateways is null || gateways.Length == 0
? null : gateways[0];
result.Add(new
{
alias = mo["Description"]?.ToString()?.Trim() ?? "",
ipv4 = ipv4 ?? "",
mac = mac.Trim(),
// A gatewayless NIC is the likely machine-IPS link; the
// gateway NIC is the corporate LAN. Server/UI can tell them
// apart from these two fields.
gateway = gateway ?? "",
subnet = subnet ?? "",
dhcp = mo["DHCPEnabled"] as bool? ?? false,
});
}
}
catch (Exception error)
{
logger.LogDebug(error, "Interface enumeration blocked");
}
return result.ToArray();
}
// The four-digit ShopDB Machine Number, discovered from on-box app config.
// DESIGN-DECISIONS-agent.md 9.4/9.5: when eDNC or UDC is installed its config
// carries the number that binds this PC to a ShopDB machines row. Source
// priority: eDNC live registry first, then UDC settings JSON. "" or "9999"
// means none (AGENT-CONTRACT.md). Never throws out to the caller.
private string? GetMachineNumber()
{
var fromRegistry = GetMachineNumberFromEdncRegistry();
if (IsUsableMachineNumber(fromRegistry))
return fromRegistry!.Trim();
var fromUdc = GetMachineNumberFromUdcSettings();
if (IsUsableMachineNumber(fromUdc))
return fromUdc!.Trim();
return null;
}
private static bool IsUsableMachineNumber(string? value)
=> !string.IsNullOrWhiteSpace(value) && value.Trim() != "9999";
// eDNC writes MachineNo under the GE Aircraft Engines\DNC\General key. Try
// the 64-bit view then the 32-bit (WOW6432Node) view, mirroring
// AgentConfigProvider so a locked-down or 32-bit-installed box still reads.
private string? GetMachineNumberFromEdncRegistry()
{
const string subKey = @"SOFTWARE\GE Aircraft Engines\DNC\General";
RegistryView[] views = { RegistryView.Registry64, RegistryView.Registry32 };
foreach (var view in views)
{
try
{
using var machine = RegistryKey.OpenBaseKey(
RegistryHive.LocalMachine, view);
using var key = machine.OpenSubKey(subKey);
var value = key?.GetValue("MachineNo")?.ToString();
if (IsUsableMachineNumber(value))
return value;
}
catch (Exception error)
{
logger.LogDebug(error,
"eDNC registry read blocked in {View} view", view);
}
}
return null;
}
// UDC stores GeneralSettings.MachineNumber in udc_settings_*.json under
// %ProgramData%\udc. Scan for the newest settings file and read the number.
private string? GetMachineNumberFromUdcSettings()
{
try
{
var root = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"udc");
if (!Directory.Exists(root))
return null;
var files = Directory.EnumerateFiles(
root, "udc_settings*.json", SearchOption.AllDirectories)
.OrderByDescending(File.GetLastWriteTimeUtc);
foreach (var file in files)
{
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(file));
if (doc.RootElement.TryGetProperty("GeneralSettings", out var general)
&& general.TryGetProperty("MachineNumber", out var number))
{
var value = number.ValueKind == JsonValueKind.String
? number.GetString()
: number.ToString();
if (IsUsableMachineNumber(value))
return value;
}
}
catch (Exception error)
{
logger.LogDebug(error, "UDC settings parse failed: {File}", file);
}
}
}
catch (Exception error)
{
logger.LogDebug(error, "UDC settings scan failed");
}
return null;
}
private DateTimeOffset? GetLastBootTime()
{
var raw = QueryFirst(
"SELECT LastBootUpTime FROM Win32_OperatingSystem", "LastBootUpTime");
if (string.IsNullOrWhiteSpace(raw))
return null;
try
{
return ManagementDateTimeConverter.ToDateTime(raw).ToUniversalTime();
}
catch (Exception error)
{
logger.LogDebug(error, "LastBootUpTime parse failed");
return null;
}
}
// Walk each curated channel newest-first, keep only the curated IDs for
// that channel, cap per channel. Reads Security/System/Application - the
// old scaffold read System only and filtered by the wrong IDs.
private EndpointEventRecord[] GetCuratedEvents()
{
var records = new List<EndpointEventRecord>();
foreach (var channel in CuratedEventCatalog.Channels)
{
var wanted = CuratedEventCatalog.IdsForChannel(channel);
try
{
using var log = new EventLog(channel);
int kept = 0;
for (int i = log.Entries.Count - 1;
i >= 0 && kept < MaxEventsPerChannel; i--)
{
EventLogEntry entry;
try { entry = log.Entries[i]; } catch { continue; }
int id = (int)(entry.InstanceId & 0xFFFF);
if (!wanted.Contains(id))
continue;
records.Add(new EndpointEventRecord
{
EventId = id,
EventChannel = channel,
EventSource = entry.Source,
EventTime = new DateTimeOffset(
entry.TimeGenerated.ToUniversalTime(), TimeSpan.Zero),
EventMessage = Truncate(entry.Message, 1000),
});
kept++;
}
}
catch (Exception error)
{
// Channel unreadable (Security needs elevation; a log may be
// off): log and continue with the other channels.
logger.LogDebug(error, "Event channel {Channel} unreadable", channel);
}
}
return records.ToArray();
}
private string? QueryFirst(string wql, string property)
{
try
{
using var searcher = new ManagementObjectSearcher(wql);
foreach (var mo in searcher.Get())
{
var value = mo[property]?.ToString();
if (!string.IsNullOrWhiteSpace(value))
return value.Trim();
}
}
catch (Exception error)
{
logger.LogDebug(error, "WMI query failed: {Query}", wql);
}
return null;
}
private static string Truncate(string? text, int max)
{
if (string.IsNullOrEmpty(text))
return "";
text = text.Trim();
return text.Length <= max ? text : text[..max];
}
}

View File

@@ -0,0 +1,164 @@
using System.Collections.Concurrent;
using System.Text;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// A dependency-free rolling-file ILoggerProvider. The runtime code only ever
// talks to ILogger, so swapping or adding sinks later (a ShopDB HTTP sink, Seq,
// syslog) is a host-builder change with zero worker changes. This provider
// writes one line per entry to a daily file under the configured log directory
// and prunes files older than the retention window.
public sealed class FileLoggerProvider : ILoggerProvider
{
private readonly string directory;
private readonly LogLevel minLevel;
private readonly int retentionDays;
private readonly object gate = new();
private readonly ConcurrentDictionary<string, FileLogger> loggers = new();
private string currentDateStamp = "";
private StreamWriter? writer;
public FileLoggerProvider(string directory, LogLevel minLevel = LogLevel.Information,
int retentionDays = 14)
{
this.directory = directory;
this.minLevel = minLevel;
this.retentionDays = retentionDays;
// Best-effort: creating the log directory can fail on a locked-down host
// or when a non-privileged user targets a SYSTEM-owned path. Never let
// that abort construction; Write() degrades to a no-op if the sink is
// unusable.
try { Directory.CreateDirectory(directory); }
catch (Exception error) when (error is IOException or UnauthorizedAccessException) { }
}
// The default log directory when config leaves LogPath empty:
// %ProgramData%\ShopDB\EndpointAgent\logs
public static string DefaultDirectory()
=> Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"ShopDB", "EndpointAgent", "logs");
public ILogger CreateLogger(string categoryName)
=> loggers.GetOrAdd(categoryName, name => new FileLogger(this, name));
internal bool IsEnabled(LogLevel level) => level >= minLevel && level != LogLevel.None;
internal void Write(string category, LogLevel level, string message, Exception? error)
{
var now = DateTimeOffset.Now;
var line = new StringBuilder()
.Append(now.ToString("yyyy-MM-dd HH:mm:ss.fff zzz"))
.Append(" [").Append(LevelTag(level)).Append("] ")
.Append(ShortCategory(category)).Append(": ")
.Append(message);
if (error is not null)
line.Append(Environment.NewLine).Append(error);
lock (gate)
{
// Logging must never take down the agent. If the log file cannot be
// opened or written (for example a non-privileged user running the
// exe against a logs directory owned by the SYSTEM service, which
// yields UnauthorizedAccessException on open), swallow it and drop
// the line rather than letting it unwind through bootstrap. A failed
// sink is a degraded state, not a fatal one.
try
{
RollIfNeeded(now);
writer?.WriteLine(line.ToString());
}
catch (Exception sinkError) when (sinkError is IOException or UnauthorizedAccessException)
{
writer = null;
}
}
}
private void RollIfNeeded(DateTimeOffset now)
{
var stamp = now.ToString("yyyyMMdd");
if (stamp == currentDateStamp && writer is not null)
return;
writer?.Flush();
writer?.Dispose();
currentDateStamp = stamp;
var path = Path.Combine(directory, $"agent-{stamp}.log");
writer = new StreamWriter(new FileStream(
path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
{
AutoFlush = true,
};
PruneOldFiles();
}
private void PruneOldFiles()
{
try
{
var cutoff = DateTime.Now.AddDays(-retentionDays);
foreach (var file in Directory.EnumerateFiles(directory, "agent-*.log"))
{
if (File.GetLastWriteTime(file) < cutoff)
File.Delete(file);
}
}
catch
{
// Pruning is best-effort; never let housekeeping break logging.
}
}
private static string LevelTag(LogLevel level) => level switch
{
LogLevel.Trace => "TRC",
LogLevel.Debug => "DBG",
LogLevel.Information => "INF",
LogLevel.Warning => "WRN",
LogLevel.Error => "ERR",
LogLevel.Critical => "CRT",
_ => "---",
};
private static string ShortCategory(string category)
{
var dot = category.LastIndexOf('.');
return dot >= 0 && dot < category.Length - 1 ? category[(dot + 1)..] : category;
}
public void Dispose()
{
lock (gate)
{
writer?.Flush();
writer?.Dispose();
writer = null;
}
}
private sealed class FileLogger : ILogger
{
private readonly FileLoggerProvider provider;
private readonly string category;
public FileLogger(FileLoggerProvider provider, string category)
{
this.provider = provider;
this.category = category;
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => provider.IsEnabled(logLevel);
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
Exception? exception, Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel))
return;
provider.Write(category, logLevel, formatter(state, exception), exception);
}
}
}

View File

@@ -0,0 +1,24 @@
namespace ShopDbEndpointAgent.Core;
// The collection surface. The runtime worker depends only on this, so the
// concrete implementation (pure C# today, possibly C# + PowerShell utilities
// later) can change without touching the worker or the HTTP client.
public interface ICollector
{
EndpointReport Collect(string agentVersion, bool adopt);
}
// The documented seam for site-provided PowerShell "agent utilities". The
// agent configuration settings page (server-side, future) lets site staff load
// their own .ps1 scripts as utilities; the runtime discovers them under the
// configured utilities directory and merges their JSON output into the report.
//
// This iteration ships the interface and a discovery contract only - the
// concrete runner is added when the settings page can push utilities down.
public interface IAgentUtilityRunner
{
// Run every discovered utility and return a map of utility name -> parsed
// JSON result, to be attached to the report. Never throws: a failing
// utility is logged and skipped so it cannot break the telemetry run.
IReadOnlyDictionary<string, object> RunAll(CancellationToken token);
}

View File

@@ -0,0 +1,200 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
namespace ShopDbEndpointAgent.Core;
// Launches a program in the ACTIVE CONSOLE USER'S session at that user's
// integrity, from the agent's LocalSystem service (session 0).
//
// Why this exists: the agent runs as a session-0 service. A plain
// Process.Start() there launches GUI apps (DncMain.exe, UDC.exe) onto the hidden
// session-0 desktop, so the logged-in operator never sees a window even though
// the process is running. process_start needs the app to appear on the user's
// desktop, so we obtain the interactive user's token and CreateProcessAsUser.
//
// Elevation is deliberately NOT handled here: the process starts at the user's
// normal (medium) integrity and CyberArk/EPM brokers any elevation afterward.
//
// Only process_start uses this. Stop/status/service actions stay as SYSTEM.
public static class InteractiveProcessLauncher
{
public readonly record struct LaunchOutcome(bool Launched, string? Error);
private const int TokenDuplicate = 0x0002;
private const int TokenQuery = 0x0008;
private const int TokenAssignPrimary = 0x0001;
private const int TokenAdjustPrivileges = 0x0020;
private const int TokenAdjustDefault = 0x0080;
private const int TokenAdjustSessionId = 0x0100;
private const uint CreateUnicodeEnvironment = 0x00000400;
private const uint CreateNoWindow = 0x08000000;
private const uint InvalidSessionId = 0xFFFFFFFF;
// Returns Launched=true when CreateProcessAsUser succeeds. Never throws;
// failures come back as Launched=false with a descriptive Error so the
// caller can surface a clean CommandResult.
public static LaunchOutcome LaunchInActiveSession(
string applicationPath, string? workingDirectory)
{
var sessionId = WTSGetActiveConsoleSessionId();
if (sessionId == InvalidSessionId)
return new LaunchOutcome(false,
"No active console session (no interactive user is signed in).");
if (!WTSQueryUserToken(sessionId, out var userToken))
return new LaunchOutcome(false,
"Could not obtain the interactive user token (WTSQueryUserToken failed): "
+ new Win32Exception(Marshal.GetLastWin32Error()).Message);
var duplicatedToken = IntPtr.Zero;
var environmentBlock = IntPtr.Zero;
try
{
var desiredAccess = TokenAssignPrimary | TokenDuplicate | TokenQuery
| TokenAdjustPrivileges | TokenAdjustDefault | TokenAdjustSessionId;
if (!DuplicateTokenEx(userToken, (uint)desiredAccess, IntPtr.Zero,
SecurityImpersonationLevel.SecurityImpersonation,
TokenType.TokenPrimary, out duplicatedToken))
{
return new LaunchOutcome(false,
"Could not duplicate the user token (DuplicateTokenEx failed): "
+ new Win32Exception(Marshal.GetLastWin32Error()).Message);
}
// A per-user environment block so the launched app sees the user's
// profile variables rather than SYSTEM's.
if (!CreateEnvironmentBlock(out environmentBlock, duplicatedToken, false))
environmentBlock = IntPtr.Zero;
var startupInfo = new STARTUPINFO();
startupInfo.cb = Marshal.SizeOf(startupInfo);
// Target the interactive desktop so the window is visible.
startupInfo.lpDesktop = @"winsta0\default";
var creationFlags = CreateUnicodeEnvironment | CreateNoWindow;
var launched = CreateProcessAsUser(
duplicatedToken,
applicationPath,
null,
IntPtr.Zero,
IntPtr.Zero,
false,
creationFlags,
environmentBlock,
workingDirectory,
ref startupInfo,
out var processInformation);
if (!launched)
{
return new LaunchOutcome(false,
"CreateProcessAsUser failed: "
+ new Win32Exception(Marshal.GetLastWin32Error()).Message);
}
if (processInformation.hProcess != IntPtr.Zero)
CloseHandle(processInformation.hProcess);
if (processInformation.hThread != IntPtr.Zero)
CloseHandle(processInformation.hThread);
return new LaunchOutcome(true, null);
}
finally
{
if (environmentBlock != IntPtr.Zero)
DestroyEnvironmentBlock(environmentBlock);
if (duplicatedToken != IntPtr.Zero)
CloseHandle(duplicatedToken);
if (userToken != IntPtr.Zero)
CloseHandle(userToken);
}
}
private enum SecurityImpersonationLevel
{
SecurityAnonymous,
SecurityIdentification,
SecurityImpersonation,
SecurityDelegation,
}
private enum TokenType
{
TokenPrimary = 1,
TokenImpersonation,
}
[StructLayout(LayoutKind.Sequential)]
private struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public uint dwProcessId;
public uint dwThreadId;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct STARTUPINFO
{
public int cb;
public string? lpReserved;
public string? lpDesktop;
public string? lpTitle;
public uint dwX;
public uint dwY;
public uint dwXSize;
public uint dwYSize;
public uint dwXCountChars;
public uint dwYCountChars;
public uint dwFillAttribute;
public uint dwFlags;
public ushort wShowWindow;
public ushort cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint WTSGetActiveConsoleSessionId();
[DllImport("wtsapi32.dll", SetLastError = true)]
private static extern bool WTSQueryUserToken(uint sessionId, out IntPtr token);
[DllImport("advapi32.dll", SetLastError = true)]
private static extern bool DuplicateTokenEx(
IntPtr existingToken,
uint desiredAccess,
IntPtr tokenAttributes,
SecurityImpersonationLevel impersonationLevel,
TokenType tokenType,
out IntPtr newToken);
[DllImport("userenv.dll", SetLastError = true)]
private static extern bool CreateEnvironmentBlock(
out IntPtr environment, IntPtr token, bool inherit);
[DllImport("userenv.dll", SetLastError = true)]
private static extern bool DestroyEnvironmentBlock(IntPtr environment);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CreateProcessAsUser(
IntPtr token,
string? applicationName,
string? commandLine,
IntPtr processAttributes,
IntPtr threadAttributes,
bool inheritHandles,
uint creationFlags,
IntPtr environment,
string? currentDirectory,
ref STARTUPINFO startupInfo,
out PROCESS_INFORMATION processInformation);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr handle);
}

View File

@@ -0,0 +1,114 @@
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Site-provided PowerShell "agent utilities" runner. Discovers *.ps1 under the
// configured utilities directory and runs each with the OS powershell.exe,
// parsing its stdout as JSON. This is the documented collection seam: the
// future agent-configuration settings page lets site staff push their own
// scripts down, and their JSON output is merged into the report under
// `scheduledtasks`-adjacent extension keys without the runtime knowing what any
// script means.
//
// Safety posture for this iteration:
// - Disabled unless a utilities directory is configured AND exists.
// - Each script is bounded by a timeout and its failure is isolated.
// - Output must be a single JSON value; non-JSON output is logged and dropped.
// Script SIGNING/allow-listing is a rollout concern (code signing + Intune) and
// is called out in the design doc; do not enable this in production until that
// is in place.
public sealed class PowerShellUtilityRunner : IAgentUtilityRunner
{
private readonly string utilitiesPath;
private readonly ILogger<PowerShellUtilityRunner> logger;
private readonly TimeSpan perScriptTimeout;
public PowerShellUtilityRunner(string utilitiesPath,
ILogger<PowerShellUtilityRunner> logger, TimeSpan? perScriptTimeout = null)
{
this.utilitiesPath = utilitiesPath;
this.logger = logger;
this.perScriptTimeout = perScriptTimeout ?? TimeSpan.FromSeconds(30);
}
public IReadOnlyDictionary<string, object> RunAll(CancellationToken token)
{
var results = new Dictionary<string, object>();
if (string.IsNullOrWhiteSpace(utilitiesPath) || !Directory.Exists(utilitiesPath))
return results;
foreach (var script in Directory.EnumerateFiles(utilitiesPath, "*.ps1"))
{
token.ThrowIfCancellationRequested();
var name = Path.GetFileNameWithoutExtension(script);
try
{
var parsed = RunOne(script, token);
if (parsed is not null)
results[name] = parsed;
}
catch (Exception error)
{
logger.LogWarning(error, "Agent utility {Utility} failed", name);
}
}
return results;
}
private object? RunOne(string scriptPath, CancellationToken token)
{
var start = new ProcessStartInfo
{
FileName = "powershell.exe",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
start.ArgumentList.Add("-NoProfile");
start.ArgumentList.Add("-NonInteractive");
start.ArgumentList.Add("-ExecutionPolicy");
start.ArgumentList.Add("Bypass");
start.ArgumentList.Add("-File");
start.ArgumentList.Add(scriptPath);
using var process = new Process { StartInfo = start };
if (!process.Start())
return null;
var stdout = process.StandardOutput.ReadToEnd();
var stderr = process.StandardError.ReadToEnd();
if (!process.WaitForExit((int)perScriptTimeout.TotalMilliseconds))
{
try { process.Kill(entireProcessTree: true); } catch { }
logger.LogWarning("Agent utility {Script} timed out", scriptPath);
return null;
}
if (process.ExitCode != 0)
{
logger.LogWarning("Agent utility {Script} exit {Code}: {Error}",
scriptPath, process.ExitCode, stderr.Trim());
return null;
}
stdout = stdout.Trim();
if (stdout.Length == 0)
return null;
try
{
using var document = JsonDocument.Parse(stdout);
return JsonSerializer.Deserialize<object>(stdout);
}
catch (JsonException)
{
logger.LogWarning("Agent utility {Script} produced non-JSON output",
scriptPath);
return null;
}
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>ShopDbEndpointAgent.Core</RootNamespace>
<AssemblyName>ShopDbEndpointAgent.Core</AssemblyName>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="System.Management" Version="8.0.0" />
<PackageReference Include="System.Diagnostics.EventLog" Version="8.0.0" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,129 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// The telemetry loop as a hosted BackgroundService. On first contact it sends
// adopt=true (once) so the server's computers collector creates the PC; every
// run after that omits adopt and relies on strict hostname resolution. Between
// runs it waits the configured cadence plus/minus jitter so a fleet does not
// report in lockstep (thundering herd).
//
// The worker never lets one bad run kill the loop: a transient failure is
// already retried inside CollectorClient, and a permanent failure (bad key,
// PC removed server-side) is logged and the loop simply waits for the next
// cadence tick rather than hammering the server.
public sealed class TelemetryWorker : BackgroundService
{
private readonly ICollector collector;
private readonly CollectorClient client;
private readonly IAgentUtilityRunner? utilities;
private readonly AgentConfig config;
private readonly string agentVersion;
private readonly ILogger<TelemetryWorker> logger;
private readonly CollectSignal? collectSignal;
private readonly Random jitter = new();
public TelemetryWorker(ICollector collector, CollectorClient client,
AgentConfig config, string agentVersion, ILogger<TelemetryWorker> logger,
IAgentUtilityRunner? utilities = null, CollectSignal? collectSignal = null)
{
this.collector = collector;
this.client = client;
this.config = config;
this.agentVersion = agentVersion;
this.logger = logger;
this.utilities = utilities;
this.collectSignal = collectSignal;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation(
"Endpoint agent {Version} -> {BaseUrl}, cadence {Cadence}s +/-{Jitter}s",
agentVersion, config.BaseUrl, config.CadenceSeconds, config.JitterSeconds);
bool firstRun = true;
while (!stoppingToken.IsCancellationRequested)
{
bool adopt = firstRun && config.AdoptOnFirstRun;
try
{
await RunOnceAsync(adopt, stoppingToken);
firstRun = false;
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (CollectorException error) when (error.IsPermanent)
{
// Bad key or PC removed server-side: surface it, do not retry
// faster than cadence. If it was a first-run adopt, keep trying
// to adopt next tick (the operator may be provisioning the PC).
logger.LogError(error, "Permanent collector error; waiting for next cadence");
}
catch (Exception error)
{
logger.LogError(error, "Telemetry run failed");
}
try
{
if (collectSignal is not null)
{
// Wake early if a command asked for an immediate re-collect,
// otherwise fall through when the cadence delay elapses.
bool woken = await collectSignal.WaitAsync(NextDelay(), stoppingToken);
if (woken)
logger.LogInformation("Off-cadence collect requested after a command");
}
else
{
await Task.Delay(NextDelay(), stoppingToken);
}
}
catch (OperationCanceledException)
{
break;
}
}
logger.LogInformation("Endpoint agent stopped");
}
// Public so a --once console run and future on-demand triggers can reuse the
// exact production collection path.
public async Task<CollectorResponse> RunOnceAsync(bool adopt, CancellationToken token)
{
var report = collector.Collect(agentVersion, adopt);
if (utilities is not null)
{
var extra = utilities.RunAll(token);
if (extra.Count > 0)
logger.LogDebug("Loaded {Count} agent utility result(s)", extra.Count);
}
logger.LogInformation(
"Reporting (adopt={Adopt}) user={User} serial={Serial} events={Events}",
adopt, report.LoggedInUser, report.SerialNumber, report.Events?.Length ?? 0);
var result = await client.SendAsync(report, token);
logger.LogInformation("OK action={Action} assetid={AssetId} identity={Identity}",
result.Action, result.AssetId, result.IdentityValue);
foreach (var warning in result.Warnings ?? Array.Empty<string>())
logger.LogWarning("Server warning: {Warning}", warning);
return result;
}
private TimeSpan NextDelay()
{
int spread = Math.Max(0, config.JitterSeconds);
int offset = spread == 0 ? 0 : jitter.Next(-spread, spread + 1);
int seconds = Math.Max(1, config.CadenceSeconds + offset);
return TimeSpan.FromSeconds(seconds);
}
}

View File

@@ -0,0 +1,17 @@
namespace ShopDbEndpointAgent.Tray;
// Interactive user-session tray app (SCAFFOLD). Separate process from the
// headless Windows Service: the service owns telemetry, this owns the operator
// surface - a system-tray icon and the 5-click-max ServiceNow ticket flow
// (DESIGN-DECISIONS-agent.md section 6). This iteration wires the tray and a
// stubbed ticket dialog; the ServiceNow submission (direct vs server-proxied)
// is deferred until that decision is made server-side.
internal static class Program
{
[STAThread]
private static void Main()
{
ApplicationConfiguration.Initialize();
Application.Run(new TrayContext());
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<RootNamespace>ShopDbEndpointAgent.Tray</RootNamespace>
<AssemblyName>shopdb-endpoint-tray</AssemblyName>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<DebugType>none</DebugType>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ShopDbEndpointAgent.Core\ShopDbEndpointAgent.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,118 @@
namespace ShopDbEndpointAgent.Tray;
// The 5-click-max issue reporter (SCAFFOLD). Target flow from
// DESIGN-DECISIONS-agent.md section 6:
// network precheck -> issue dropdown -> optional screenshot -> submit.
// This iteration renders the dropdown of fixed reasons and a submit button but
// does NOT talk to ServiceNow yet - the submission path (direct API vs proxied
// through a ShopDB server endpoint so ServiceNow creds stay server-side) is an
// open decision. Submit currently shows what WOULD be sent.
internal sealed class TicketForm : Form
{
// Fixed dropdown reasons from the source conversation (section 6).
private static readonly string[] Reasons =
{
"Can't logon to CSF",
"Data collections down",
"Can't download program",
"Machine communication failing",
"Network drives down",
"Other (free text)",
};
private readonly ComboBox reasonBox;
private readonly TextBox detailBox;
private readonly CheckBox screenshotBox;
public TicketForm()
{
Text = "Report an issue";
FormBorderStyle = FormBorderStyle.FixedDialog;
StartPosition = FormStartPosition.CenterScreen;
MinimizeBox = false;
MaximizeBox = false;
ClientSize = new Size(420, 260);
var reasonLabel = new Label
{
Text = "What is happening?",
Left = 16,
Top = 16,
Width = 388,
};
reasonBox = new ComboBox
{
Left = 16,
Top = 40,
Width = 388,
DropDownStyle = ComboBoxStyle.DropDownList,
};
reasonBox.Items.AddRange(Reasons);
reasonBox.SelectedIndex = 0;
var detailLabel = new Label
{
Text = "Details (optional)",
Left = 16,
Top = 76,
Width = 388,
};
detailBox = new TextBox
{
Left = 16,
Top = 100,
Width = 388,
Height = 80,
Multiline = true,
};
screenshotBox = new CheckBox
{
Text = "Attach a screenshot of my screen",
Left = 16,
Top = 188,
Width = 388,
};
var submit = new Button
{
Text = "Submit",
Left = 244,
Top = 220,
Width = 76,
DialogResult = DialogResult.OK,
};
submit.Click += OnSubmit;
var cancel = new Button
{
Text = "Cancel",
Left = 328,
Top = 220,
Width = 76,
DialogResult = DialogResult.Cancel,
};
Controls.AddRange(new Control[]
{
reasonLabel, reasonBox, detailLabel, detailBox, screenshotBox,
submit, cancel,
});
AcceptButton = submit;
CancelButton = cancel;
}
private void OnSubmit(object? sender, EventArgs e)
{
// Placeholder: show what would be submitted. The real path (screenshot
// capture to Pictures\Screenshots, then ServiceNow submit) lands once
// the direct-vs-proxied decision is made.
var summary =
$"Reason: {reasonBox.SelectedItem}\n" +
$"Details: {(string.IsNullOrWhiteSpace(detailBox.Text) ? "(none)" : detailBox.Text)}\n" +
$"Screenshot: {(screenshotBox.Checked ? "yes" : "no")}";
MessageBox.Show(
"Ticketing is not wired up yet. This would be submitted:\n\n" + summary,
"ShopDB agent (scaffold)", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
}

View File

@@ -0,0 +1,202 @@
using System.Diagnostics;
namespace ShopDbEndpointAgent.Tray;
// Owns the tray icon and its context menu for the app's lifetime. Kept minimal
// and headless-friendly: no main window, the icon is the whole UI surface until
// the operator opens the ticket dialog.
//
// "Send Status now" reuses the exact production collection path by shelling out
// to the installed shopdbagent.exe with --once. That keeps the collector key and
// all collection logic in the service binary: the tray never holds a secret and
// never duplicates wiring, it just asks the agent to report on demand so the
// ShopDB monitor page reflects the box immediately instead of at next cadence.
internal sealed class TrayContext : ApplicationContext
{
// The headless agent/service executable. The tray ships next to it in the
// install directory, so same-dir resolution is the primary path.
private const string AgentExeName = "shopdbagent.exe";
private const string DefaultInstallDir = @"C:\Program Files\ShopDB\EndpointAgent";
private readonly NotifyIcon trayIcon;
private readonly Icon brandIcon;
private bool sending;
public TrayContext()
{
var menu = new ContextMenuStrip();
menu.Items.Add("Send Status now", null, OnSendStatus);
menu.Items.Add("Report an issue...", null, OnReportIssue);
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("About ShopDB agent", null, OnAbout);
menu.Items.Add("Exit", null, OnExit);
// Branded tray icon drawn from the ShopDB WebUI favicon (see TrayIcon).
brandIcon = TrayIcon.Create();
trayIcon = new NotifyIcon
{
Icon = brandIcon,
Text = "ShopDB Endpoint Agent",
Visible = true,
ContextMenuStrip = menu,
};
trayIcon.DoubleClick += OnSendStatus;
}
private void OnReportIssue(object? sender, EventArgs e)
{
using var dialog = new TicketForm();
dialog.ShowDialog();
}
// Runs shopdbagent.exe --once out of process so the operator can push a
// fresh report without waiting for the next cadence tick. The exit code is
// the agent's own contract: 0 = reported OK, non-zero = failed (bad key,
// host removed, network) and the detail is in the rolling agent log.
private async void OnSendStatus(object? sender, EventArgs e)
{
if (sending)
return;
var agentExe = ResolveAgentExe();
if (agentExe is null)
{
ShowBalloon("Send Status", "Could not find " + AgentExeName +
". Is the ShopDB Endpoint Agent installed?", ToolTipIcon.Error);
return;
}
sending = true;
trayIcon.Text = "ShopDB Endpoint Agent (sending status...)";
ShowBalloon("Send Status", "Sending this PC's status to ShopDB...",
ToolTipIcon.Info);
try
{
int exitCode = await RunAgentOnceAsync(agentExe);
if (exitCode == 0)
ShowBalloon("Send Status", "Status sent to ShopDB.",
ToolTipIcon.Info);
else
ShowBalloon("Send Status",
"Status send failed (exit " + exitCode +
"). See the agent log under ProgramData\\ShopDB.",
ToolTipIcon.Warning);
}
catch (Exception error)
{
ShowBalloon("Send Status", "Could not run the agent: " +
error.Message, ToolTipIcon.Error);
}
finally
{
sending = false;
trayIcon.Text = "ShopDB Endpoint Agent";
}
}
private static Task<int> RunAgentOnceAsync(string agentExe)
{
var startInfo = new ProcessStartInfo
{
FileName = agentExe,
Arguments = "--once",
WorkingDirectory = Path.GetDirectoryName(agentExe) ?? Environment.CurrentDirectory,
UseShellExecute = false,
CreateNoWindow = true,
};
// The tray and the agent are both single-file, self-contained apps. When
// the tray host started, the .NET single-file bootstrapper set DOTNET_*
// and bundle-extraction variables in this process's environment. With
// UseShellExecute=false the child inherits those variables, so the
// freshly installed agent tries to bootstrap against the tray's stale
// extraction state and dies during host startup (exit 0xE0434352) before
// any managed code - or the file log - runs. Stripping these inherited
// markers gives the child the same clean host environment it gets when
// launched from a shell, and it also removes the need to restart the tray
// after every agent rebuild. The agent re-derives whatever it needs.
ScrubInheritedDotnetHostVariables(startInfo);
var completion = new TaskCompletionSource<int>();
var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
process.Exited += (_, _) =>
{
completion.TrySetResult(process.ExitCode);
process.Dispose();
};
if (!process.Start())
{
process.Dispose();
completion.TrySetException(
new InvalidOperationException("failed to start " + agentExe));
}
return completion.Task;
}
// Remove the .NET host / single-file bundle markers this tray process
// inherited at startup so a spawned single-file child bootstraps cleanly
// instead of adopting the tray's extraction state. Names are matched by a
// DOTNET_/COREHOST_ prefix (covers DOTNET_BUNDLE_EXTRACT_BASE_DIR,
// DOTNET_ROOT, the internal startup hooks, etc.) so this stays correct as
// the runtime adds new host variables.
private static void ScrubInheritedDotnetHostVariables(ProcessStartInfo startInfo)
{
var toRemove = startInfo.Environment.Keys
.Where(key =>
key.StartsWith("DOTNET_", StringComparison.OrdinalIgnoreCase) ||
key.StartsWith("COREHOST_", StringComparison.OrdinalIgnoreCase))
.ToList();
foreach (var key in toRemove)
startInfo.Environment.Remove(key);
}
// Same directory first (the installer ships the tray next to the agent),
// then the known Program Files install directory as a fallback.
private static string? ResolveAgentExe()
{
var beside = Path.Combine(AppContext.BaseDirectory, AgentExeName);
if (File.Exists(beside))
return beside;
var installed = Path.Combine(DefaultInstallDir, AgentExeName);
if (File.Exists(installed))
return installed;
return null;
}
private void ShowBalloon(string title, string text, ToolTipIcon icon)
{
trayIcon.BalloonTipTitle = title;
trayIcon.BalloonTipText = text;
trayIcon.BalloonTipIcon = icon;
trayIcon.ShowBalloonTip(4000);
}
private void OnAbout(object? sender, EventArgs e)
=> MessageBox.Show(
"ShopDB Endpoint Agent tray.\n" +
"Telemetry runs as the ShopDB Endpoint Agent Windows service.\n" +
"Send Status now reports this PC to ShopDB on demand.",
"About", MessageBoxButtons.OK, MessageBoxIcon.Information);
private void OnExit(object? sender, EventArgs e)
{
trayIcon.Visible = false;
ExitThread();
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
trayIcon.Dispose();
brandIcon.Dispose();
}
base.Dispose(disposing);
}
}

View File

@@ -0,0 +1,81 @@
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
namespace ShopDbEndpointAgent.Tray;
// Builds the tray icon from the ShopDB WebUI favicon (frontend/public/favicon.svg:
// a #4181ff rounded square with a white "S") by drawing it with GDI+ at runtime.
// Drawing it here rather than shipping a binary .ico keeps the brand in one
// place (these values mirror the SVG) and avoids adding an SVG rasterizer or a
// checked-in binary to the build.
internal static class TrayIcon
{
// Favicon colors (frontend/public/favicon.svg).
private static readonly Color Background = ColorTranslator.FromHtml("#4181ff");
private static readonly Color Foreground = Color.White;
// Renders the branded icon at a tray-friendly size. 32px covers standard and
// most high-DPI tray scaling; the OS downsamples as needed.
public static Icon Create(int size = 32)
{
using var bitmap = new Bitmap(size, size, PixelFormat.Format32bppArgb);
using (var graphics = Graphics.FromImage(bitmap))
{
graphics.SmoothingMode = SmoothingMode.AntiAlias;
graphics.TextRenderingHint =
System.Drawing.Text.TextRenderingHint.AntiAlias;
graphics.Clear(Color.Transparent);
// Rounded square (SVG rect rx=14 on a 64 viewport -> ~0.22 radius).
float inset = size * 0.03f;
var rect = new RectangleF(inset, inset,
size - inset * 2, size - inset * 2);
float radius = size * 0.22f;
using (var path = RoundedRect(rect, radius))
using (var fill = new SolidBrush(Background))
graphics.FillPath(fill, path);
// White "S", centered.
using var font = new Font("Arial", size * 0.58f,
FontStyle.Bold, GraphicsUnit.Pixel);
using var text = new SolidBrush(Foreground);
using var format = new StringFormat
{
Alignment = StringAlignment.Center,
LineAlignment = StringAlignment.Center,
};
graphics.DrawString("S", font, text, rect, format);
}
// Icon.FromHandle borrows the GDI HICON, so clone into a managed Icon
// and free the handle to avoid leaking one per launch.
IntPtr hicon = bitmap.GetHicon();
try
{
using var borrowed = Icon.FromHandle(hicon);
return (Icon)borrowed.Clone();
}
finally
{
DestroyIcon(hicon);
}
}
private static GraphicsPath RoundedRect(RectangleF rect, float radius)
{
float diameter = radius * 2;
var path = new GraphicsPath();
path.AddArc(rect.Left, rect.Top, diameter, diameter, 180, 90);
path.AddArc(rect.Right - diameter, rect.Top, diameter, diameter, 270, 90);
path.AddArc(rect.Right - diameter, rect.Bottom - diameter, diameter,
diameter, 0, 90);
path.AddArc(rect.Left, rect.Bottom - diameter, diameter, diameter, 90, 90);
path.CloseFigure();
return path;
}
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DestroyIcon(IntPtr handle);
}

View File

@@ -0,0 +1,5 @@
<Solution>
<Project Path="ShopDbEndpointAgent.Core/ShopDbEndpointAgent.Core.csproj" />
<Project Path="ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj" />
<Project Path="ShopDbEndpointAgent/ShopDbEndpointAgent.csproj" />
</Solution>

View File

@@ -0,0 +1,164 @@
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ShopDbEndpointAgent.Core;
// Headless Windows Service host for the ShopDB endpoint agent. Composes the
// Core runtime (config -> collector -> client -> worker) behind the generic
// host, adds durable file logging and a WER-style fault monitor, and supports:
// (default) run as a Windows Service (AddWindowsService)
// --console run interactively in the foreground for debugging
// --once collect and report exactly once, then exit (smoke test / cron)
//
// Secrets are never read here: AgentConfigProvider layers the registry
// (HKLM\SOFTWARE\GE\ShopDB: CollectorKey, BaseUrl) over the shipped, non-secret
// appsettings.json. The executable is intentionally named shopdbagent.exe so it
// never collides with third-party agent.exe processes/services (for example the
// MTConnect agent this same build can monitor).
var runOnce = args.Any(a => string.Equals(a, "--once", StringComparison.OrdinalIgnoreCase));
var forceConsole = args.Any(a => string.Equals(a, "--console", StringComparison.OrdinalIgnoreCase));
var agentVersion = Assembly.GetExecutingAssembly()
.GetName().Version?.ToString() ?? "0.0.0";
// Bootstrap logger first so config loading (including the registry read that
// CyberArk/EPM may gate) is fully diagnosable before the host starts.
using var bootstrapFileLogger = new FileLoggerProvider(FileLoggerProvider.DefaultDirectory());
var bootstrapLogger = bootstrapFileLogger.CreateLogger("Startup");
var faultMonitor = new AgentFaultMonitor(bootstrapLogger);
faultMonitor.Install();
var config = AgentConfigProvider.Load(logger: bootstrapLogger);
var logDirectory = string.IsNullOrWhiteSpace(config.LogPath)
? FileLoggerProvider.DefaultDirectory()
: config.LogPath;
if (!config.IsUsable())
{
bootstrapLogger.LogError(
"Config incomplete: provision CollectorKey and BaseUrl in " +
"HKLM\\SOFTWARE\\GE\\ShopDB (or baseurl/apikey in appsettings.json for dev). " +
"See the registry lines above to tell 'missing' from 'access denied'.");
return 1;
}
try
{
if (runOnce)
return await RunOnceAsync(config, agentVersion, logDirectory, faultMonitor);
await RunHostAsync(config, agentVersion, logDirectory, forceConsole);
return 0;
}
catch (Exception error)
{
faultMonitor.ReportFatal("host", error);
return 1;
}
static async Task RunHostAsync(AgentConfig config, string agentVersion,
string logDirectory, bool forceConsole)
{
var builder = Host.CreateApplicationBuilder();
if (!forceConsole)
builder.Services.AddWindowsService(options =>
options.ServiceName = "ShopDbEndpointAgent");
ConfigureLogging(builder.Logging, logDirectory);
RegisterCoreServices(builder.Services, config, agentVersion);
builder.Services.AddHostedService(sp => sp.GetRequiredService<TelemetryWorker>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<CommandWorker>());
await builder.Build().RunAsync();
}
static async Task<int> RunOnceAsync(AgentConfig config, string agentVersion,
string logDirectory, AgentFaultMonitor faultMonitor)
{
using var services = BuildOnceServices(config, agentVersion, logDirectory);
var worker = services.GetRequiredService<TelemetryWorker>();
var logger = services.GetRequiredService<ILogger<TelemetryWorker>>();
try
{
var result = await worker.RunOnceAsync(config.AdoptOnFirstRun, CancellationToken.None);
logger.LogInformation("Single report complete: action={Action}", result.Action);
return 0;
}
catch (Exception error)
{
faultMonitor.ReportFatal("--once", error);
return 1;
}
}
static ServiceProvider BuildOnceServices(AgentConfig config, string agentVersion,
string logDirectory)
{
var services = new ServiceCollection();
services.AddLogging(logging => ConfigureLogging(logging, logDirectory));
RegisterCoreServices(services, config, agentVersion);
return services.BuildServiceProvider();
}
static void RegisterCoreServices(IServiceCollection services, AgentConfig config,
string agentVersion)
{
services.AddSingleton(config);
// Shared wake used by the command worker to trigger an immediate telemetry
// collect after a command changes endpoint state (e.g. MTConnect toggle).
services.AddSingleton<CollectSignal>();
if (!string.IsNullOrWhiteSpace(config.UtilitiesPath) &&
Directory.Exists(config.UtilitiesPath))
{
services.AddSingleton<IAgentUtilityRunner>(sp =>
new PowerShellUtilityRunner(config.UtilitiesPath,
sp.GetRequiredService<ILogger<PowerShellUtilityRunner>>()));
}
services.AddSingleton<ICollector>(sp => new EndpointCollector(
sp.GetRequiredService<ILogger<EndpointCollector>>(),
config,
sp.GetService<IAgentUtilityRunner>()));
services.AddSingleton(sp => new CollectorClient(
config, sp.GetRequiredService<ILogger<CollectorClient>>()));
services.AddSingleton(sp => new TelemetryWorker(
sp.GetRequiredService<ICollector>(),
sp.GetRequiredService<CollectorClient>(),
config,
agentVersion,
sp.GetRequiredService<ILogger<TelemetryWorker>>(),
sp.GetService<IAgentUtilityRunner>(),
sp.GetRequiredService<CollectSignal>()));
// Command channel: outbound long-poll for allowlisted WebUI commands, run
// through the fail-closed executor. Hosted alongside telemetry but on its
// own loop so a held-open poll never delays a report.
services.AddSingleton(sp => new CommandClient(
config, sp.GetRequiredService<ILogger<CommandClient>>()));
services.AddSingleton(sp => new CommandExecutor(
sp.GetRequiredService<ILogger<CommandExecutor>>(),
config));
services.AddSingleton(sp => new CommandWorker(
sp.GetRequiredService<CommandClient>(),
sp.GetRequiredService<CommandExecutor>(),
sp.GetRequiredService<ILogger<CommandWorker>>(),
sp.GetRequiredService<CollectSignal>()));
}
static void ConfigureLogging(ILoggingBuilder logging, string logDirectory)
{
logging.ClearProviders();
logging.AddProvider(new FileLoggerProvider(logDirectory));
logging.SetMinimumLevel(LogLevel.Information);
}

View File

@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
<AssemblyName>shopdbagent</AssemblyName>
<!-- 1.2.0: process_start launches via a per-image path allowlist
(AgentConfig.LaunchPaths, shipped defaults overridable per site).
1.1.0: generic, data-driven service_*/process_* actions carry their
target over the wire, so new policies/apps need no agent rebuild. -->
<Version>1.3.0</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<DebugType>none</DebugType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.EventLog" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ShopDbEndpointAgent.Core\ShopDbEndpointAgent.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,26 @@
{
"baseurl": "",
"apikey": "REPLACE_WITH_COLLECTOR_KEY",
"cadenceseconds": 900,
"jitterseconds": 60,
"adoptonfirstrun": true,
"utilitiespath": "",
"logpath": "",
"monitors": {
"processes": [
{ "name": "dncMain.exe", "app": "eDNC" },
{ "name": "udc.exe", "app": "Universal Data Collector" },
{ "name": "agent.exe", "app": "MTConnect" }
],
"services": [
{ "name": "MTConnect UDC Agent", "app": "MTConnect" }
]
},
"launchpaths": {
"DncMain.exe": "C:\\Program Files (x86)\\Dnc\\bin\\DncMain.exe",
"UDC.exe": "C:\\Program Files\\UDC\\UDC.exe",
"agent.exe": "C:\\ProgramData\\udc\\mtconnect_udc\\agent\\run.bat"
}
}

View File

@@ -0,0 +1,355 @@
# install-shopdbagent.ps1
# ShopDB Endpoint Agent installer for the test endpoint. Run from an ELEVATED
# (admin) PowerShell on the endpoint. Provisions the registry secrets, copies
# the agent files to Program Files, runs a one-shot smoke test, then installs
# and starts the Windows service.
#
# Secrets are provisioned to HKLM\SOFTWARE\GE\ShopDB. On locked-down hosts
# (CyberArk/EPM) the agent may be blocked from READING that hive, so the same
# values are also stamped into the installed appsettings.json as a fallback.
#
# The agent and tray are published as single-file self-contained executables,
# so copying the two .exe files plus appsettings.json is the complete payload.
[CmdletBinding()]
param(
# ShopDB server root URL for this instance. REQUIRED - there is no default,
# because a default here means one site's hostname shipped to every site
# (ADR-015) and, worse, a working install pointed at somebody else's server.
#
# https is required. The collector key travels in the X-API-Key header and
# every telemetry payload follows it, so plain http puts both on the wire in
# clear. Pass -AllowInsecureHttp only for a local test instance.
[Parameter(Mandatory = $true)]
[string] $BaseUrl,
# Endpoints-only collector key: this instance's .env COLLECTOR_API_KEY_ENDPOINTS.
# REQUIRED and never defaulted - a key in source is a key in git history, and
# this one is also the command channel's kill switch.
[Parameter(Mandatory = $true)]
[string] $CollectorKey,
# Permit an http:// BaseUrl. For a local test instance only.
[switch] $AllowInsecureHttp,
[string] $InstallDir = "C:\Program Files\ShopDB\EndpointAgent",
[string] $ServiceName = "ShopDbEndpointAgent",
# Interactive tray app that shows the system-tray icon and the on-demand
# "Send Status now" action. Separate process from the SYSTEM service (a
# service cannot draw a tray icon in a user session). Pass -NoTray to skip.
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
[switch] $NoTray,
# Add a Microsoft Defender path exclusion for the install directory. The
# agent and tray are unsigned single-file self-contained executables, which
# Defender heuristics (and the tray's GDI/user32 icon P/Invoke) can flag as
# a false positive and quarantine, killing the tray before its icon appears.
# Pass -NoDefenderExclusion to skip (e.g. where policy manages exclusions
# centrally or the binaries are code-signed).
[switch] $NoDefenderExclusion
)
# Refuse cleartext unless it was asked for explicitly. The key is a bearer
# credential for the whole fleet's telemetry and command channel.
if ($BaseUrl -notmatch '^https://' -and -not $AllowInsecureHttp) {
throw "BaseUrl must be https:// (got '$BaseUrl'). Pass -AllowInsecureHttp for a local test instance."
}
$ErrorActionPreference = "Stop"
$sourceDir = Split-Path -Parent $MyInvocation.MyCommand.Path
function Assert-Admin {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw "This script must be run from an elevated (Run as administrator) PowerShell."
}
}
Assert-Admin
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
throw "BaseUrl is not set. Pass -BaseUrl 'http://<host>:<port>' when running the script."
}
# Write HKLM values through the explicit 64-bit registry view. If this script
# runs under 32-bit PowerShell (some management tooling launches x86), the
# PSDrive HKLM: silently redirects writes into WOW6432Node. The 64-bit agent
# service and 64-bit Explorer (the logon Run key) then read the 64-bit view and
# see nothing, so config "goes missing" and the tray never autostarts. Pinning
# the view removes that dependency on the launching shell's bitness.
function Set-Hklm64Value {
param(
[Parameter(Mandatory)] [string] $SubKey,
[Parameter(Mandatory)] [string] $Name,
[Parameter(Mandatory)] [string] $Value
)
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
[Microsoft.Win32.RegistryHive]::LocalMachine,
[Microsoft.Win32.RegistryView]::Registry64)
try {
$key = $base.CreateSubKey($SubKey)
try {
$key.SetValue($Name, $Value, [Microsoft.Win32.RegistryValueKind]::String)
}
finally { $key.Dispose() }
}
finally { $base.Dispose() }
}
# Tear down any previously installed agent BEFORE touching files. The agent and
# tray are single-file self-contained exes; while the old service or tray is
# running it holds those .exe files open, so Copy-Item over them fails with a
# file-in-use error. Stopping the service, deleting it, and killing any lingering
# agent/tray processes up front makes the install a clean overwrite. Best-effort
# throughout: a missing service or process is the normal fresh-install case.
function Uninstall-PreviousAgent {
param(
[Parameter(Mandatory)] [string] $Service,
[Parameter(Mandatory)] [string] $TrayExe,
[Parameter(Mandatory)] [string] $TargetDir
)
$existing = Get-Service -Name $Service -ErrorAction SilentlyContinue
if ($existing) {
Write-Host " stopping and removing existing service $Service ..."
if ($existing.Status -ne "Stopped") {
try { Stop-Service -Name $Service -Force -ErrorAction Stop }
catch { Write-Warning " could not stop service: $($_.Exception.Message)" }
}
# sc.exe delete only marks the service for deletion; the SCM finishes the
# removal once every handle closes. Give it a moment so the subsequent
# sc.exe create does not hit a "marked for deletion" (1072) error.
sc.exe delete $Service | Out-Null
Start-Sleep -Seconds 2
}
# Kill any lingering worker or tray processes that would keep the exes
# locked. Match by the installed exe base names; the service host process is
# shopdbagent, the tray is the TrayExe base name.
$trayBase = [System.IO.Path]::GetFileNameWithoutExtension($TrayExe)
foreach ($name in @("shopdbagent", $trayBase)) {
$procs = Get-Process -Name $name -ErrorAction SilentlyContinue
foreach ($p in $procs) {
Write-Host " killing lingering process $($p.ProcessName) (pid $($p.Id)) ..."
try { Stop-Process -Id $p.Id -Force -ErrorAction Stop }
catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" }
}
}
# Also kill anything still running out of the target install directory,
# regardless of exe name. A tray from an older build (different exe name) or
# one launched from a stale path would otherwise survive the name-based sweep
# above, keep its exe locked, and leave a duplicate icon once step 5 starts
# the fresh tray. Match on the executable path so we only touch our own dir.
$targetPrefix = $TargetDir.TrimEnd('\') + '\'
foreach ($p in (Get-Process -ErrorAction SilentlyContinue)) {
$path = $null
try { $path = $p.Path } catch { $path = $null }
if ($path -and $path.StartsWith($targetPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
Write-Host " killing process from install dir $($p.ProcessName) (pid $($p.Id)) ..."
try { Stop-Process -Id $p.Id -Force -ErrorAction Stop }
catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" }
}
}
# Brief settle so file handles are released before the copy overwrites them.
Start-Sleep -Milliseconds 500
}
Write-Host "== ShopDB Endpoint Agent install ==" -ForegroundColor Cyan
Write-Host "BaseUrl : $BaseUrl"
Write-Host "InstallDir : $InstallDir"
Write-Host "ServiceName : $ServiceName"
# 0. Clear the Mark-of-the-Web on the SOURCE payload first. The operator
# typically extracts installer.zip into Downloads, so every file carries a
# Zone.Identifier ADS. SmartScreen reads that and blocks the unsigned exes with
# "Windows protected your PC" the first time they run - including the [3/5]
# smoke test below, which launches shopdbagent.exe out of $sourceDir before the
# installed copy exists. Unblocking the source folder up front keeps the smoke
# test (and any manual run from the extract folder) clean; the installed copies
# get unblocked again in step 2a after they land in Program Files. Best-effort:
# a file with no MotW is a no-op.
Write-Host "`n[0a] Clearing Mark-of-the-Web on the extracted installer files ..."
Get-ChildItem -Path $sourceDir -File | ForEach-Object {
try { Unblock-File -Path $_.FullName -ErrorAction Stop }
catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" }
}
# 0. Uninstall any previous agent first, so the file copy is a clean overwrite
# and does not fail on exes locked by a running service or tray.
Write-Host "`n[0/5] Removing any previously installed agent ..."
Uninstall-PreviousAgent -Service $ServiceName -TrayExe $TrayExeName -TargetDir $InstallDir
# 1. Provision registry secrets (64-bit view, see Set-Hklm64Value).
$regPath = "HKLM:\SOFTWARE\GE\ShopDB"
Write-Host "`n[1/5] Provisioning registry $regPath (Registry64) ..."
Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "BaseUrl" -Value $BaseUrl
Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "CollectorKey" -Value $CollectorKey
# 2. Copy agent files to the install directory. The agent and tray are
# single-file self-contained executables, so the .exe plus appsettings.json is
# the whole payload; there are no side-by-side runtime DLLs to copy.
Write-Host "[2/5] Copying agent files to $InstallDir ..."
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
$agentSource = Join-Path $sourceDir "shopdbagent.exe"
if (-not (Test-Path $agentSource)) {
throw "shopdbagent.exe not found in $sourceDir. Unzip the full installer package and run from that folder."
}
Copy-Item -Path $agentSource -Destination $InstallDir -Force
$appsettings = Join-Path $sourceDir "appsettings.json"
$installedSettings = Join-Path $InstallDir "appsettings.json"
if (Test-Path $appsettings) {
Copy-Item -Path $appsettings -Destination $InstallDir -Force
}
# Copy the interactive tray app next to the agent so its same-dir resolution
# finds shopdbagent.exe for the "Send Status now" action. Optional: absent on a
# service-only (headless) build.
$trayInstalled = $null
if (-not $NoTray) {
$traySource = Join-Path $sourceDir $TrayExeName
if (Test-Path $traySource) {
Copy-Item -Path $traySource -Destination $InstallDir -Force
$trayInstalled = Join-Path $InstallDir $TrayExeName
Write-Host " copied tray app $TrayExeName"
}
else {
Write-Warning " tray app $TrayExeName not found in $sourceDir; skipping tray (service still installs)"
}
}
# Fallback for locked-down endpoints: some hosts (CyberArk/EPM) gate the HKLM
# hive so the agent cannot READ the values the registry step just wrote. Stamp
# BaseUrl/CollectorKey into the installed appsettings.json too. The agent reads
# appsettings first, then lets the registry override it, so this is a safe
# no-op where the registry read works and the rescue where it does not.
if (Test-Path $installedSettings) {
try {
$json = Get-Content $installedSettings -Raw | ConvertFrom-Json
$json.baseurl = $BaseUrl
$json.apikey = $CollectorKey
$json | ConvertTo-Json -Depth 10 | Set-Content -Path $installedSettings -Encoding UTF8
Write-Host " stamped BaseUrl/CollectorKey into appsettings.json (registry-read fallback)"
}
catch {
Write-Warning " could not stamp appsettings.json: $($_.Exception.Message)"
}
}
$exePath = Join-Path $InstallDir "shopdbagent.exe"
# 2a. Strip the Mark-of-the-Web from the copied files. Windows tags files
# extracted from a downloaded zip with a Zone.Identifier alternate data stream;
# SmartScreen reads that and blocks the unsigned exe with "Windows protected
# your PC" the first time it runs, and the operator sees only a "Don't run"
# button. Unblock-File removes the ADS so the smoke test and the tray launch
# start clean. Best-effort: a file with no MotW is a no-op.
Write-Host "[2a/5] Clearing Mark-of-the-Web on installed files ..."
Get-ChildItem -Path $InstallDir -File | ForEach-Object {
try { Unblock-File -Path $_.FullName -ErrorAction Stop }
catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" }
}
# 2b. Defender path exclusion for the install dir. Unsigned single-file
# self-contained binaries (and the tray's icon P/Invoke) can trip Defender
# heuristics and get quarantined, which is why the tray dies before showing an
# icon. Excluding the fixed install directory is the least-privilege fix short
# of code-signing. Best-effort: Set-MpPreference is absent on servers without
# Defender and can be blocked by tamper protection; a failure is not fatal.
if (-not $NoDefenderExclusion) {
Write-Host "[2b/5] Adding Defender path exclusion for $InstallDir ..."
if (Get-Command Add-MpPreference -ErrorAction SilentlyContinue) {
try {
Add-MpPreference -ExclusionPath $InstallDir -ErrorAction Stop
Write-Host " excluded $InstallDir from Defender scanning."
}
catch {
Write-Warning " could not add Defender exclusion (tamper protection or policy?): $($_.Exception.Message)"
Write-Warning " if the tray is quarantined, code-sign the binaries or add the exclusion via policy."
}
}
else {
Write-Host " Defender cmdlets not present; skipping exclusion."
}
}
# 3. One-shot smoke test (exact production collection path, then exits).
Write-Host "[3/5] Running one-shot smoke test (--once) ..."
$proc = Start-Process -FilePath $exePath -ArgumentList "--once" -WorkingDirectory $InstallDir -Wait -PassThru -NoNewWindow
Write-Host " smoke test exit code: $($proc.ExitCode)"
$logDir = Join-Path $env:ProgramData "ShopDB\EndpointAgent\logs"
$logFile = Join-Path $logDir ("agent-{0}.log" -f (Get-Date -Format "yyyyMMdd"))
if (Test-Path $logFile) {
Write-Host " --- last log lines ---" -ForegroundColor DarkGray
Get-Content $logFile -Tail 20 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
}
if ($proc.ExitCode -ne 0) {
throw "Smoke test failed (exit $($proc.ExitCode)). Check $logFile before installing the service."
}
# 4. Install the Windows service. Any previous instance was already stopped,
# deleted, and its processes killed in step 0, so this is a clean create. Guard
# against the rare case where the SCM has not finished a pending deletion yet.
Write-Host "[4/5] Installing service $ServiceName ..."
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($existing) {
Write-Host " service still present (pending deletion); removing again ..."
if ($existing.Status -ne "Stopped") { Stop-Service -Name $ServiceName -Force }
sc.exe delete $ServiceName | Out-Null
Start-Sleep -Seconds 2
}
$binPath = '"' + $exePath + '"'
sc.exe create $ServiceName binPath= $binPath start= auto DisplayName= "ShopDB Endpoint Agent" | Out-Null
sc.exe description $ServiceName "Collects endpoint telemetry for ShopDB and runs allowlisted WebUI commands." | Out-Null
sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/5000 | Out-Null
Start-Service -Name $ServiceName
Write-Host " service started."
# 5. Register the interactive tray app to launch at user logon (per-machine).
Write-Host "[5/5] Registering tray app for logon ..."
if ($trayInstalled -and (Test-Path $trayInstalled)) {
# Register in the 64-bit Run key so 64-bit Explorer fires it at logon. A
# 32-bit write would land in WOW6432Node\...\Run, which Explorer ignores.
Set-Hklm64Value -SubKey "SOFTWARE\Microsoft\Windows\CurrentVersion\Run" `
-Name "ShopDbEndpointTray" -Value ('"' + $trayInstalled + '"')
Write-Host " tray app registered to start at logon (Registry64): $trayInstalled"
# Start it now so the icon appears without waiting for the next logon. The
# Run key only fires at logon, so a fresh install would otherwise show no
# tray until the operator signs out and back in.
#
# The installer runs elevated, so a direct Start-Process would launch the
# tray ELEVATED too. That is wrong for a tray app (it should run at the
# user's medium integrity) and, on EPM-managed hosts (CyberArk), elevating an
# unsigned exe triggers a broker prompt that comes back as Win32 1223
# "The operation was canceled by the user" - which is exactly what we hit.
# Launching through explorer.exe hands the exe to the already-running user
# shell, which spawns it de-elevated in the interactive session, sidestepping
# the elevation broker entirely.
$interactiveUser = $env:USERNAME
if ($interactiveUser -and $interactiveUser -ne "SYSTEM") {
try {
# explorer.exe <path> re-parents the launch under the user shell at
# medium integrity; it returns immediately regardless of the tray's
# own lifetime, so there is nothing to wait on here.
Start-Process -FilePath "explorer.exe" -ArgumentList "`"$trayInstalled`"" | Out-Null
Write-Host " tray app started for the current session (de-elevated via explorer)."
}
catch {
Write-Warning " could not start tray now: $($_.Exception.Message); it will start at next logon."
}
}
else {
Write-Host " no interactive user session; tray will start at next logon."
}
}
else {
Write-Host " tray skipped (headless install)."
}
Write-Host "`nInstall complete." -ForegroundColor Green
Write-Host "Service '$ServiceName' is running and set to auto-start."
Write-Host "Logs: $logDir"

View File

@@ -4,6 +4,7 @@
"backups", "backups",
"computers", "computers",
"employees", "employees",
"endpoints",
"geenforce", "geenforce",
"guides", "guides",
"knowledgebase", "knowledgebase",

View File

@@ -61,6 +61,8 @@ services:
LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_LEVEL: ${LOG_LEVEL:-INFO}
ZABBIX_URL: ${ZABBIX_URL:-} ZABBIX_URL: ${ZABBIX_URL:-}
ZABBIX_TOKEN: ${ZABBIX_TOKEN:-} ZABBIX_TOKEN: ${ZABBIX_TOKEN:-}
COLLECTOR_API_KEY: ${COLLECTOR_API_KEY:-}
COLLECTOR_API_KEY_ENDPOINTS: ${COLLECTOR_API_KEY_ENDPOINTS:-}
ports: ports:
- "${API_PORT:-5001}:5001" - "${API_PORT:-5001}:5001"
volumes: volumes:

View File

@@ -41,6 +41,9 @@ Recorded in the comment block in `shopdb/__init__.py`:
| 0.19.0 | **BREAKING.** `get_dashboard_widgets` returns DATA AND SHAPE, not a component name. The old shape (`name` + `component` + `size`) named a Vue component per widget, which cannot survive a lean build - a plugin's component may never be staged into the frontend bundle (ADR-013) - and five plugins were declaring widgets that pointed at components nobody had written. A card now declares `id` / `title` / `endpoint` / `render` / `severity` / `permission` / `empty` / `position`; see PLUGIN-HOOKS.md. Also added `send_upload` so a plugin serving user-supplied bytes gets the headers that keep an SVG from running as script | **contract change (minor, pre-1.0)** | | 0.19.0 | **BREAKING.** `get_dashboard_widgets` returns DATA AND SHAPE, not a component name. The old shape (`name` + `component` + `size`) named a Vue component per widget, which cannot survive a lean build - a plugin's component may never be staged into the frontend bundle (ADR-013) - and five plugins were declaring widgets that pointed at components nobody had written. A card now declares `id` / `title` / `endpoint` / `render` / `severity` / `permission` / `empty` / `position`; see PLUGIN-HOOKS.md. Also added `send_upload` so a plugin serving user-supplied bytes gets the headers that keep an SVG from running as script | **contract change (minor, pre-1.0)** |
| 0.20.0 | The `Asset` model gained `levelid` (which floor plan its `mapx`/`mapy` are pixels of) and `mapverifiedat`; `Location` gained `levelid` too, and `resolve_asset_position` now returns the `levelid` belonging to whichever source supplied the coordinates. A plugin that writes a map position MUST write the level with it - a position without one renders as "level unknown" rather than being drawn on the default drawing, because a marker on the wrong floor plan looks correct (ADR-017) | additive surface (minor) | | 0.20.0 | The `Asset` model gained `levelid` (which floor plan its `mapx`/`mapy` are pixels of) and `mapverifiedat`; `Location` gained `levelid` too, and `resolve_asset_position` now returns the `levelid` belonging to whichever source supplied the coordinates. A plugin that writes a map position MUST write the level with it - a position without one renders as "level unknown" rather than being drawn on the default drawing, because a marker on the wrong floor plan looks correct (ADR-017) | additive surface (minor) |
| 0.21.0 | Added the `get_guest_grantable_permissions` hook: a plugin names the WRITE permissions a site may grant to the seeded `guest` role, which is who anyone without a token now holds. Reads (`.view`/`.read`) are grantable without a plugin saying anything; anything else is refused. Also exposed `require_any_permission` on `shopdb.api` for a read two different rights should reach | additive optional hook + surface (minor) | | 0.21.0 | Added the `get_guest_grantable_permissions` hook: a plugin names the WRITE permissions a site may grant to the seeded `guest` role, which is who anyone without a token now holds. Reads (`.view`/`.read`) are grantable without a plugin saying anything; anything else is refused. Also exposed `require_any_permission` on `shopdb.api` for a read two different rights should reach | additive optional hook + surface (minor) |
| 0.22.0 | Added the `register_import_hook` surface: a plugin owns a VIRTUAL COLUMN on an importable CSV table - a header the bulk importer accepts but never writes - plus a post-write row hook that turns the value into a plugin concept. The computers plugin uses it for `machineassetnumber` on `computers.csv`, which builds the PC->machine controls link, so a site writes the machine number in the PC's own row instead of hand-building a separate relationships sheet. Core never learns what the column means. `RowWarning` is exported with it so a hook can report a soft failure without aborting the import | additive surface (minor) |
| 0.23.0 | Added `mint_service_token`, `list_service_tokens` and `revoke_service_tokens` so a plugin can provision, list and revoke a single-scope managed service token without importing core token internals. The endpoints plugin uses them for a UI-managed, regenerable agent key scoped to `collector.ingest.endpoints` alone - narrow on purpose, so rotating or revoking it cannot disturb the computers or GE-Enforce collectors, and revocation is an instant kill switch on agent ingest | additive surface (minor) |
| 0.24.0 | Added `collector_authorized(pluginname)` so a plugin can gate an agent-facing route that lives OUTSIDE the generic collector blueprint on the same per-plugin key and kill switch the ingest route uses. The endpoints command channel needs it: the agent long-polls `/api/endpoints/commands/next` and posts results back, and those are agent callbacks rather than JWT routes. Without it a plugin would reach into core collector internals to authorize them | additive surface (minor) |
The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0, The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0,
0.11.0, 0.16.0, 0.18.0 and 0.19.0; 0.12.0 through 0.15.0 and 0.17.0 are recorded 0.11.0, 0.16.0, 0.18.0 and 0.19.0; 0.12.0 through 0.15.0 and 0.17.0 are recorded

View File

@@ -12,7 +12,7 @@ never by editing this file.
| series | value | governed by | | series | value | governed by |
|---|---|---| |---|---|---|
| product `__version__` | `0.12.0` | ADR-007 | | product `__version__` | `0.12.0` | ADR-007 |
| plugin contract `__contract_version__` | `0.21.0` | ADR-002 | | plugin contract `__contract_version__` | `0.24.0` | ADR-002 |
They move independently. A contract bump is not a release. They move independently. A contract bump is not a release.
@@ -25,8 +25,9 @@ with `flask plugin upgrade-all`. Both are needed on a deploy.
|---|---| |---|---|
| core | `7d34_singular_relationship_types` | | core | `7d34_singular_relationship_types` |
| backups | `backups0003clearlastseen` | | backups | `backups0003clearlastseen` |
| computers | `computers0001anchor` | | computers | `computers0004policies` |
| employees | `employees0002photo` | | employees | `employees0002photo` |
| endpoints | `endpoints0003cmdtarget` |
| geenforce | `geenforce0004minlib` | | geenforce | `geenforce0004minlib` |
| guides | `guides0001baseline` | | guides | `guides0001baseline` |
| knowledgebase | `knowledgebase0001anchor` | | knowledgebase | `knowledgebase0001anchor` |
@@ -41,13 +42,14 @@ with `flask plugin upgrade-all`. Both are needed on a deploy.
| usb | `usb0002dropmachineid` | | usb | `usb0002dropmachineid` |
| warranty | `warranty0002proof` | | warranty | `warranty0002proof` |
## Bundled plugins (16) ## Bundled plugins (17)
| plugin | version | core_version | owns migrations | | plugin | version | core_version | owns migrations |
|---|---|---|---| |---|---|---|---|
| backups | 1.0.0 | >=0.16.0,<1.0.0 | yes | | backups | 1.0.0 | >=0.16.0,<1.0.0 | yes |
| computers | 1.0.0 | >=0.20.0,<1.0.0 | yes | | computers | 1.0.0 | >=0.20.0,<1.0.0 | yes |
| employees | 1.0.0 | >=0.1.0,<1.0.0 | yes | | employees | 1.0.0 | >=0.1.0,<1.0.0 | yes |
| endpoints | 0.1.0 | >=0.20.0,<1.0.0 | yes |
| geenforce | 0.1.0 | >=0.18.0,<1.0.0 | yes | | geenforce | 0.1.0 | >=0.18.0,<1.0.0 | yes |
| guides | 1.0.0 | >=0.1.0,<1.0.0 | yes | | guides | 1.0.0 | >=0.1.0,<1.0.0 | yes |
| knowledgebase | 1.0.0 | >=0.1.0,<1.0.0 | yes | | knowledgebase | 1.0.0 | >=0.1.0,<1.0.0 | yes |
@@ -88,6 +90,6 @@ Manifest-less directories under `plugins/` are core frontend surface and always
## Size ## Size
- test functions defined: **1418** (parametrised cases collect higher) - test functions defined: **1448** (parametrised cases collect higher)
- documented API paths: **305** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`) - documented API paths: **331** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)

View File

@@ -273,6 +273,38 @@
"params": "path: filename", "params": "path: filename",
"purpose": "Serve an uploaded branding logo (public - kiosks and print pages read it)", "purpose": "Serve an uploaded branding logo (public - kiosks and print pages read it)",
"example": "curl -O http://localhost:5001/api/settings/branding/logo-site.svg" "example": "curl -O http://localhost:5001/api/settings/branding/logo-site.svg"
},
{
"method": "GET",
"path": "/api/settings/asset-import/script",
"auth": "admin role",
"params": "none",
"purpose": "Download Collect-ShopDB.ps1 with this site CSV path already embedded, so the collector script needs no hand editing before it runs",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/settings/asset-import/script -o Collect-ShopDB.ps1"
},
{
"method": "GET",
"path": "/api/settings/asset-import/templates",
"auth": "admin role",
"params": "none",
"purpose": "List the importable CSV tables in the order they must be uploaded - foreign keys mean the order is not optional",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/settings/asset-import/templates"
},
{
"method": "GET",
"path": "/api/settings/asset-import/templates/<tablename>",
"auth": "admin role",
"params": "path: tablename",
"purpose": "Download a generated CSV template for one importable table, with its real column names as the header row",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/settings/asset-import/templates/assets -o assets.csv"
},
{
"method": "POST",
"path": "/api/settings/asset-import/import",
"auth": "admin role",
"params": "multipart: one file per table; form: commit (false = validate only)",
"purpose": "Validate uploaded CSVs and, only when commit=true, load them. The default is a dry run that reports what WOULD change; a committing run writes an AuditLog row with the reconciled count",
"example": "curl -X POST -H \"Authorization: Bearer $TOKEN\" -F \"assets=@assets.csv\" -F \"commit=true\" http://localhost:5001/api/settings/asset-import/import"
} }
] ]
}, },
@@ -1729,6 +1761,102 @@
"params": "none", "params": "none",
"purpose": "The collector reporter (Report-AssetToShopDB.ps1) stamped with THIS site's values: the site base URL becomes the -ApiUrl default and computers_routableranges becomes -AllowedRanges, so it downloads ready to deploy. Only the parameter DEFAULTS are substituted - the copy in plugins/computers/client/ stays runnable, so there is no second version to drift - and everything stamped stays overridable by argument or registry. The collector key is deliberately NOT included: this file lands on every shop-floor PC, and a token spread across hundreds of bays cannot be rotated quietly. Serves text/plain as an attachment, with the SHA-256 in X-Script-Sha256 so a deployment can verify what it fetched", "purpose": "The collector reporter (Report-AssetToShopDB.ps1) stamped with THIS site's values: the site base URL becomes the -ApiUrl default and computers_routableranges becomes -AllowedRanges, so it downloads ready to deploy. Only the parameter DEFAULTS are substituted - the copy in plugins/computers/client/ stays runnable, so there is no second version to drift - and everything stamped stays overridable by argument or registry. The collector key is deliberately NOT included: this file lands on every shop-floor PC, and a token spread across hundreds of bays cannot be rotated quietly. Serves text/plain as an attachment, with the SHA-256 in X-Script-Sha256 so a deployment can verify what it fetched",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/client-script" "example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/client-script"
},
{
"method": "GET",
"path": "/api/computers/policies/configuration",
"auth": "computers.view",
"params": "none",
"purpose": "List Configuration Policies (scope + the actions applied to matching endpoints); 404 while the computers_configpolicies_enabled setting is off",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/policies/configuration"
},
{
"method": "GET",
"path": "/api/computers/policies/configuration/<policy_id>",
"auth": "computers.view",
"params": "path: policy_id",
"purpose": "One Configuration Policy with its actions expanded; 404 while the computers_configpolicies_enabled setting is off",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/policies/configuration/1policy_id"
},
{
"method": "POST",
"path": "/api/computers/policies/configuration",
"auth": "computers.create",
"params": "body: name (required), description, scopejson (scope AST), actions[] of {action, mode: desired|gate, params}",
"purpose": "Create a Configuration Policy. Scope trees go through validate_scope and every action name is checked against the endpoints command allowlist, so a bad tree or unknown action is rejected before it reaches the database; 404 while the computers_configpolicies_enabled setting is off",
"example": "curl -X POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"name\":\"Shopfloor MTConnect\",\"actions\":[{\"action\":\"mtconnect_start\",\"mode\":\"desired\"}]}' http://localhost:5001/api/computers/policies/configuration"
},
{
"method": "PUT",
"path": "/api/computers/policies/configuration/<policy_id>",
"auth": "computers.edit",
"params": "path: policy_id; body: any of name, description, scopejson, actions[] (actions replace wholesale)",
"purpose": "Update a Configuration Policy; same scope and action validation as create; 404 while the computers_configpolicies_enabled setting is off",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/policies/configuration/1policy_id"
},
{
"method": "DELETE",
"path": "/api/computers/policies/configuration/<policy_id>",
"auth": "computers.delete",
"params": "path: policy_id",
"purpose": "Delete a Configuration Policy and its actions; 404 while the computers_configpolicies_enabled setting is off",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/policies/configuration/1policy_id"
},
{
"method": "GET",
"path": "/api/computers/policies/business",
"auth": "computers.view",
"params": "none",
"purpose": "List Business Policies (ordered bundles of Configuration Policies); 404 while the computers_configpolicies_enabled setting is off",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/policies/business"
},
{
"method": "GET",
"path": "/api/computers/policies/business/<policy_id>",
"auth": "computers.view",
"params": "path: policy_id",
"purpose": "One Business Policy with its member Configuration Policies in sort order; 404 while the computers_configpolicies_enabled setting is off",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/policies/business/1policy_id"
},
{
"method": "POST",
"path": "/api/computers/policies/business",
"auth": "computers.create",
"params": "body: name (required), description, priority, items[] of {configurationpolicyid, sortorder}",
"purpose": "Create a Business Policy. Every referenced Configuration Policy must exist; priority decides which policy wins when two match the same endpoint; 404 while the computers_configpolicies_enabled setting is off",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/policies/business"
},
{
"method": "PUT",
"path": "/api/computers/policies/business/<policy_id>",
"auth": "computers.edit",
"params": "path: policy_id; body: any of name, description, priority, items[] (items replace wholesale)",
"purpose": "Update a Business Policy or reorder its members; 404 while the computers_configpolicies_enabled setting is off",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/policies/business/1policy_id"
},
{
"method": "DELETE",
"path": "/api/computers/policies/business/<policy_id>",
"auth": "computers.delete",
"params": "path: policy_id",
"purpose": "Delete a Business Policy and its item rows; 404 while the computers_configpolicies_enabled setting is off",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/policies/business/1policy_id"
},
{
"method": "GET",
"path": "/api/computers/policies/actions",
"auth": "computers.view",
"params": "none",
"purpose": "The allowlisted action names and display labels a policy may reference, read from the endpoints command allowlist. The UI builds its action picker from this rather than hardcoding a list that would drift; 404 while the computers_configpolicies_enabled setting is off",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/policies/actions"
},
{
"method": "GET",
"path": "/api/computers/policies/effective/<asset_id>",
"auth": "computers.view",
"params": "path: asset_id",
"purpose": "Preview the resolved effective policy for one endpoint: which Business Policy won, which Configuration Policies matched, and the resulting desired/gate actions. Read-only, for debugging a scope that is not matching what its author expected; 404 while the computers_configpolicies_enabled setting is off",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/policies/effective/1asset_id"
} }
], ],
"surface": "plugin-computers" "surface": "plugin-computers"
@@ -3623,5 +3751,154 @@
"example": "curl http://localhost:5001/api/guides/images/5 -H \"Authorization: Bearer $TOK\" --output shot.png" "example": "curl http://localhost:5001/api/guides/images/5 -H \"Authorization: Bearer $TOK\" --output shot.png"
} }
] ]
},
{
"surface": "plugin-endpoints",
"endpoints": [
{
"method": "GET",
"path": "/api/endpoints/",
"auth": "endpoints.view",
"params": "query: search (hostname substring)",
"purpose": "List endpoints reporting through the ShopDB agent, with last-seen and online state",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/"
},
{
"method": "GET",
"path": "/api/endpoints/<asset_id>",
"auth": "endpoints.view",
"params": "path: asset_id",
"purpose": "One endpoint: its agent state, version, and last check-in",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/1asset_id"
},
{
"method": "GET",
"path": "/api/endpoints/<asset_id>/events",
"auth": "endpoints.view",
"params": "path: asset_id; query: eventid (page from this id)",
"purpose": "Agent event history for one endpoint, newest first",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/1asset_id/events"
},
{
"method": "GET",
"path": "/api/endpoints/asset/<asset_id>/status",
"auth": "endpoints.view",
"params": "path: asset_id",
"purpose": "Asset-detail panel: online state, uptime, and agent version for one endpoint",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1asset_id/status"
},
{
"method": "GET",
"path": "/api/endpoints/asset/<asset_id>/telemetrymeta",
"auth": "endpoints.view",
"params": "path: asset_id",
"purpose": "Asset-detail panel: what telemetry this endpoint reports and when each series was last updated",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1asset_id/telemetrymeta"
},
{
"method": "GET",
"path": "/api/endpoints/asset/<asset_id>/services",
"auth": "endpoints.view",
"params": "path: asset_id",
"purpose": "Asset-detail panel: Windows services the agent watches on this endpoint, with running state",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1asset_id/services"
},
{
"method": "GET",
"path": "/api/endpoints/asset/<asset_id>/recentevents",
"auth": "endpoints.view",
"params": "path: asset_id",
"purpose": "Asset-detail panel: the most recent agent events for one endpoint",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1asset_id/recentevents"
},
{
"method": "GET",
"path": "/api/endpoints/asset/<asset_id>/interfaces",
"auth": "endpoints.view",
"params": "path: asset_id",
"purpose": "Asset-detail panel: network interfaces as the collector reports them ({alias, ipv4, mac, gateway, subnet, dhcp})",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1asset_id/interfaces"
},
{
"method": "GET",
"path": "/api/endpoints/dashboard/summary",
"auth": "endpoints.view",
"params": "none",
"purpose": "Dashboard card: endpoint counts by online/offline/never-seen",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/dashboard/summary"
},
{
"method": "GET",
"path": "/api/endpoints/dashboard/quiet",
"auth": "endpoints.view",
"params": "none",
"purpose": "Dashboard card: endpoints that have gone quiet - no check-in inside the configured quiet-hours window",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/dashboard/quiet"
},
{
"method": "GET",
"path": "/api/endpoints/dashboard/stability",
"auth": "endpoints.view",
"params": "none",
"purpose": "Dashboard card: endpoints ranked by reboot/agent-restart churn, to surface a machine that is flapping",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/dashboard/stability"
},
{
"method": "GET",
"path": "/api/endpoints/agentkey",
"auth": "admin role",
"params": "none",
"purpose": "Status of the endpoints agent key: whether one exists, when it was issued, and its last use. Never returns the key itself",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/agentkey"
},
{
"method": "POST",
"path": "/api/endpoints/agentkey/regenerate",
"auth": "admin role",
"params": "none",
"purpose": "Issue a new endpoints agent key and return it ONCE. The key is scoped to collector.ingest.endpoints only, so rotating it cannot disturb the computers or GE-Enforce collectors. Every agent must be reconfigured with the new value",
"example": "curl -X POST -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/agentkey/regenerate"
},
{
"method": "DELETE",
"path": "/api/endpoints/agentkey",
"auth": "admin role",
"params": "none",
"purpose": "Revoke the endpoints agent key. The kill switch for agent ingest: reports and command polls stop being authorized immediately",
"example": "curl -X DELETE -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/agentkey"
},
{
"method": "GET",
"path": "/api/endpoints/<asset_id>/commands",
"auth": "endpoints.view",
"params": "path: asset_id",
"purpose": "Command history for one endpoint: what was queued, claimed, and what it returned",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/1asset_id/commands"
},
{
"method": "POST",
"path": "/api/endpoints/<asset_id>/commands",
"auth": "endpoints.command",
"params": "path: asset_id; body: action (required, allowlisted), target, targetkind",
"purpose": "Queue one allowlisted command for an endpoint. Unknown actions are rejected, destructive ones (system_restart) demand a stricter permission, the effective policy must permit the action for this endpoint, and a second command will not stack while one is pending or running",
"example": "curl -X POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"action\":\"service_restart\",\"target\":\"MTConnectAgent\",\"targetkind\":\"service\"}' http://localhost:5001/api/endpoints/1/commands"
},
{
"method": "GET",
"path": "/api/endpoints/commands/next",
"auth": "collector token (collector.ingest.endpoints)",
"params": "query: hostname (required)",
"purpose": "Agent long-poll. Claims the oldest pending command for the calling host and atomically marks it running so no second poll claims it; 204 when nothing is queued inside the hold window, and the agent re-polls. Collector-token auth, not JWT",
"example": "curl -H \"X-Collector-Key: $KEY\" \"http://localhost:5001/api/endpoints/commands/next?hostname=WJSF-PC-01\""
},
{
"method": "POST",
"path": "/api/endpoints/commands/<command_id>/result",
"auth": "collector token (collector.ingest.endpoints)",
"params": "path: command_id; body: the command outcome (status, output)",
"purpose": "Agent reports a claimed command result. Finalizes the command row and, for MTConnect actions that observed run state, caches that on the EndpointState so the UI toggle reflects reality between polls - both in one commit. Collector-token auth, not JWT",
"example": "curl -X POST -H \"X-Collector-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"status\":\"succeeded\",\"output\":\"ok\"}' http://localhost:5001/api/endpoints/commands/1/result"
}
]
} }
] ]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,136 @@
# Frontend Development Standards
## Naming Convention (LOCKED)
The shopdb-flask naming convention lives in the root `CONTRIBUTING.md`. Read it before naming any variable, component, API param, or CSS class.
Frontend-specific reminders pulled from the convention:
- Variables holding API field values: match the API field name exactly. Do NOT convert to camelCase. (`response.machineid`, NOT `response.machineId`)
- Pure JS variables: camelCase (`currentUser`, `isLoading`)
- Vue components: PascalCase, spelled out (`AssetDetail.vue`, `MachineForm.vue`)
- CSS classes: lowercase with dashes (`asset-detail`, `machine-form`)
- API params sent to backend: match DB column names without underscores (`params.locationid = 5`, NOT `params.location_id`)
- No emojis, em-dashes, smart quotes, or Unicode arrows anywhere. Plain ASCII only.
- Banned shorthand as standalone variables: `cfg`, `ctx`, `mgr`, `req`, `res`, `env`, `util`, `helper`. Spell them out (`canvasContext`, `response`, `manager`, etc.). Suffix usage like `printers_bp` is allowed.
Pre-commit hook at `scripts/check-naming-and-style.sh` enforces these rules.
## CSS Styling Standards
### Use CSS Variables for ALL Colors
**NEVER hardcode colors in component styles.** Always use CSS variables defined in `src/assets/style.css`.
Available variables:
```css
--primary /* Primary brand color */
--primary-dark /* Darker primary for hover states */
--secondary /* Secondary/muted color */
--success /* Success states (green) */
--warning /* Warning states (orange) */
--danger /* Error/danger states (red) */
--bg /* Page background */
--bg-card /* Card/panel background */
--text /* Primary text color */
--text-light /* Secondary/muted text */
--border /* Border color */
--link /* Link color (bright blue in dark mode) */
```
**Bad:**
```css
.my-card {
background: white;
color: #1a1a1a;
}
```
**Good:**
```css
.my-card {
background: var(--bg-card);
color: var(--text);
}
```
### Detail Pages - Use Global Styles
All detail pages (MachineDetail, PCDetail, PrinterDetail, ApplicationDetail) should use the **unified global styles** from `style.css`:
- `.detail-page` - Container wrapper
- `.hero-card` - Main hero section with image and info
- `.hero-image`, `.hero-content`, `.hero-title`, `.hero-meta`, `.hero-details`
- `.section-card` - Info sections
- `.section-title` - Section headers
- `.info-list`, `.info-row`, `.info-label`, `.info-value`
- `.content-grid`, `.content-column` - Two-column layout
- `.audit-footer` - Created/modified timestamps
**Only add scoped styles for page-specific elements** (e.g., supplies grid for printers, version list for applications).
### PrinterDetail.vue is the Master Template for Detail Pages
Use `PrinterDetail.vue` as the reference for new detail pages. Follow its structure and styling patterns.
### List Pages - Use Global Styles
All list pages should use the **unified global styles** from `style.css`:
- `.page-header` - Header with title and action button
- `.filters` - Search and filter controls
- `.card` - Main content container
- `.table-container` - Scrollable table wrapper
- `table`, `th`, `td` - Table styling
- `.pagination` - Page navigation
- `.badge`, `.badge-success`, etc. - Status badges
- `.actions` - Action button column
**PrintersList.vue is the Master Template for List Pages**
Use `PrintersList.vue` as the reference for new list pages. It has NO scoped styles - everything uses global CSS.
**Only add scoped styles for page-specific elements** (e.g., icon cells for applications, stats badge for knowledge base).
### Dark Mode Support
Dark mode is automatic via `@media (prefers-color-scheme: dark)`. Using CSS variables ensures colors adapt automatically - no extra work needed per page.
## Data Entry: Page or Modal
Both patterns are in use on purpose. Pick by what the record IS, not by how big the form feels today.
**Routed form page** (`views/<Thing>Form.vue` + a route) when the record has its own detail page and its own URL: machines, PCs, printers, network devices, measuring tools, USB devices, applications, knowledge-base articles, notifications, printed items. These forms are long, often carry an image upload, a map-position picker or relationship editing, and someone will want to link straight to one.
**Modal over its list** when the record is reference data that only exists inside the list it belongs to: every `*TypesList`, plus locations, vendors, models, VLANs, subnets, support teams, custom fields, API tokens, printer drivers, dashboard defaults. The form is a handful of fields, and keeping the list visible behind the dialog is the point.
Rule of thumb: **a thing with a detail page gets a form page; a lookup row gets a modal.**
Modal rules (from the overlay-close fix, commit d8fe0a4):
- A modal holding typed input must NOT close on overlay click or Escape. Losing a part-filled form to a stray click is not an acceptable failure.
- A confirmation dialog MAY close on overlay click (`@click.self`), since it holds nothing to lose.
- The panel background is `var(--bg-card-solid)`, never `var(--bg-card)`: the card variable is translucent in dark mode, which leaves a dialog see-through over the overlay. The shared `.modal` in `style.css` already does this; hand-rolled `.modal-panel` rules must too.
## Component Organization
- **Global styles**: `src/assets/style.css`
- **Page-specific styles**: Scoped `<style scoped>` block, only for unique elements
- **Font sizes**: Use `rem` units, base is 18px for readability on 1080p
## File Structure
```
src/
assets/
style.css # Global styles, CSS variables, detail page styles
views/
machines/
MachineDetail.vue # Uses global styles only
pcs/
PCDetail.vue # Global + PC-specific (app-list, etc.)
printers/
PrinterDetail.vue # Global + printer-specific (supplies-grid)
applications/
ApplicationDetail.vue # Global + app-specific (version-list, pc-list)
```

View File

@@ -1,12 +1,12 @@
{ {
"name": "shopdb-frontend", "name": "shopdb-frontend",
"version": "0.7.0", "version": "0.12.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "shopdb-frontend", "name": "shopdb-frontend",
"version": "0.7.0", "version": "0.12.0",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@fullcalendar/core": "^6.1.20", "@fullcalendar/core": "^6.1.20",

View File

@@ -146,6 +146,27 @@ export const machinesApi = {
} }
} }
// Endpoints API (plugin) - fleet telemetry read surface
export const endpointsApi = {
list(params = {}) {
return api.get('/endpoints', { params })
},
get(assetId) {
return api.get(`/endpoints/${assetId}`)
},
enqueueCommand(assetId, action, target = null) {
const body = { action }
if (target) body.target = target
return api.post(`/endpoints/${assetId}/commands`, body)
},
listCommands(assetId) {
return api.get(`/endpoints/${assetId}/commands`)
},
telemetryMeta(assetId) {
return api.get(`/endpoints/asset/${assetId}/telemetrymeta`)
}
}
// Computers API (plugin) // Computers API (plugin)
export const computersApi = { export const computersApi = {
list(params = {}) { list(params = {}) {
@@ -187,6 +208,11 @@ export const computersApi = {
list(params = {}) { list(params = {}) {
return api.get('/computers/types', { params }) return api.get('/computers/types', { params })
}, },
// Every type, for a dropdown. perpage is capped at 100 server-side, so
// asking for 200 returns 100 and silently truncates the list.
listAll(params = {}) {
return fetchAllPages('/computers/types', params)
},
get(id) { get(id) {
return api.get(`/computers/types/${id}`) return api.get(`/computers/types/${id}`)
}, },
@@ -214,6 +240,50 @@ export const computersApi = {
remove(id) { remove(id) {
return api.delete(`/computers/protocols/${id}`) return api.delete(`/computers/protocols/${id}`)
} }
},
// Configuration/Business policies (RMM-style). Gated server-side by the
// computers_configpolicies_enabled setting; routes return 404 while off.
policies: {
actions() {
return api.get('/computers/policies/actions')
},
effective(assetid) {
return api.get(`/computers/policies/effective/${assetid}`)
},
configuration: {
list() {
return api.get('/computers/policies/configuration')
},
get(id) {
return api.get(`/computers/policies/configuration/${id}`)
},
create(data) {
return api.post('/computers/policies/configuration', data)
},
update(id, data) {
return api.put(`/computers/policies/configuration/${id}`, data)
},
remove(id) {
return api.delete(`/computers/policies/configuration/${id}`)
}
},
business: {
list() {
return api.get('/computers/policies/business')
},
get(id) {
return api.get(`/computers/policies/business/${id}`)
},
create(data) {
return api.post('/computers/policies/business', data)
},
update(id, data) {
return api.put(`/computers/policies/business/${id}`, data)
},
remove(id) {
return api.delete(`/computers/policies/business/${id}`)
}
}
} }
} }
@@ -273,6 +343,12 @@ export const locationsApi = {
list(params = {}) { list(params = {}) {
return api.get('/locations', { params }) return api.get('/locations', { params })
}, },
// Every location, paged past the backend's 100-row cap. Location selector
// dropdowns must use this: list() with a large perpage is clamped to 100 and
// still returns a success response, so the tail simply goes missing.
listAll(params = {}) {
return fetchAllPages('/locations', params)
},
get(id) { get(id) {
return api.get(`/locations/${id}`) return api.get(`/locations/${id}`)
}, },
@@ -1019,6 +1095,42 @@ export const settingsApi = {
form.append('kind', kind) form.append('kind', kind)
form.append('file', file) form.append('file', file)
return api.post('/settings/branding-logo', form, { headers: { 'Content-Type': 'multipart/form-data' } }) return api.post('/settings/branding-logo', form, { headers: { 'Content-Type': 'multipart/form-data' } })
},
downloadCollectorScript() {
// Collect-ShopDB.ps1 with this site's CSV path baked into $NetworkStoreRoot
return api.get('/settings/asset-import/script', { responseType: 'blob' })
},
listImportTemplates() {
// Importable CSV tables in the order they must be uploaded
return api.get('/settings/asset-import/templates')
},
downloadImportTemplate(tablename) {
// Generated CSV template (header + example row) for one table
return api.get(`/settings/asset-import/templates/${encodeURIComponent(tablename)}`, { responseType: 'blob' })
},
importAssetCsvs(files, commit = false, options = {}) {
// Multipart upload of one or more CSVs. commit=false is a dry-run preview
// that writes nothing; commit=true applies the whole set in one transaction.
// options.source picks a foreign adapter (azure, thor); default native means
// the files are already canonical per-table CSVs. options.computertype is
// stamped onto every imported computer when a source assigns a PC type.
const form = new FormData()
for (const file of files) {
form.append('files', file)
}
form.append('commit', commit ? 'true' : 'false')
if (options.source) {
form.append('source', options.source)
}
if (options.computertype) {
form.append('computertype', options.computertype)
}
if (options.category) {
form.append('category', options.category)
}
return api.post('/settings/asset-import/import', form, {
headers: { 'Content-Type': 'multipart/form-data' }
})
} }
} }

View File

@@ -4,7 +4,30 @@
on any asset detail page, so a plugin adds detail-page UI with no Vue. --> on any asset detail page, so a plugin adds detail-page UI with no Vue. -->
<template v-for="panel in panels" :key="panel.id"> <template v-for="panel in panels" :key="panel.id">
<div v-if="panelVisible(panel)" class="section-card"> <div v-if="panelVisible(panel)" class="section-card">
<h3 class="section-title">{{ panel.title }}</h3> <!-- collapsible panels get a clickable header with a caret and, when the
panel declares a status rollup, a green/yellow/red dot for the worst
row (a stopped service or a warning/critical event). --><h3
v-if="panel.collapsible"
class="section-title pap-header"
role="button"
tabindex="0"
:aria-expanded="!isCollapsed(panel)"
@click="toggleCollapse(panel)"
@keydown.enter.prevent="toggleCollapse(panel)"
@keydown.space.prevent="toggleCollapse(panel)"
>
<span class="pap-caret" :class="{ collapsed: isCollapsed(panel) }" aria-hidden="true">&#9662;</span>
<span class="pap-header-title">{{ panel.title }}</span>
<span
v-if="rollupStatus(panel)"
class="pap-status-dot"
:style="{ backgroundColor: rollupStatus(panel).color }"
:title="rollupStatus(panel).level"
></span>
</h3>
<h3 v-else class="section-title">{{ panel.title }}</h3>
<div v-show="!panel.collapsible || !isCollapsed(panel)" class="pap-body">
<!-- list: items each with a title, optional status badge, meta lines --> <!-- list: items each with a title, optional status badge, meta lines -->
<div v-if="panel.render === 'list'" class="pap-list"> <div v-if="panel.render === 'list'" class="pap-list">
@@ -16,6 +39,13 @@
:style="colorStyle(mapBadge(panel, item).color)"> :style="colorStyle(mapBadge(panel, item).color)">
{{ mapBadge(panel, item).label }} {{ mapBadge(panel, item).label }}
</span> </span>
<button
v-if="rowAction(panel, item)"
type="button"
class="btn btn-sm pap-row-action"
:disabled="actionBusy[panel.id]"
@click="runRowAction(panel, item)"
>{{ rowAction(panel, item).toggleLabel }}</button>
</div> </div>
<div v-if="mapMeta(panel, item).length" class="pap-meta"> <div v-if="mapMeta(panel, item).length" class="pap-meta">
<span v-for="(m, mi) in mapMeta(panel, item)" :key="mi" <span v-for="(m, mi) in mapMeta(panel, item)" :key="mi"
@@ -25,6 +55,9 @@
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage"> <router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
{{ panel.manage.label || 'Manage' }} {{ panel.manage.label || 'Manage' }}
</router-link> </router-link>
<div v-if="actionError[panel.id]" class="pap-action-error">
{{ actionError[panel.id] }}
</div>
</template> </template>
<div v-else class="pap-empty"> <div v-else class="pap-empty">
<span class="muted">{{ panel.empty || 'Nothing to show.' }}</span> <span class="muted">{{ panel.empty || 'Nothing to show.' }}</span>
@@ -90,6 +123,7 @@
<span v-for="(b, bi) in badges(panel)" :key="bi" class="status-badge" <span v-for="(b, bi) in badges(panel)" :key="bi" class="status-badge"
:style="colorStyle(b.color)">{{ b.label }}</span> :style="colorStyle(b.color)">{{ b.label }}</span>
</div> </div>
</div>
</div> </div>
</template> </template>
</template> </template>
@@ -100,7 +134,8 @@ import api from '../api'
import { colorStyle } from '@/utils/colorStyle' import { colorStyle } from '@/utils/colorStyle'
import { import {
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink, toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
keyvalueFields, tableColumns, cell, badges, tabSections, keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
rowAction,
} from './pluginAssetPanels' } from './pluginAssetPanels'
const props = defineProps({ const props = defineProps({
@@ -115,6 +150,117 @@ const tabs = ref({})
function activeTab(panel) { return tabs.value[panel.id] || 0 } function activeTab(panel) { return tabs.value[panel.id] || 0 }
function setTab(panel, index) { tabs.value = { ...tabs.value, [panel.id]: index } } function setTab(panel, index) { tabs.value = { ...tabs.value, [panel.id]: index } }
// Collapsed state per panel id, kept here for the same reason as tabs: panels
// are replaced wholesale on refetch. A panel may declare collapsedDefault to
// start collapsed (for example a low-priority events card); once the user
// toggles it, their explicit choice (stored by id) wins.
const collapsed = ref({})
function isCollapsed(panel) {
const state = collapsed.value[panel.id]
if (state === undefined) return !!panel.collapsedDefault
return !!state
}
function toggleCollapse(panel) {
collapsed.value = { ...collapsed.value, [panel.id]: !isCollapsed(panel) }
}
// Per-panel action state: busy guards a double-click while an enqueue POST is
// in flight, and the error string surfaces a rejected enqueue (bad action, or
// a 409 when a command is already queued). Keyed by panel id for the same
// reason as tabs/collapsed: panels are replaced wholesale on refetch.
const actionBusy = ref({})
const actionError = ref({})
// Enqueue the inline row toggle's command (Start/Stop for an actionable
// services/processes row, e.g. MTConnect), then reload so the row reflects the
// new state. The agent long-polls, runs it, posts the result, and wakes an
// immediate telemetry re-collect, so the settled run state lands on the next
// panel refresh rather than after the full cadence.
async function runRowAction(panel, item) {
if (actionBusy.value[panel.id]) return
const info = rowAction(panel, item)
if (!info || !info.endpoint || !info.toggle) return
actionBusy.value = { ...actionBusy.value, [panel.id]: true }
actionError.value = { ...actionError.value, [panel.id]: '' }
try {
const body = { action: info.toggle }
if (info.target) body.target = info.target
// Baseline telemetry freshness before queuing so we can tell a genuinely
// new snapshot from the stale cached one after the command settles.
const baseline = await currentTelemetryStamp()
const response = await api.post(toApiPath(info.endpoint, props.assetid), body)
const commandId = response?.data?.data?.endpointcommandid
// The POST only queues the command (201). The agent runs it asynchronously,
// so poll for the terminal result and surface the agent's own message when
// it fails (e.g. a bad launch path), rather than reporting a false success.
const settled = await waitForCommandResult(commandId)
if (settled && settled.status === 'failed') {
actionError.value = {
...actionError.value,
[panel.id]: settled.resultmessage || 'The endpoint agent could not run the command.',
}
} else if (settled && settled.status === 'succeeded') {
// The command ran, but the fresh run state only lands when the agent's
// follow-up telemetry post arrives. Wait for lastreportedat to advance so
// the reloaded row reflects reality instead of the pre-command snapshot.
await waitForFreshTelemetry(baseline)
}
await load()
} catch (err) {
const message = err?.response?.data?.message || 'Command failed to queue.'
actionError.value = { ...actionError.value, [panel.id]: message }
} finally {
actionBusy.value = { ...actionBusy.value, [panel.id]: false }
}
}
// Poll the endpoint's recent commands until the given command reaches a terminal
// state (succeeded/failed) or the attempt budget runs out. Returns the command
// row or null. Uses the same commands endpoint the operator view reads.
async function waitForCommandResult(commandId, { attempts = 15, delayMs = 1000 } = {}) {
if (!commandId || !props.assetid) return null
for (let i = 0; i < attempts; i += 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
try {
const response = await api.get(`/endpoints/${props.assetid}/commands`)
const command = (response?.data?.data || []).find(
(row) => row.endpointcommandid === commandId)
if (command && (command.status === 'succeeded' || command.status === 'failed')) {
return command
}
} catch {
// Transient poll failure: keep trying until the attempt budget runs out.
}
}
return null
}
// Read the endpoint's current lastreportedat (ISO string or null) so a row
// action can baseline freshness before it forces a state change.
async function currentTelemetryStamp() {
if (!props.assetid) return null
try {
const response = await api.get(`/endpoints/asset/${props.assetid}/telemetrymeta`)
return response?.data?.data?.lastreportedat || null
} catch {
return null
}
}
// Poll lastreportedat until it advances past the baseline (proof the agent has
// posted a fresh snapshot reflecting the command) or the attempt budget runs
// out. Bounded so an idle/offline agent never hangs the row button; the caller
// reloads regardless once this returns.
async function waitForFreshTelemetry(baseline, { attempts = 15, delayMs = 1000 } = {}) {
const baseMs = baseline ? Date.parse(baseline) : 0
for (let i = 0; i < attempts; i += 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
const stamp = await currentTelemetryStamp()
if (stamp && Date.parse(stamp) > baseMs) return true
}
return false
}
// Fetch the declared panels for this asset, then each panel's data endpoint. // Fetch the declared panels for this asset, then each panel's data endpoint.
async function load() { async function load() {
panels.value = [] panels.value = []
@@ -142,6 +288,10 @@ async function load() {
} }
watch(() => props.assetid, load, { immediate: true }) watch(() => props.assetid, load, { immediate: true })
// Let a parent detail page (for example PCDetail's header Refresh button)
// trigger a panel reload without duplicating the fetch plumbing.
defineExpose({ refresh: load })
</script> </script>
<style scoped> <style scoped>
@@ -195,6 +345,22 @@ watch(() => props.assetid, load, { immediate: true })
.pap-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; } .pap-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.pap-table th, .pap-table td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); } .pap-table th, .pap-table td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); }
.pap-badges { display: flex; flex-wrap: wrap; gap: 0.5rem; } .pap-badges { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.pap-row-action { margin-left: 0.5rem; flex: 0 0 auto; }
.pap-action-error { color: var(--danger, #f5365c); font-size: 0.82rem; }
.pap-header {
display: flex; align-items: center; gap: 0.5rem;
cursor: pointer; user-select: none;
}
.pap-header:hover .pap-header-title { color: var(--primary); }
.pap-header-title { flex: 1 1 auto; }
.pap-caret {
font-size: 0.7rem; color: var(--text-light);
transition: transform 0.15s ease;
}
.pap-caret.collapsed { transform: rotate(-90deg); }
.pap-status-dot {
flex: 0 0 auto; width: 0.7rem; height: 0.7rem; border-radius: 50%;
}
.mono { font-family: monospace; } .mono { font-family: monospace; }
.muted { color: var(--text-light); } .muted { color: var(--text-light); }
</style> </style>

View File

@@ -29,10 +29,20 @@ export function humanize(key) {
export function rows(panel) { export function rows(panel) {
const data = panel._data const data = panel._data
if (Array.isArray(data)) return data let list
if (data && Array.isArray(data.rows)) return data.rows if (Array.isArray(data)) list = data
if (data && Array.isArray(data.items)) return data.items else if (data && Array.isArray(data.rows)) list = data.rows
return [] else if (data && Array.isArray(data.items)) list = data.items
else return []
// Optional panel-level row filter so two panels can share one endpoint feed
// and each show a slice of it (for example Tracked Services vs Tracked
// Processes off the same services snapshot). Absent filter = all rows.
const filter = panel.filter
if (filter && filter.key) {
const want = String(filter.equals).toLowerCase()
return list.filter((item) => String(item[filter.key]).toLowerCase() === want)
}
return list
} }
export function mapTitle(panel, item) { export function mapTitle(panel, item) {
@@ -98,6 +108,37 @@ export function badges(panel) {
return list.map((b) => ({ label: b.label, color: b.color })) return list.map((b) => ({ label: b.label, color: b.color }))
} }
// Header status rollup for a card. A panel opts in with
// status: { key, levels: { <rowvalue>: 'critical'|'warning'|'ok'|'unknown' } }.
// The card header then shows a single green/yellow/red dot reflecting the worst
// row: any critical -> red, else any warning -> yellow, else ok -> green. A
// value not in levels is treated as unknown and does not raise the level.
export const STATUS_RANK = { ok: 1, unknown: 1, warning: 2, critical: 3 }
export const STATUS_COLOR = {
ok: '#2dce89',
warning: '#ffc107',
critical: '#f5365c',
unknown: '#6c757d',
}
export function rollupStatus(panel) {
const status = panel && panel.status
if (!status || !status.key) return null
const items = rows(panel)
if (!items.length) return null
const levels = status.levels || {}
let worst = 'ok'
let sawKnown = false
for (const item of items) {
const level = levels[item[status.key]]
if (!level) continue
sawKnown = true
if (STATUS_RANK[level] > STATUS_RANK[worst]) worst = level
}
if (!sawKnown) return null
return { level: worst, color: STATUS_COLOR[worst] || STATUS_COLOR.unknown }
}
// tabs: [{ label, fields: [{label, value, mono}] }]. One section is shown at a // tabs: [{ label, fields: [{label, value, mono}] }]. One section is shown at a
// time, so a panel with 30 values stays the height of its largest section // time, so a panel with 30 values stays the height of its largest section
// instead of dragging one multicol column far past the other. // instead of dragging one multicol column far past the other.
@@ -114,6 +155,30 @@ export function tabSections(panel) {
.filter((s) => s.fields.length > 0) .filter((s) => s.fields.length > 0)
} }
// Inline row action for a list panel. A monitored services/processes row may
// carry action: { running, start, stop, status } when it has a registered
// command (e.g. MTConnect). The card renders a single toggle on that row: when
// running it offers Stop, otherwise Start. Rows with no action are badge-only.
// commandendpoint on the panel is where the enqueue POST goes.
export function rowAction(panel, item) {
const action = item && item.action
if (!action || (!action.start && !action.stop)) return null
const running = action.running === true
return {
running,
// The action name to enqueue for the primary toggle button.
toggle: running ? action.stop : action.start,
toggleLabel: running ? 'Stop' : 'Start',
// status is a non-mutating probe, always available when declared.
status: action.status || null,
// Generic actions (service_*/process_*) carry the subject to act on; fixed
// actions (mtconnect_*) leave this null.
target: action.target || null,
// The declared enqueue endpoint template; the caller substitutes assetid.
endpoint: panel.commandendpoint || null,
}
}
export function panelVisible(panel) { export function panelVisible(panel) {
if (panel.render === 'tabs') return tabSections(panel).length > 0 if (panel.render === 'tabs') return tabSections(panel).length > 0
if (panel.render === 'list') return rows(panel).length > 0 || !!panel.empty if (panel.render === 'list') return rows(panel).length > 0 || !!panel.empty

View File

@@ -1,7 +1,8 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { import {
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink, toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink,
keyvalueFields, tableColumns, badges, panelVisible, tabSections, keyvalueFields, tableColumns, badges, panelVisible, tabSections, rollupStatus,
rowAction,
} from './pluginAssetPanels' } from './pluginAssetPanels'
const warrantyPanel = { const warrantyPanel = {
@@ -71,6 +72,34 @@ describe('list render mapping (warranty)', () => {
}) })
}) })
describe('list row filtering (tracked services vs processes)', () => {
const feedPanel = {
id: 'endpoints-tracked-services', render: 'list',
filter: { key: 'kind', equals: 'service' },
map: { title: 'name' },
_data: [
{ name: 'UDC MTConnect Agent', kind: 'service' },
{ name: 'DncMain', kind: 'process' },
{ name: 'Spooler', kind: 'SERVICE' },
],
}
it('keeps only rows whose filter key matches, case-insensitively', () => {
const kept = rows(feedPanel).map((r) => r.name)
expect(kept).toEqual(['UDC MTConnect Agent', 'Spooler'])
})
it('filters the same feed to processes when equals is process', () => {
const kept = rows({ ...feedPanel, filter: { key: 'kind', equals: 'process' } })
expect(kept.map((r) => r.name)).toEqual(['DncMain'])
})
it('returns every row when no filter is declared', () => {
const kept = rows({ ...feedPanel, filter: undefined })
expect(kept).toHaveLength(3)
})
})
describe('keyvalue + table + badge', () => { describe('keyvalue + table + badge', () => {
it('keyvalue reads a fields array or a plain object', () => { it('keyvalue reads a fields array or a plain object', () => {
const fromFields = keyvalueFields({ const fromFields = keyvalueFields({
@@ -130,3 +159,72 @@ describe('tabSections', () => {
expect(panelVisible({ render: 'tabs', _data: { sections: [] } })).toBe(false) expect(panelVisible({ render: 'tabs', _data: { sections: [] } })).toBe(false)
}) })
}) })
describe('rollupStatus', () => {
const status = {
key: 'statecolor',
levels: { green: 'ok', yellow: 'warning', red: 'critical' },
}
it('returns null when the panel declares no status', () => {
expect(rollupStatus({ _data: [{ statecolor: 'green' }] })).toBeNull()
})
it('returns null when there are no rows', () => {
expect(rollupStatus({ status, _data: [] })).toBeNull()
})
it('is ok (green) when every row is ok', () => {
const result = rollupStatus({ status, _data: [{ statecolor: 'green' }, { statecolor: 'green' }] })
expect(result).toEqual({ level: 'ok', color: '#2dce89' })
})
it('is warning (yellow) when a warning is present but no critical', () => {
const result = rollupStatus({ status, _data: [{ statecolor: 'green' }, { statecolor: 'yellow' }] })
expect(result).toEqual({ level: 'warning', color: '#ffc107' })
})
it('is critical (red) when any row is stopped', () => {
const result = rollupStatus({ status, _data: [{ statecolor: 'yellow' }, { statecolor: 'red' }] })
expect(result).toEqual({ level: 'critical', color: '#f5365c' })
})
it('ignores unmapped values without raising the level', () => {
const result = rollupStatus({ status, _data: [{ statecolor: 'gray' }, { statecolor: 'green' }] })
expect(result).toEqual({ level: 'ok', color: '#2dce89' })
})
it('returns null when no row value maps to a known level', () => {
expect(rollupStatus({ status, _data: [{ statecolor: 'gray' }] })).toBeNull()
})
})
describe('rowAction (inline MTConnect toggle)', () => {
const panel = { id: 'endpoints-services', render: 'list', commandendpoint: '/api/endpoints/5/commands' }
it('returns null for rows without an action', () => {
expect(rowAction(panel, { name: 'Some Service' })).toBeNull()
expect(rowAction(panel, { name: 'x', action: { status: 'mtconnect_status' } })).toBeNull()
})
it('offers Stop when the row is running', () => {
const info = rowAction(panel, {
name: 'MTConnect',
action: { running: true, start: 'mtconnect_start', stop: 'mtconnect_stop', status: 'mtconnect_status' },
})
expect(info.running).toBe(true)
expect(info.toggle).toBe('mtconnect_stop')
expect(info.toggleLabel).toBe('Stop')
expect(info.endpoint).toBe('/api/endpoints/5/commands')
})
it('offers Start when the row is not running', () => {
const info = rowAction(panel, {
name: 'MTConnect',
action: { running: false, start: 'mtconnect_start', stop: 'mtconnect_stop', status: 'mtconnect_status' },
})
expect(info.running).toBe(false)
expect(info.toggle).toBe('mtconnect_start')
expect(info.toggleLabel).toBe('Start')
})
})

View File

@@ -150,6 +150,12 @@ export default [
component: () => import('../../views/settings/BrandingSettings.vue'), component: () => import('../../views/settings/BrandingSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true } meta: { requiresAuth: true, requiresAdmin: true }
}, },
{
path: 'settings/importsiteassets',
name: 'import-site-assets',
component: () => import('../../views/settings/ImportSiteAssets.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{ {
path: 'settings/printing', path: 'settings/printing',
name: 'printing-settings', name: 'printing-settings',

View File

@@ -113,7 +113,7 @@ import ToastHost from '../components/ToastHost.vue'
import { import {
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor, Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler, Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
Box, KeyRound, LogOut, Wrench Box, KeyRound, LogOut, Wrench, Activity
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { currentTheme, toggleTheme } from '../stores/theme' import { currentTheme, toggleTheme } from '../stores/theme'
@@ -183,6 +183,7 @@ const iconMap = {
'ruler': Ruler, 'ruler': Ruler,
'box': Box, 'box': Box,
'wrench': Wrench, 'wrench': Wrench,
'activity': Activity,
} }
// Default navigation (used as fallback if API fails) // Default navigation (used as fallback if API fails)

View File

@@ -360,8 +360,10 @@ async function loadFilterOptions(fields) {
filterOptions.value.assettypes = response.data.data || [] filterOptions.value.assettypes = response.data.data || []
} }
if (fields.includes('location') && !filterOptions.value.locations.length) { if (fields.includes('location') && !filterOptions.value.locations.length) {
const response = await locationsApi.list({ perpage: 100 }) // listAll: 100 is the server-side cap, not a generous limit, and the
filterOptions.value.locations = response.data.data || [] // location list is past it - a report filtered by a late-alphabet
// location could not be built.
filterOptions.value.locations = await locationsApi.listAll()
} }
if (fields.includes('application') && !filterOptions.value.applications.length) { if (fields.includes('application') && !filterOptions.value.applications.length) {
// listAll: 100 is the server-side cap, not a generous limit, and the // listAll: 100 is the server-side cap, not a generous limit, and the

View File

@@ -0,0 +1,966 @@
<template>
<div>
<div class="page-header">
<h1>Import Site Assets</h1>
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
</div>
<div class="card">
<p class="lead">
There are several ways to seed this facility's inventory - Machines, PCs,
Applications, and their properties - into ShopDB. You can combine them:
each source writes to the same set of importable CSVs, and a later run
fills gaps without wiping data an earlier run captured.
</p>
<div class="methods">
<div class="method is-primary">
<div class="method-head">
<span class="badge">Recommended</span>
<h3>Asset Collector PowerShell script</h3>
</div>
<p>
Deploy <code>Collect-ShopDB.ps1</code> to your existing endpoints in a
way of your choosing (logon script, scheduled task, remote execution,
or a manual run). It runs read-only, extracts each PC and any attached
eDNC/DNC machine, and extrapolates vendor, model, OS, network, and
installed-application details. Every run UPSERTs rows into the shared
CSV store below, so a site builds its inventory one PC at a time.
</p>
</div>
<div class="method">
<div class="method-head">
<h3>Existing databases</h3>
</div>
<p>
Export from an existing MES, CMDB, or asset spreadsheet into the same
CSV schema and drop the files in the collection location.
</p>
</div>
<div class="method">
<div class="method-head">
<h3>Intune / Azure exports</h3>
</div>
<p>
Map a device or application export (for example from Microsoft Graph)
onto the computers and applications CSVs, then place them alongside the
script output.
</p>
</div>
</div>
</div>
<div class="card">
<h3 class="section-title">Asset Collector Script</h3>
<p class="muted">
Download <code>Collect-ShopDB.ps1</code> and deploy it to your endpoints
(logon script, scheduled task, remote execution, or a manual run). The CSV
Collections Location you save below is baked into the script's
<code>$NetworkStoreRoot</code> variable, so every endpoint publishes into
the same store without editing PowerShell by hand.
</p>
<div v-if="loading" class="muted">Loading...</div>
<template v-else>
<label class="field">
<span>CSV Collections Location</span>
<input
v-model="location"
type="text"
placeholder="\\server.fqdn\share\folder"
/>
<small class="muted">
UNC path or folder where the script deposits its CSVs (e.g.
\\server.fqdn\share\folder). Saved here and embedded into the script
download.
</small>
</label>
<div class="actions">
<button class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving...' : 'Save Location' }}
</button>
<button class="btn btn-secondary" :disabled="downloadingScript" @click="downloadScript">
{{ downloadingScript ? 'Preparing...' : 'Download Script' }}
</button>
<span v-if="saved" class="muted">Saved.</span>
<span v-if="error" class="error">{{ error }}</span>
</div>
</template>
</div>
<div class="card">
<h3 class="section-title">Import CSVs</h3>
<p class="muted">
Select one or more filled-in CSVs. They are ordered by dependency
automatically, so you can pick the whole set at once. Preview first: it
writes nothing and reports exactly what each file would create or update,
plus any rows that need fixing. Nothing is imported until you press
Commit, and the whole set is applied together - if any row has a problem,
nothing is written.
</p>
<label class="field">
<span>Source format</span>
<div class="source-cards">
<button
v-for="s in IMPORT_SOURCES"
:key="s.key"
type="button"
:class="['source-card', { active: importSource === s.key }]"
@click="selectSource(s.key)"
>
<span class="source-card-title">{{ s.label }}</span>
<span class="source-card-desc">{{ s.description }}</span>
</button>
</div>
<small class="muted">
ShopDB native expects the canonical per-table CSVs below. Azure and
Thor accept those tools' own exports and convert them for you.
</small>
</label>
<div v-if="importSource === 'azure'" class="source-help">
<p class="muted">
An Azure device-group export lists device hostnames plus their Entra
deviceId. The device displayName is matched to an existing PC by
hostname (case-insensitive): a match updates that PC in place and
stores its deviceId, object id, and category; a device with no
matching hostname creates a new computer (assettype computer, status
In Use). It carries no model, OS, location, or user data - enrich
those later with the collector or a Thor import.
</p>
<label class="field">
<span>Assign computer type (optional)</span>
<select v-model="azureComputerType">
<option value="">No type</option>
<option v-for="t in computerTypes" :key="t.computertypeid" :value="t.computertype">
{{ t.computertype }}
</option>
<option value="__custom__">Custom (add a new type)...</option>
</select>
<small class="muted">
Stamped onto every device in this file. For example import the SHP
group with "Shopfloor PC" and the BUS group with "Inspection PC".
Picking from the list prevents typos labelling hundreds of machines.
</small>
</label>
<label v-if="azureComputerType === '__custom__'" class="field">
<span>New computer type name</span>
<div class="custom-type-row">
<input
v-model="customComputerType"
type="text"
placeholder="e.g. Inspection PC"
/>
<button
class="btn btn-secondary btn-sm"
:disabled="!customComputerType.trim() || addingType"
@click="addComputerType"
>
{{ addingType ? 'Adding...' : 'Add type' }}
</button>
</div>
<small v-if="customTypeError" class="error">{{ customTypeError }}</small>
<small v-else class="muted">
Adds the type to your settings now and selects it for this import.
</small>
</label>
<label class="field">
<span>Azure device category (optional)</span>
<input
v-model="azureDeviceCategory"
type="text"
placeholder="e.g. SFLD_DYN_Device_Group_<Site>_BUS_NC_COUNTRY:USA"
/>
<small class="muted">
The Entra group/category these devices belong to. Stored on every
imported device and shown under Entra Device Info on the PC card.
</small>
</label>
<details class="instructions">
<summary>How to export group members from Azure</summary>
<ol>
<li>Open the Microsoft Entra admin center (entra.microsoft.com).</li>
<li>Go to Groups, then open the device group you want to import.</li>
<li>Select Members in the left menu.</li>
<li>In the top command bar, click Bulk operations-&gt;Download members to export the CSV.</li>
<li>Upload that CSV here with the source set to Azure device group.</li>
</ol>
</details>
</div>
<div v-else-if="importSource === 'thor'" class="source-help">
<p class="muted">
A Thor PC-inventory export is a wide, fixed-format dump. This maps the
hostname, BIOS manufacturer, model, OS, logged-in user, last-reported
and boot times, and location into ShopDB. Each host is matched to an
existing PC by hostname (case-insensitive): a match updates that PC in
place; a host with no matching hostname creates a new computer.
Network details (MAC, switch, VLAN) and the machine relationship are
not imported in this pass.
</p>
</div>
<div class="import-controls">
<input
ref="fileInput"
type="file"
accept=".csv"
multiple
@change="onFilesPicked"
/>
<div class="actions">
<button
class="btn btn-secondary"
:disabled="!selectedFiles.length || previewing || committing"
@click="preview"
>
{{ previewing ? 'Checking...' : 'Preview (dry run)' }}
</button>
<button
class="btn btn-primary"
:disabled="!canCommit"
@click="commit"
>
{{ committing ? 'Importing...' : 'Commit Import' }}
</button>
<button
v-if="selectedFiles.length"
class="btn btn-secondary btn-sm"
:disabled="previewing || committing"
@click="clearSelection"
>
Clear
</button>
</div>
</div>
<p v-if="selectedFiles.length" class="muted">
{{ selectedFiles.length }} file(s) selected:
{{ selectedFiles.map(f => f.name).join(', ') }}
</p>
<p v-if="importError" class="error">{{ importError }}</p>
<div v-if="report" class="report">
<p v-if="report.request_error" class="error">
{{ report.request_error }}
</p>
<p
v-else
:class="['report-banner', report.committed ? 'ok' : (report.problem_count ? 'warn' : 'info')]"
>
<template v-if="report.committed">
Imported: {{ report.total_created }} created,
{{ report.total_updated }} updated.
</template>
<template v-else-if="report.problem_count">
{{ report.problem_count }} problem(s) found - nothing was written.
Fix the rows below and preview again.
</template>
<template v-else>
Looks good: {{ report.total_created }} would be created,
{{ report.total_updated }} updated. Press Commit Import to apply.
</template>
</p>
<p v-if="report.reconciled_count" class="report-banner reconcile">
{{ report.reconciled_count }} name(s) reconciled to an existing record
within the similarity threshold ({{ report.reconcile_threshold }}).
Review the matches below before committing.
</p>
<p v-if="report.warning_count" class="report-banner amber">
{{ report.warning_count }} warning(s): these rows still import, but
something needs a look - most often a machine link that could not be
made. Review them below; each says what to do.
</p>
<table v-if="report.files && report.files.length" class="report-table">
<thead>
<tr>
<th>File</th>
<th>Table</th>
<th>Created</th>
<th>Updated</th>
<th>Reconciled</th>
<th>Warnings</th>
<th>Problems</th>
</tr>
</thead>
<tbody>
<tr v-for="f in report.files" :key="f.table">
<td><code>{{ f.filename }}</code></td>
<td>{{ f.table }}</td>
<td>{{ f.created }}</td>
<td>{{ f.updated }}</td>
<td :class="{ 'has-reconcile': (f.reconciliations || []).length }">
{{ (f.reconciliations || []).length }}
</td>
<td :class="{ 'has-warnings': (f.warnings || []).length }">
{{ (f.warnings || []).length }}
</td>
<td :class="{ 'has-problems': f.problems.length }">
{{ f.problems.length }}
</td>
</tr>
</tbody>
</table>
<div v-if="allReconciliations.length" class="reconciliations">
<h4>Reconciled matches</h4>
<p class="muted">
These names did not match exactly but were close enough to bind to an
existing record. Confirm each is correct; if any is wrong, add the
exact name to the matching CSV (or lower/raise the reconciliation
threshold in Site settings) and preview again.
</p>
<ul>
<li v-for="(r, i) in allReconciliations" :key="i">
<code>{{ r.filename }}</code>
line {{ r.line }}<template v-if="r.column">, column
<code>{{ r.column }}</code></template>:
"{{ r.input }}" matched <strong>{{ r.matched }}</strong>
({{ r.table }}, {{ Math.round(r.score * 100) }}%)
</li>
</ul>
</div>
<div v-if="allWarnings.length" class="warnings">
<h4>Warnings</h4>
<p class="muted">
Each row below imported, but something could not be completed - most
often a PC's machine link. Nothing here blocks the import; fix what
you can and re-import to complete the links.
</p>
<ul>
<li v-for="(w, i) in allWarnings" :key="i">
<code>{{ w.filename }}</code>
line {{ w.line }}<template v-if="w.column">, column
<code>{{ w.column }}</code></template>:
{{ w.reason }}<template v-if="w.action"> - <em>{{ w.action }}</em></template>
</li>
</ul>
</div>
<div v-if="allProblems.length" class="problems">
<h4>Problems to fix</h4>
<ul>
<li v-for="(p, i) in allProblems" :key="i">
<code>{{ p.filename }}</code>
line {{ p.line }}<template v-if="p.column">, column
<code>{{ p.column }}</code></template>: {{ p.message }}
</li>
</ul>
</div>
<p v-if="report.skipped && report.skipped.length" class="muted warn">
Skipped (not importable): {{ report.skipped.join(', ') }}
</p>
</div>
</div>
<div class="card">
<h3 class="section-title">CSV Import Templates</h3>
<p class="muted">
These import templates are generated from the live ShopDB schema. Each
file has a header row plus a small worked example - commented rows you
can uncomment and edit. The examples are internally consistent across
every file (the same locations, vendors, and types are referenced), so
you can download the full set, keep it as a starter workbook, and fill
in your own assets right away. Import them in the order below - a table
never references something that has not been loaded yet. Foreign keys
accept the referenced row's name or its numeric id, and re-importing a
file updates matching rows instead of duplicating them.
</p>
<p v-if="templatesLoading" class="muted">Loading...</p>
<ol v-else class="templates">
<li v-for="t in templates" :key="t.table">
<div class="template-row">
<code>{{ t.filename }}</code>
<button class="btn btn-secondary btn-sm" @click="downloadTemplate(t)">
Download Template
</button>
</div>
</li>
</ol>
<p v-if="templatesError" class="error">{{ templatesError }}</p>
</div>
<div class="card">
<h3 class="section-title">PC to Machine Relationships</h3>
<p class="muted">
A PC that drives a machine (a CMM PC, a DNC/eDNC host) is linked to that
machine so the two show up connected across ShopDB. You can build that
link two ways. The Asset Collector builds it automatically: the script
reads the four-digit workstation number from the PC it runs on, reports
it, and ShopDB matches that number to a machine's
<code>assetnumber</code>.
</p>
<p class="muted">
Or build it straight from the spreadsheet: put the machine's
<code>assetnumber</code> in the <code>machineassetnumber</code> column on
<code>computers.csv</code>, and the import creates the link for you.
</p>
<p class="muted">
Either way, load your machines first (so their
<code>assetnumber</code> values exist), then the PCs. A machine number
that does not match an existing machine - or one already driven by
another PC - is reported as a warning and skipped, not guessed, so
nothing links to the wrong asset. The PC still imports; fix the number
and re-import to complete the link.
</p>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { settingsApi, computersApi } from '@/api'
import { apiError } from '../../utils/apiError'
const LOCATION_KEY = 'asset_import_csv_location'
const location = ref('')
const loading = ref(true)
const saving = ref(false)
const saved = ref(false)
const error = ref('')
const downloadingScript = ref(false)
const templates = ref([])
const templatesLoading = ref(false)
const templatesError = ref('')
const fileInput = ref(null)
const selectedFiles = ref([])
const previewing = ref(false)
const committing = ref(false)
const importError = ref('')
const report = ref(null)
// Which format the selected files are in. 'native' means the uploads are
// already canonical per-table CSVs; 'azure' and 'thor' route through a
// server-side adapter that transforms the foreign export first.
const IMPORT_SOURCES = [
{ key: 'native', label: 'ShopDB native', description: 'The canonical per-table CSVs below (assets, computers, vendors, ...).' },
{ key: 'azure', label: 'Azure / Intune device group', description: 'A device-group member export from Entra. Creates PCs by hostname and stores the deviceId.' },
{ key: 'thor', label: 'Thor PC inventory', description: 'A Thor inventory export. Maps hostname, vendor, model, OS, user, and location.' }
]
const importSource = ref('native')
// Optional computer type stamped onto every computer an Azure import creates or
// updates, so a SHP group becomes Shopfloor PCs and a BUS group Inspection PCs.
const azureComputerType = ref('')
const customComputerType = ref('')
const customTypeError = ref('')
const addingType = ref(false)
const computerTypes = ref([])
const azureDeviceCategory = ref('')
// Commit is allowed only after a clean preview of the CURRENT selection: a
// report with no problems and no request-level error that has not already been
// committed.
const canCommit = computed(() => {
if (!report.value || committing.value || previewing.value) return false
if (report.value.committed) return false
if (report.value.request_error) return false
return report.value.problem_count === 0
})
const allProblems = computed(() => {
if (!report.value?.files) return []
const out = []
for (const file of report.value.files) {
for (const problem of file.problems) {
out.push({ filename: file.filename, ...problem })
}
}
return out
})
const allWarnings = computed(() => {
if (!report.value?.files) return []
const out = []
for (const file of report.value.files) {
for (const warning of (file.warnings || [])) {
out.push({ filename: file.filename, ...warning })
}
}
return out
})
const allReconciliations = computed(() => {
if (!report.value?.files) return []
const out = []
for (const file of report.value.files) {
for (const event of (file.reconciliations || [])) {
out.push({ filename: file.filename, ...event })
}
}
return out
})
async function loadLocation() {
loading.value = true
try {
const response = await settingsApi.get(LOCATION_KEY).catch(nullOn404)
location.value = readValue(response) || ''
} catch (err) {
error.value = apiError(err, 'Could not load the CSV location.')
} finally {
loading.value = false
}
}
function nullOn404(err) {
if (err.response?.status === 404) return null
throw err
}
function readValue(response) {
if (!response) return ''
const setting = response.data?.data || response.data
return setting?.value ?? ''
}
async function save() {
saving.value = true
saved.value = false
error.value = ''
try {
await settingsApi.update(LOCATION_KEY, location.value)
saved.value = true
} catch (err) {
error.value = apiError(err, 'Save failed.')
} finally {
saving.value = false
}
}
function saveBlob(data, filename) {
const url = window.URL.createObjectURL(new Blob([data]))
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
}
async function downloadScript() {
downloadingScript.value = true
error.value = ''
try {
const response = await settingsApi.downloadCollectorScript()
saveBlob(response.data, 'Collect-ShopDB.ps1')
} catch (err) {
error.value = apiError(err, 'Script download failed.')
} finally {
downloadingScript.value = false
}
}
async function loadTemplates() {
templatesLoading.value = true
templatesError.value = ''
try {
const response = await settingsApi.listImportTemplates()
const data = response.data?.data || response.data || {}
templates.value = data.tables || []
} catch (err) {
templatesError.value = apiError(err, 'Could not list import templates.')
} finally {
templatesLoading.value = false
}
}
async function downloadTemplate(t) {
templatesError.value = ''
try {
const response = await settingsApi.downloadImportTemplate(t.table)
saveBlob(response.data, t.filename)
} catch (err) {
templatesError.value = apiError(err, 'Template download failed.')
}
}
function onFilesPicked(event) {
selectedFiles.value = Array.from(event.target.files || [])
// A new selection invalidates any previous preview so a stale clean report
// cannot be committed against different files.
report.value = null
importError.value = ''
}
function clearSelection() {
selectedFiles.value = []
report.value = null
importError.value = ''
if (fileInput.value) fileInput.value.value = ''
}
function selectSource(key) {
importSource.value = key
// Switching format invalidates a preview built against the previous adapter.
report.value = null
importError.value = ''
}
async function runImport(commit) {
importError.value = ''
try {
const options = { source: importSource.value }
if (importSource.value === 'azure') {
if (azureComputerType.value && azureComputerType.value !== '__custom__') {
options.computertype = azureComputerType.value.trim()
}
if (azureDeviceCategory.value.trim()) {
options.category = azureDeviceCategory.value.trim()
}
}
const response = await settingsApi.importAssetCsvs(selectedFiles.value, commit, options)
report.value = response.data?.data || response.data || null
} catch (err) {
importError.value = apiError(err, commit ? 'Import failed.' : 'Preview failed.')
report.value = null
}
}
async function preview() {
previewing.value = true
try {
await runImport(false)
} finally {
previewing.value = false
}
}
async function commit() {
committing.value = true
try {
await runImport(true)
} finally {
committing.value = false
}
}
async function addComputerType() {
const name = customComputerType.value.trim()
if (!name) return
customTypeError.value = ''
addingType.value = true
try {
const response = await computersApi.types.create({ computertype: name })
const created = response.data?.data || response.data
await loadComputerTypes()
// Select the freshly added type by its name.
azureComputerType.value = created?.computertype || name
customComputerType.value = ''
} catch (err) {
customTypeError.value = apiError(err, 'Could not add the computer type.')
} finally {
addingType.value = false
}
}
async function loadComputerTypes() {
try {
// listAll returns the rows themselves, already paged through.
computerTypes.value = await computersApi.types.listAll()
} catch {
computerTypes.value = []
}
}
onMounted(async () => {
await loadLocation()
await loadTemplates()
await loadComputerTypes()
})
</script>
<style scoped>
.lead {
margin-top: 0;
color: var(--text);
}
.methods {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
margin-top: 12px;
}
.method {
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
background: var(--bg);
}
.method.is-primary {
border-color: var(--primary);
}
.method-head {
display: flex;
align-items: center;
gap: 8px;
}
.method-head h3 {
margin: 0;
font-size: 1rem;
}
.method p {
margin: 8px 0 0;
color: var(--text-light);
font-size: 0.9rem;
}
.badge {
background: var(--primary);
color: #fff;
border-radius: 10px;
padding: 2px 8px;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.section-title {
margin: 0 0 8px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 640px;
margin-top: 12px;
}
.field > span {
font-weight: 600;
}
.field input[type='text'] {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
font-family: var(--font-mono, monospace);
}
.field select {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
}
.custom-type-row {
display: flex;
gap: 8px;
align-items: center;
}
.custom-type-row input {
flex: 1;
}
.source-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
margin: 4px 0;
}
.source-card {
display: flex;
flex-direction: column;
gap: 4px;
text-align: left;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
cursor: pointer;
}
.source-card:hover {
border-color: var(--primary);
}
.source-card.active {
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary) inset;
}
.source-card-title {
font-weight: 600;
}
.source-card-desc {
font-size: 0.85rem;
color: var(--text-light);
}
.source-help {
margin-top: 12px;
border-left: 3px solid var(--border);
padding-left: 12px;
}
.source-help .instructions {
margin-top: 8px;
}
.source-help .instructions summary {
cursor: pointer;
font-weight: 600;
color: var(--text);
}
.source-help .instructions ol {
margin: 8px 0 0;
padding-left: 20px;
color: var(--text-light);
font-size: 0.9rem;
}
.source-help .instructions li {
margin: 4px 0;
}
.actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.templates {
margin: 8px 0 0;
padding-left: 20px;
}
.templates li {
margin: 6px 0;
}
.template-row {
display: flex;
align-items: center;
gap: 12px;
}
.template-row code {
min-width: 200px;
}
.btn-sm {
padding: 4px 10px;
font-size: 0.8rem;
}
.muted {
color: var(--text-light);
font-size: 0.9rem;
}
.muted.warn {
color: var(--warning, #c47f00);
}
.error {
color: var(--danger);
margin-top: 8px;
}
.import-controls {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
}
.report {
margin-top: 14px;
}
.report-banner {
padding: 8px 12px;
border-radius: 6px;
font-weight: 600;
}
.report-banner.ok {
background: var(--success-bg, rgba(40, 160, 80, 0.15));
color: var(--success, #2e7d32);
}
.report-banner.info {
background: var(--info-bg, rgba(60, 120, 200, 0.15));
color: var(--text);
}
.report-banner.warn {
background: var(--warning-bg, rgba(196, 127, 0, 0.15));
color: var(--warning, #c47f00);
}
.report-table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
}
.report-table th,
.report-table td {
text-align: left;
padding: 6px 10px;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
}
.report-table td.has-problems {
color: var(--danger);
font-weight: 700;
}
.report-table td.has-reconcile {
color: var(--warning, #c47f00);
font-weight: 700;
}
.report-banner.reconcile {
background: var(--warning-bg, rgba(196, 127, 0, 0.15));
color: var(--warning, #c47f00);
}
.reconciliations {
margin-top: 12px;
}
.reconciliations h4 {
margin: 0 0 6px;
color: var(--warning, #c47f00);
}
.reconciliations ul {
margin: 6px 0 0;
padding-left: 20px;
}
.reconciliations li {
margin: 4px 0;
font-size: 0.9rem;
}
.report-table td.has-warnings {
color: var(--amber, #d18b16);
font-weight: 700;
}
.report-banner.amber {
background: var(--amber-bg, rgba(209, 139, 22, 0.15));
color: var(--amber, #d18b16);
}
.warnings {
margin-top: 12px;
}
.warnings h4 {
margin: 0 0 6px;
color: var(--amber, #d18b16);
}
.warnings ul {
margin: 6px 0 0;
padding-left: 20px;
}
.warnings li {
margin: 4px 0;
font-size: 0.9rem;
}
.warnings li em {
color: var(--text-muted, #9aa4b2);
font-style: normal;
}
.problems {
margin-top: 12px;
}
.problems h4 {
margin: 0 0 6px;
color: var(--danger);
}
.problems ul {
margin: 0;
padding-left: 20px;
}
.problems li {
margin: 4px 0;
font-size: 0.9rem;
}
</style>

View File

@@ -119,7 +119,8 @@ const LABELS = {
usb_directory_mode: 'USB Directory Mode', usb_directory_mode: 'USB Directory Mode',
site_timezone: 'Site Timezone', site_timezone: 'Site Timezone',
audit_retentiondays: 'Audit Log Retention (days)', audit_retentiondays: 'Audit Log Retention (days)',
setup_complete: 'Setup Complete' setup_complete: 'Setup Complete',
asset_import_csv_location: 'Asset Import CSV Location'
} }
function prettyLabel(key) { function prettyLabel(key) {
return LABELS[key] || key return LABELS[key] || key
@@ -228,7 +229,10 @@ async function load() {
try { try {
const response = await settingsApi.list() const response = await settingsApi.list()
const all = response.data?.data || response.data || [] const all = response.data?.data || response.data || []
items.value = all.filter(s => s.category === 'site') // The Asset Collector CSV location is managed on the dedicated Import Site
// Assets page.
const importKeys = ['asset_import_csv_location']
items.value = all.filter(s => s.category === 'site' && !importKeys.includes(s.key))
} catch (err) { } catch (err) {
console.error('Error loading site settings:', err) console.error('Error loading site settings:', err)
error.value = 'Could not load settings.' error.value = 'Could not load settings.'

View File

@@ -4,7 +4,7 @@
// Order: site identity + the reference-data catalogs users touch daily come // Order: site identity + the reference-data catalogs users touch daily come
// first, then the platform/system groups (integrations, communication, search, // first, then the platform/system groups (integrations, communication, search,
// plugins, access) cluster together at the end. // plugins, access) cluster together at the end.
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key } from 'lucide-vue-next' import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key, Upload } from 'lucide-vue-next'
export const settingsGroups = [ export const settingsGroups = [
{ {
@@ -13,6 +13,7 @@ export const settingsGroups = [
{ to: '/settings/site', icon: Home, title: 'Site & Facility', description: 'Site URL/FQDN, facility name, PC access domain, employee-ID pattern, printer hostname template' }, { to: '/settings/site', icon: Home, title: 'Site & Facility', description: 'Site URL/FQDN, facility name, PC access domain, employee-ID pattern, printer hostname template' },
{ to: '/settings/floormap', icon: MapPin, title: 'Floor Map', description: 'Facility floor-plan blueprint image and pixel dimensions (width, height) for the site map' }, { to: '/settings/floormap', icon: MapPin, title: 'Floor Map', description: 'Facility floor-plan blueprint image and pixel dimensions (width, height) for the site map' },
{ to: '/settings/branding', icon: Palette, title: 'Branding', description: 'Site, QR, and badge logos, favicon, and brand primary, accent, sidebar, and theme colors' }, { to: '/settings/branding', icon: Palette, title: 'Branding', description: 'Site, QR, and badge logos, favicon, and brand primary, accent, sidebar, and theme colors' },
{ to: '/settings/importsiteassets', icon: Upload, title: 'Import Site Assets', description: 'Seed Machines, PCs, and Applications via the Asset Collector script, existing databases, or Intune/Azure exports; set the shared CSV collection location and download collected files' },
], ],
}, },
{ {

View File

@@ -4,10 +4,10 @@ The direct-database migration this guide used to describe is retired. Legacy
data now enters through the HTTP import surface, which runs authorization, data now enters through the HTTP import surface, which runs authorization,
validation, auditing, and plugin hooks on every row: validation, auditing, and plugin hooks on every row:
- Contract: `docs/IMPORT-API.md` (admin API token + `X-Import-Mode: true`, - Contract: the IMPORT-API wiki page (admin API token + `X-Import-Mode: true`,
which preserves original created/modified timestamps). which preserves original created/modified timestamps).
- Adoption playbook for a new site: `docs/IMPORT-ADOPTION.md`. - Adoption playbook for a new site: the IMPORT-ADOPTION wiki page.
- Worked reference loader (West Jefferson classic-ASP schema, 15 stages, - Worked reference loader (West Jefferson classic-ASP schema, 15 stages,
idempotent and resumable): `scripts/site_imports/wjf/`. idempotent and resumable): `scripts/site_imports/wjf/`.
- Full pilot sequence (stand up + import + verify + cutover): - Full pilot sequence (stand up + import + verify + cutover):
`docs/PILOT-DEPLOY.md`. the PILOT-DEPLOY wiki page.

View File

@@ -1,5 +1,6 @@
"""Computers plugin API.""" """Computers plugin API."""
from .routes import computers_bp from .routes import computers_bp
from . import policy_routes # noqa: F401 registers policy routes on computers_bp
__all__ = ['computers_bp'] __all__ = ['computers_bp']

View File

@@ -0,0 +1,399 @@
"""CRUD API for Configuration and Business Policies.
Registered on computers_bp under /api/computers/policies/... Kept in its own
module so the whole policy surface can be removed by not importing it - part of
the exit gate. Every route first checks the computers_configpolicies_enabled
switch and returns 404 while it is off, so enabling/disabling the feature never
half-exposes it.
Write-time validation lives here (not in the model): scope ASTs go through
validate_scope, and action names are checked against the endpoints command
allowlist, so a bad tree or an unknown action never reaches the database.
"""
from flask import request
from shopdb.api import (db, AuditLog, success_response, error_response,
ErrorCodes, require_permission)
from .routes import computers_bp
from ..models import (ConfigurationPolicy, ConfigurationPolicyAction,
BusinessPolicy, BusinessPolicyItem, POLICY_SOURCE_LOCAL,
ACTION_MODE_DESIRED, ACTION_MODE_GATE)
from ..policyflag import policy_flag_enabled
from ..services.policyscope import validate_scope, ScopeError
_MODES = {ACTION_MODE_DESIRED, ACTION_MODE_GATE}
def _feature_gate():
"""Return a 404 response when the policy feature is off, else None."""
if not policy_flag_enabled():
return error_response(ErrorCodes.NOT_FOUND,
'Policy feature is not enabled', http_code=404)
return None
def _known_actions():
"""The code-level allowlist of action names a policy may reference."""
from plugins.endpoints.commands import COMMAND_ALLOWLIST
return set(COMMAND_ALLOWLIST.keys())
def _configpolicy_dict(policy):
data = policy.to_dict()
data['actions'] = [a.to_dict() for a in policy.actions]
return data
def _businesspolicy_dict(policy):
data = policy.to_dict()
data['items'] = [{
'businesspolicyitemid': it.businesspolicyitemid,
'configurationpolicyid': it.configurationpolicyid,
'sortorder': it.sortorder,
'name': it.configurationpolicy.name if it.configurationpolicy else None,
} for it in policy.items]
return data
# =============================================================================
# Configuration Policies
# =============================================================================
@computers_bp.route('/policies/configuration', methods=['GET'])
@require_permission('computers.view')
def list_configuration_policies():
gate = _feature_gate()
if gate:
return gate
policies = ConfigurationPolicy.query.order_by(ConfigurationPolicy.name).all()
return success_response([_configpolicy_dict(p) for p in policies])
@computers_bp.route('/policies/configuration/<int:policy_id>', methods=['GET'])
@require_permission('computers.view')
def get_configuration_policy(policy_id: int):
gate = _feature_gate()
if gate:
return gate
policy = db.session.get(ConfigurationPolicy, policy_id)
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Configuration policy not found', http_code=404)
return success_response(_configpolicy_dict(policy))
def _apply_actions(policy, actions):
"""Replace a policy's actions from request data, validating each.
Returns an error_response on failure, else None.
"""
known = _known_actions()
policy.actions.clear()
for row in actions or []:
name = (row.get('action') or '').strip()
if name not in known:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Unknown action: {name!r}')
mode = (row.get('mode') or ACTION_MODE_GATE).strip()
if mode not in _MODES:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Unknown action mode: {mode!r}')
policy.actions.append(ConfigurationPolicyAction(
action=name,
mode=mode,
desiredstate=row.get('desiredstate'),
params=row.get('params'),
intervalminutes=row.get('intervalminutes'),
sortorder=row.get('sortorder', 0) or 0,
))
return None
@computers_bp.route('/policies/configuration', methods=['POST'])
@require_permission('computers.create')
def create_configuration_policy():
gate = _feature_gate()
if gate:
return gate
data = request.get_json() or {}
name = (data.get('name') or '').strip()
if not name:
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
if ConfigurationPolicy.query.filter_by(name=name).first():
return error_response(ErrorCodes.CONFLICT,
f"Configuration policy '{name}' already exists",
http_code=409)
scope = data.get('scopejson')
if scope is not None:
try:
validate_scope(scope)
except ScopeError as exc:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Invalid scope: {exc}')
policy = ConfigurationPolicy(
name=name,
description=data.get('description'),
scopejson=scope,
source=POLICY_SOURCE_LOCAL,
)
err = _apply_actions(policy, data.get('actions'))
if err:
return err
db.session.add(policy)
AuditLog.log('created', 'ConfigurationPolicy', entityname=name)
db.session.commit()
return success_response(_configpolicy_dict(policy),
message='Configuration policy created',
http_code=201)
@computers_bp.route('/policies/configuration/<int:policy_id>', methods=['PUT'])
@require_permission('computers.edit')
def update_configuration_policy(policy_id: int):
gate = _feature_gate()
if gate:
return gate
policy = db.session.get(ConfigurationPolicy, policy_id)
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Configuration policy not found', http_code=404)
if policy.source != POLICY_SOURCE_LOCAL:
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)
data = request.get_json() or {}
if 'name' in data:
newname = (data['name'] or '').strip()
if newname and newname != policy.name:
if ConfigurationPolicy.query.filter_by(name=newname).first():
return error_response(ErrorCodes.CONFLICT,
f"Configuration policy '{newname}' already exists",
http_code=409)
policy.name = newname
if 'description' in data:
policy.description = data['description']
if 'isactive' in data:
policy.isactive = data['isactive']
if 'scopejson' in data:
scope = data['scopejson']
if scope is not None:
try:
validate_scope(scope)
except ScopeError as exc:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Invalid scope: {exc}')
policy.scopejson = scope
if 'actions' in data:
err = _apply_actions(policy, data['actions'])
if err:
return err
AuditLog.log('updated', 'ConfigurationPolicy', entityname=policy.name)
db.session.commit()
return success_response(_configpolicy_dict(policy),
message='Configuration policy updated')
@computers_bp.route('/policies/configuration/<int:policy_id>', methods=['DELETE'])
@require_permission('computers.delete')
def delete_configuration_policy(policy_id: int):
gate = _feature_gate()
if gate:
return gate
policy = db.session.get(ConfigurationPolicy, policy_id)
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Configuration policy not found', http_code=404)
if policy.source != POLICY_SOURCE_LOCAL:
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)
inuse = BusinessPolicyItem.query.filter_by(
configurationpolicyid=policy_id).count()
if inuse:
return error_response(
ErrorCodes.CONFLICT,
f'Cannot delete: used by {inuse} business policy item(s)',
http_code=409)
db.session.delete(policy)
AuditLog.log('deleted', 'ConfigurationPolicy', entityname=policy.name)
db.session.commit()
return success_response(None, message='Configuration policy deleted')
# =============================================================================
# Business Policies
# =============================================================================
@computers_bp.route('/policies/business', methods=['GET'])
@require_permission('computers.view')
def list_business_policies():
gate = _feature_gate()
if gate:
return gate
policies = (BusinessPolicy.query
.order_by(BusinessPolicy.priority, BusinessPolicy.businesspolicyid)
.all())
return success_response([_businesspolicy_dict(p) for p in policies])
@computers_bp.route('/policies/business/<int:policy_id>', methods=['GET'])
@require_permission('computers.view')
def get_business_policy(policy_id: int):
gate = _feature_gate()
if gate:
return gate
policy = db.session.get(BusinessPolicy, policy_id)
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Business policy not found', http_code=404)
return success_response(_businesspolicy_dict(policy))
def _apply_items(policy, items):
"""Replace a business policy's configuration-policy items, validating each."""
policy.items.clear()
seen = set()
for order, row in enumerate(items or []):
cpid = row.get('configurationpolicyid')
if cpid is None:
return error_response(ErrorCodes.VALIDATION_ERROR,
'item requires configurationpolicyid')
if cpid in seen:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'duplicate configuration policy {cpid}')
if not db.session.get(ConfigurationPolicy, cpid):
return error_response(ErrorCodes.VALIDATION_ERROR,
f'unknown configuration policy {cpid}')
seen.add(cpid)
policy.items.append(BusinessPolicyItem(
configurationpolicyid=cpid,
sortorder=row.get('sortorder', order),
))
return None
@computers_bp.route('/policies/business', methods=['POST'])
@require_permission('computers.create')
def create_business_policy():
gate = _feature_gate()
if gate:
return gate
data = request.get_json() or {}
name = (data.get('name') or '').strip()
if not name:
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
if BusinessPolicy.query.filter_by(name=name).first():
return error_response(ErrorCodes.CONFLICT,
f"Business policy '{name}' already exists",
http_code=409)
policy = BusinessPolicy(
name=name,
description=data.get('description'),
priority=data.get('priority', 100),
source=POLICY_SOURCE_LOCAL,
)
err = _apply_items(policy, data.get('items'))
if err:
return err
db.session.add(policy)
AuditLog.log('created', 'BusinessPolicy', entityname=name)
db.session.commit()
return success_response(_businesspolicy_dict(policy),
message='Business policy created', http_code=201)
@computers_bp.route('/policies/business/<int:policy_id>', methods=['PUT'])
@require_permission('computers.edit')
def update_business_policy(policy_id: int):
gate = _feature_gate()
if gate:
return gate
policy = db.session.get(BusinessPolicy, policy_id)
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Business policy not found', http_code=404)
if policy.source != POLICY_SOURCE_LOCAL:
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)
data = request.get_json() or {}
if 'name' in data:
newname = (data['name'] or '').strip()
if newname and newname != policy.name:
if BusinessPolicy.query.filter_by(name=newname).first():
return error_response(ErrorCodes.CONFLICT,
f"Business policy '{newname}' already exists",
http_code=409)
policy.name = newname
if 'description' in data:
policy.description = data['description']
if 'priority' in data:
policy.priority = data['priority']
if 'isactive' in data:
policy.isactive = data['isactive']
if 'items' in data:
err = _apply_items(policy, data['items'])
if err:
return err
AuditLog.log('updated', 'BusinessPolicy', entityname=policy.name)
db.session.commit()
return success_response(_businesspolicy_dict(policy),
message='Business policy updated')
@computers_bp.route('/policies/business/<int:policy_id>', methods=['DELETE'])
@require_permission('computers.delete')
def delete_business_policy(policy_id: int):
gate = _feature_gate()
if gate:
return gate
policy = db.session.get(BusinessPolicy, policy_id)
if not policy:
return error_response(ErrorCodes.NOT_FOUND,
'Business policy not found', http_code=404)
if policy.source != POLICY_SOURCE_LOCAL:
return error_response(ErrorCodes.CONFLICT,
'Policy is managed externally and is read-only',
http_code=409)
db.session.delete(policy)
AuditLog.log('deleted', 'BusinessPolicy', entityname=policy.name)
db.session.commit()
return success_response(None, message='Business policy deleted')
# =============================================================================
# Action catalog (for the UI) and effective-policy preview
# =============================================================================
@computers_bp.route('/policies/actions', methods=['GET'])
@require_permission('computers.view')
def list_policy_actions():
"""The allowlisted action names + labels a policy may reference."""
gate = _feature_gate()
if gate:
return gate
from plugins.endpoints.commands import COMMAND_ALLOWLIST
return success_response([
{'action': name, 'label': meta.get('label'),
'description': meta.get('description')}
for name, meta in COMMAND_ALLOWLIST.items()
])
@computers_bp.route('/policies/effective/<int:asset_id>', methods=['GET'])
@require_permission('computers.view')
def get_effective_policy(asset_id: int):
"""Preview the resolved effective policy for one endpoint (debug/UI)."""
gate = _feature_gate()
if gate:
return gate
from ..services.policyresolver import effective_policy_for
effective = effective_policy_for(asset_id)
return success_response(effective.to_dict())

View File

@@ -575,6 +575,9 @@ def create_computer():
vendorid=data.get('vendorid'), vendorid=data.get('vendorid'),
modelnumberid=data.get('modelnumberid'), modelnumberid=data.get('modelnumberid'),
loggedinuser=data.get('loggedinuser'), loggedinuser=data.get('loggedinuser'),
azuredeviceid=data.get('azuredeviceid'),
azureobjectid=data.get('azureobjectid'),
azuredevicecategory=data.get('azuredevicecategory'),
lastreporteddate=data.get('lastreporteddate'), lastreporteddate=data.get('lastreporteddate'),
lastboottime=data.get('lastboottime') lastboottime=data.get('lastboottime')
) )
@@ -678,8 +681,9 @@ def update_computer(computer_id: int):
# Update computer fields # Update computer fields
computer_fields = ['computertypeid', 'hostname', 'osid', 'vendorid', computer_fields = ['computertypeid', 'hostname', 'osid', 'vendorid',
'modelnumberid', 'loggedinuser', 'lastreporteddate', 'modelnumberid', 'loggedinuser', 'azuredeviceid',
'lastboottime'] 'azureobjectid', 'azuredevicecategory',
'lastreporteddate', 'lastboottime']
for key in computer_fields: for key in computer_fields:
if key in data: if key in data:
old_val = getattr(comp, key) old_val = getattr(comp, key)

View File

@@ -62,6 +62,18 @@ export default [
name: 'access-protocols', name: 'access-protocols',
component: () => import('./views/AccessProtocolsList.vue'), component: () => import('./views/AccessProtocolsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' } meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
},
{
path: 'settings/configuration-policies',
name: 'configuration-policies',
component: () => import('./views/ConfigurationPoliciesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
},
{
path: 'settings/business-policies',
name: 'business-policies',
component: () => import('./views/BusinessPoliciesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
} }
] ]

View File

@@ -0,0 +1,223 @@
<template>
<div>
<div class="page-header">
<h2>Business Policies</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Business Policy</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Priority</th>
<th>Name</th>
<th>Description</th>
<th>Configuration policies</th>
<th>Source</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="p in policies" :key="p.businesspolicyid">
<td>{{ p.priority }}</td>
<td>{{ p.name }}</td>
<td class="cell-truncate" :title="p.description">{{ p.description || '-' }}</td>
<td>
<span v-for="it in p.items" :key="it.businesspolicyitemid" class="badge" style="margin-right:4px;">
{{ it.name }}
</span>
<span v-if="!p.items || p.items.length === 0" style="color: var(--text-light);">none</span>
</td>
<td>
<span class="badge" :class="p.source === 'local' ? 'badge-secondary' : 'badge-warning'">
{{ p.source }}
</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" :disabled="p.source !== 'local'" @click="openModal(p)">Edit</button>
<button class="btn btn-danger btn-sm" :disabled="p.source !== 'local'" @click="remove(p)">Delete</button>
</td>
</tr>
<tr v-if="policies.length === 0">
<td colspan="6" style="text-align:center; color: var(--text-light);">No business policies</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay">
<div class="modal modal-lg">
<div class="modal-header">
<h3>{{ editing ? 'Edit Business Policy' : 'Add Business Policy' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Name *</label>
<input v-model="form.name" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Priority <span class="hint">(lower runs first; first match wins per action)</span></label>
<input v-model.number="form.priority" type="number" class="form-control" />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="2"></textarea>
</div>
<div class="form-group">
<label>Configuration policies <span class="hint">(evaluated top to bottom)</span></label>
<div v-for="(it, index) in form.items" :key="index" class="item-row">
<span class="order-badge">{{ index + 1 }}</span>
<select v-model.number="it.configurationpolicyid" class="form-control">
<option :value="undefined" disabled>Select configuration policy...</option>
<option v-for="cp in configPolicies" :key="cp.configurationpolicyid" :value="cp.configurationpolicyid">
{{ cp.name }}
</option>
</select>
<button type="button" class="btn btn-secondary btn-sm" :disabled="index === 0" @click="moveUp(index)">Up</button>
<button type="button" class="btn btn-secondary btn-sm" :disabled="index === form.items.length - 1" @click="moveDown(index)">Down</button>
<button type="button" class="btn btn-danger btn-sm" @click="form.items.splice(index, 1)">Remove</button>
</div>
<button type="button" class="btn btn-secondary btn-sm" @click="addItem">+ Add configuration policy</button>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { computersApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const toast = useToast()
const policies = ref([])
const configPolicies = ref([])
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref(emptyForm())
function emptyForm() {
return { name: '', description: '', priority: 100, items: [] }
}
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const [biz, config] = await Promise.all([
computersApi.policies.business.list(),
computersApi.policies.configuration.list()
])
policies.value = biz.data.data || []
configPolicies.value = config.data.data || []
} catch (err) {
console.error('Error loading business policies:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
error.value = ''
if (item) {
form.value = {
name: item.name || '',
description: item.description || '',
priority: item.priority ?? 100,
items: (item.items || [])
.slice()
.sort((a, b) => a.sortorder - b.sortorder)
.map(it => ({ configurationpolicyid: it.configurationpolicyid }))
}
} else {
form.value = emptyForm()
}
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
function addItem() {
form.value.items.push({ configurationpolicyid: undefined })
}
function moveUp(index) {
const items = form.value.items
;[items[index - 1], items[index]] = [items[index], items[index - 1]]
}
function moveDown(index) {
const items = form.value.items
;[items[index + 1], items[index]] = [items[index], items[index + 1]]
}
async function save() {
saving.value = true
error.value = ''
try {
const payload = {
name: form.value.name,
description: form.value.description,
priority: form.value.priority,
items: form.value.items.map((it, order) => ({
configurationpolicyid: it.configurationpolicyid,
sortorder: order
}))
}
if (editing.value) {
await computersApi.policies.business.update(editing.value.businesspolicyid, payload)
} else {
await computersApi.policies.business.create(payload)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save business policy')
} finally {
saving.value = false
}
}
async function remove(p) {
if (!confirm(`Delete business policy "${p.name}"?`)) return
try {
await computersApi.policies.business.remove(p.businesspolicyid)
loadData()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
</script>
<style scoped>
.item-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.order-badge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 24px; height: 24px; border-radius: 12px;
background: var(--border-color, #444);
}
</style>

View File

@@ -0,0 +1,223 @@
<template>
<div>
<div class="page-header">
<h2>Configuration Policies</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Policy</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Actions in policy</th>
<th>Source</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="p in policies" :key="p.configurationpolicyid">
<td>{{ p.name }}</td>
<td class="cell-truncate" :title="p.description">{{ p.description || '-' }}</td>
<td>
<span v-for="a in p.actions" :key="a.action" class="badge" style="margin-right:4px;">
{{ a.action }} ({{ a.mode }})
</span>
<span v-if="!p.actions || p.actions.length === 0" style="color: var(--text-light);">none</span>
</td>
<td>
<span class="badge" :class="p.source === 'local' ? 'badge-secondary' : 'badge-warning'">
{{ p.source }}
</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" :disabled="p.source !== 'local'" @click="openModal(p)">Edit</button>
<button class="btn btn-danger btn-sm" :disabled="p.source !== 'local'" @click="remove(p)">Delete</button>
</td>
</tr>
<tr v-if="policies.length === 0">
<td colspan="5" style="text-align:center; color: var(--text-light);">No configuration policies</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay">
<div class="modal modal-lg">
<div class="modal-header">
<h3>{{ editing ? 'Edit Configuration Policy' : 'Add Configuration Policy' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Name *</label>
<input v-model="form.name" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="2"></textarea>
</div>
<div class="form-group">
<label>Scope <span class="hint">(which endpoints this applies to)</span></label>
<div class="scope-builder">
<ScopeNodeEditor :node="form.scopejson" :pctypes="pctypes" :apps="apps" :removable="false" />
</div>
</div>
<div class="form-group">
<label>Actions</label>
<div v-for="(a, index) in form.actions" :key="index" class="action-row">
<select v-model="a.action" class="form-control">
<option :value="''" disabled>Select action...</option>
<option v-for="opt in actionCatalog" :key="opt.action" :value="opt.action">
{{ opt.label || opt.action }}
</option>
</select>
<select v-model="a.mode" class="form-control mode-select">
<option value="gate">Allow (gate)</option>
<option value="desired">Desired state</option>
</select>
<button type="button" class="btn btn-danger btn-sm" @click="form.actions.splice(index, 1)">Remove</button>
</div>
<button type="button" class="btn btn-secondary btn-sm" @click="addAction">+ Add action</button>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { computersApi, applicationsApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
import ScopeNodeEditor from './ScopeNodeEditor.vue'
const toast = useToast()
const policies = ref([])
const pctypes = ref([])
const apps = ref([])
const actionCatalog = ref([])
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref(emptyForm())
function emptyForm() {
return { name: '', description: '', scopejson: { type: 'true' }, actions: [] }
}
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const [pol, types, appList, actions] = await Promise.all([
computersApi.policies.configuration.list(),
computersApi.types.list({ perpage: 200 }),
applicationsApi.listAll({ perpage: 200 }),
computersApi.policies.actions()
])
policies.value = pol.data.data || []
pctypes.value = types.data.data || []
// applicationsApi.listAll resolves to a plain array (fetchAllPages), not an
// axios response, so read it directly. Reading .data on the array throws and
// rejects the whole Promise.all, blanking every dropdown.
apps.value = Array.isArray(appList) ? appList : (appList.data?.data || appList.data || [])
actionCatalog.value = actions.data.data || []
} catch (err) {
console.error('Error loading configuration policies:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
error.value = ''
if (item) {
form.value = {
name: item.name || '',
description: item.description || '',
scopejson: item.scopejson ? JSON.parse(JSON.stringify(item.scopejson)) : { type: 'true' },
actions: (item.actions || []).map(a => ({
action: a.action, mode: a.mode || 'gate',
desiredstate: a.desiredstate, params: a.params, intervalminutes: a.intervalminutes
}))
}
} else {
form.value = emptyForm()
}
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
function addAction() {
form.value.actions.push({ action: '', mode: 'gate' })
}
async function save() {
saving.value = true
error.value = ''
try {
const payload = {
name: form.value.name,
description: form.value.description,
scopejson: form.value.scopejson,
actions: form.value.actions
}
if (editing.value) {
await computersApi.policies.configuration.update(editing.value.configurationpolicyid, payload)
} else {
await computersApi.policies.configuration.create(payload)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save policy')
} finally {
saving.value = false
}
}
async function remove(p) {
if (!confirm(`Delete configuration policy "${p.name}"?`)) return
try {
await computersApi.policies.configuration.remove(p.configurationpolicyid)
loadData()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
</script>
<style scoped>
.scope-builder {
padding: 10px;
border: 1px solid var(--border-color, #444);
border-radius: 4px;
}
.action-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.mode-select { max-width: 160px; }
</style>

View File

@@ -3,6 +3,8 @@
<div class="page-header"> <div class="page-header">
<h2>Computer Details</h2> <h2>Computer Details</h2>
<div class="header-actions"> <div class="header-actions">
<button v-if="canCommand" @click="refreshStatus" class="btn btn-secondary" :disabled="restarting || refreshing">{{ refreshing ? 'Refreshing...' : 'Refresh Status' }}</button>
<button v-if="canRestart" @click="confirmRestart" class="btn btn-warning" :disabled="restarting">Restart Computer</button>
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link> <router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link> <router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<button @click="confirmRetire" class="btn btn-danger">Retire</button> <button @click="confirmRetire" class="btn btn-danger">Retire</button>
@@ -153,6 +155,28 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Entra / Azure device info -->
<div
v-if="computer.computer?.azuredeviceid || computer.computer?.azureobjectid || computer.computer?.azuredevicecategory"
class="section-card"
>
<h3 class="section-title">Entra Device Info</h3>
<div class="info-list">
<div v-if="computer.computer?.azuredeviceid" class="info-row">
<span class="info-label">Device ID</span>
<span class="info-value">{{ computer.computer.azuredeviceid }}</span>
</div>
<div v-if="computer.computer?.azureobjectid" class="info-row">
<span class="info-label">Object ID</span>
<span class="info-value">{{ computer.computer.azureobjectid }}</span>
</div>
<div v-if="computer.computer?.azuredevicecategory" class="info-row">
<span class="info-label">Device Category</span>
<span class="info-value">{{ computer.computer.azuredevicecategory }}</span>
</div>
</div>
</div>
</div> </div>
<!-- Right Column --> <!-- Right Column -->
@@ -226,7 +250,7 @@
<CustomFieldsSection :assetid="computer.assetid" /> <CustomFieldsSection :assetid="computer.assetid" />
<!-- Warranty --> <!-- Warranty -->
<PluginAssetPanels :assetid="computer.assetid" /> <PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" />
<!-- All relationships (controls, defaultprinter, ...) --> <!-- All relationships (controls, defaultprinter, ...) -->
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" /> <AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
@@ -253,10 +277,10 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle" import { colorStyle } from "@/utils/colorStyle"
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { computersApi, applicationsApi } from '@/api' import { computersApi, applicationsApi, endpointsApi } from '@/api'
import { withBase } from '@/utils/basePath' import { withBase } from '@/utils/basePath'
import { useWarrantyBadge } from '@/composables/warrantyBadge' import { useWarrantyBadge } from '@/composables/warrantyBadge'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue' import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
@@ -267,15 +291,26 @@ import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle' import { setRecordTitle } from '@/composables/pageTitle'
import { useToast } from '@/composables/toast' import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError' import { apiError } from '@/utils/apiError'
import { useAuthStore } from '@/stores/auth'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const toast = useToast() const toast = useToast()
const authStore = useAuthStore()
const { isEnabled } = useIdentifierFlags() const { isEnabled } = useIdentifierFlags()
const loading = ref(true) const loading = ref(true)
const computer = ref(null) const computer = ref(null)
const installedApps = ref([]) const installedApps = ref([])
const assetPanels = ref(null)
const restarting = ref(false)
const refreshing = ref(false)
// Header endpoint controls only make sense to users who can act on the agent.
// Restart is gated separately because it is a destructive grant (endpoints.restart)
// enforced again server-side; hiding the button is convenience, not the guard.
const canCommand = computed(() => authStore.hasPermission('endpoints.command'))
const canRestart = computed(() => authStore.hasPermission('endpoints.restart'))
// Warranty fetch + hero badge (shared across asset detail pages). // Warranty fetch + hero badge (shared across asset detail pages).
const { heroWarranty, warrantyDate: formatWarrantyDate } = useWarrantyBadge(() => computer.value?.assetid) const { heroWarranty, warrantyDate: formatWarrantyDate } = useWarrantyBadge(() => computer.value?.assetid)
@@ -316,6 +351,108 @@ function formatDate(dateStr) {
return new Date(dateStr).toLocaleString() return new Date(dateStr).toLocaleString()
} }
// Force a live telemetry cycle rather than re-reading the cached snapshot.
// Enqueues collect_now (the agent reports off-cadence), waits for the agent to
// finish it, then re-fetches the panels so the cards reflect the endpoint's
// real current state (e.g. a process the operator just closed).
async function refreshStatus() {
if (refreshing.value) return
refreshing.value = true
try {
// Baseline the current telemetry timestamp BEFORE forcing a collect. A
// command reaching 'succeeded' only means the agent ran collect_now, not
// that it has posted the fresh snapshot yet, so we additionally wait for
// lastreportedat to move past this baseline before re-rendering the panels.
const baseline = await currentTelemetryStamp()
const response = await endpointsApi.enqueueCommand(computer.value.assetid, 'collect_now')
const commandId = response?.data?.data?.endpointcommandid
const outcome = await waitForCommand(commandId)
const fresh = outcome === 'succeeded'
? await waitForFreshTelemetry(baseline)
: false
await assetPanels.value?.refresh()
if (outcome === 'timeout') {
toast.info('Refresh queued; the endpoint agent may be offline or busy')
} else if (outcome === 'failed') {
toast.error('The endpoint agent could not refresh telemetry')
} else if (!fresh) {
toast.info('Refresh sent; the endpoint has not reported new data yet')
} else {
toast.success('Status refreshed')
}
} catch (error) {
toast.error(apiError(error, 'Failed to refresh status'))
} finally {
refreshing.value = false
}
}
// Read the endpoint's current lastreportedat (ISO string or null). Used as the
// freshness baseline; a null baseline means the box has never reported, so any
// non-null stamp later counts as fresh.
async function currentTelemetryStamp() {
try {
const response = await endpointsApi.telemetryMeta(computer.value.assetid)
return response?.data?.data?.lastreportedat || null
} catch {
return null
}
}
// Poll lastreportedat until it advances past the baseline (proof a new snapshot
// landed) or the attempt budget runs out. Returns true only on a confirmed
// newer stamp; a bounded false lets the caller still refresh and message that
// the box has not reported yet, rather than hanging the button on an idle agent.
async function waitForFreshTelemetry(baseline, { attempts = 15, delayMs = 1000 } = {}) {
const baseMs = baseline ? Date.parse(baseline) : 0
for (let i = 0; i < attempts; i += 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
const stamp = await currentTelemetryStamp()
if (stamp && Date.parse(stamp) > baseMs) return true
}
return false
}
// Poll the endpoint's recent commands until the given command reaches a terminal
// state or we give up. Returns 'succeeded' | 'failed' | 'timeout'. The agent
// long-polls, so a live box turns this around in a couple of seconds; an offline
// box lets it time out cleanly rather than hanging the button forever.
async function waitForCommand(commandId, { attempts = 15, delayMs = 1000 } = {}) {
if (!commandId) return 'timeout'
for (let i = 0; i < attempts; i += 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
try {
const response = await endpointsApi.listCommands(computer.value.assetid)
const command = (response?.data?.data || []).find(
(row) => row.endpointcommandid === commandId)
if (command && command.status === 'succeeded') return 'succeeded'
if (command && command.status === 'failed') return 'failed'
} catch {
// Transient poll failure: keep trying until the attempt budget runs out.
}
}
return 'timeout'
}
// Destructive: queues a system_restart that reboots the PC immediately
// (shutdown /r /t 0 on the agent). The confirm gate is the last stop before an
// unrecoverable action; the server re-checks endpoints.restart regardless.
async function confirmRestart() {
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
if (!confirm(`Restart ${label} now?\n\n`
+ 'The computer reboots immediately with no grace period. '
+ 'Any unsaved work on it will be lost.')) return
restarting.value = true
try {
await endpointsApi.enqueueCommand(computer.value.assetid, 'system_restart')
toast.success(`Restart queued for ${label}`)
} catch (error) {
toast.error(apiError(error, 'Failed to queue restart'))
} finally {
restarting.value = false
}
}
// Retire, not delete: the server deactivates the asset, so the PC // Retire, not delete: the server deactivates the asset, so the PC
// leaves every list and report while its history and relationships stay put. // leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true. // Reversible by an admin setting isactive back to true.

View File

@@ -166,7 +166,7 @@
> >
<option value="">Select vendor...</option> <option value="">Select vendor...</option>
<option <option
v-for="v in vendors" v-for="v in filteredVendors"
:key="v.vendorid" :key="v.vendorid"
:value="v.vendorid" :value="v.vendorid"
> >
@@ -223,6 +223,17 @@
</div> </div>
</div> </div>
<div class="form-group">
<label for="azuredeviceid">Azure Device ID</label>
<input
id="azuredeviceid"
v-model="form.azuredeviceid"
type="text"
class="form-control"
placeholder="Entra/Azure deviceId GUID (set by Azure imports)"
/>
</div>
<div class="form-group"> <div class="form-group">
<label>Remote Access Protocols</label> <label>Remote Access Protocols</label>
<div class="protocol-list"> <div class="protocol-list">
@@ -383,6 +394,7 @@ const form = ref({
locationid: '', locationid: '',
osid: '', osid: '',
loggedinuser: '', loggedinuser: '',
azuredeviceid: '',
accessmethods: [], accessmethods: [],
notes: '', notes: '',
mapx: null, mapx: null,
@@ -432,12 +444,42 @@ watch(() => form.value.serialnumber, (serial) => {
} }
}) })
// Filter models by selected vendor and PC type // Ensure a saved model id is present in models.value even when the filtered
// list (category PC) excluded it because it has no modeltype. Fetches the
// single row by id and appends it; a no-op when already present or blank.
async function ensureModelLoaded(modelId) {
if (!modelId) return
if (models.value.some(m => m.modelnumberid === modelId)) return
try {
const response = await modelsApi.get(modelId)
const model = response.data.data
if (model) models.value = [...models.value, model]
} catch (e) {
// Leave it absent; the select shows blank but we did not invent a value.
}
}
// Filter models by selected vendor and PC type. Keep the record's own saved
// model even if it is untyped or a different vendor, so editing an existing PC
// never renders a blank select that clears the field on save.
const filteredModels = computed(() => { const filteredModels = computed(() => {
// filter by vendor only (PC type now maps to computertypeid, a different id // filter by vendor only (PC type now maps to computertypeid, a different id
// space than a model's machinetypeid) // space than a model's machinetypeid)
const savedId = form.value.modelnumberid
if (!form.value.vendorid) return models.value if (!form.value.vendorid) return models.value
return models.value.filter(m => m.vendorid === form.value.vendorid) return models.value.filter(
m => m.vendorid === form.value.vendorid || m.modelnumberid === savedId)
})
// Only offer vendors that actually have PC-category models. models is already
// scoped to category PC at load, so its vendorids are the PC vendor set. Keep
// the record's own vendor even if it falls outside that set, so editing an
// existing PC never silently drops its saved vendor.
const filteredVendors = computed(() => {
const pcVendorIds = new Set(models.value.map(m => m.vendorid))
return vendors.value.filter(
v => pcVendorIds.has(v.vendorid) || v.vendorid === form.value.vendorid
)
}) })
onMounted(async () => { onMounted(async () => {
@@ -448,8 +490,8 @@ onMounted(async () => {
computersApi.types.list({ perpage: 100 }), computersApi.types.list({ perpage: 100 }),
assetsApi.statuses.list(), assetsApi.statuses.list(),
vendorsApi.list({ perpage: 100 }), vendorsApi.list({ perpage: 100 }),
modelsApi.listAll(), // backend caps perpage at 100; page through all modelsApi.listAll({ category: 'PC' }), // backend caps perpage at 100; page through all
locationsApi.list({ perpage: 100 }), locationsApi.listAll(), // backend caps perpage at 100; page through all
operatingsystemsApi.list({ perpage: 100 }), operatingsystemsApi.list({ perpage: 100 }),
computersApi.protocols.list(), computersApi.protocols.list(),
// Handles its own failure: a site without printers still gets a form. // Handles its own failure: a site without printers still gets a form.
@@ -459,7 +501,7 @@ onMounted(async () => {
statuses.value = statusRes.data.data || [] statuses.value = statusRes.data.data || []
vendors.value = vendorRes.data.data || [] vendors.value = vendorRes.data.data || []
models.value = allModels models.value = allModels
locations.value = locRes.data.data || [] locations.value = locRes || []
operatingsystems.value = osRes.data.data || [] operatingsystems.value = osRes.data.data || []
protocols.value = protoRes.data.data || [] protocols.value = protoRes.data.data || []
@@ -486,6 +528,7 @@ onMounted(async () => {
locationid: pc.locationid || '', locationid: pc.locationid || '',
osid: ext.osid || '', osid: ext.osid || '',
loggedinuser: ext.loggedinuser || '', loggedinuser: ext.loggedinuser || '',
azuredeviceid: ext.azuredeviceid || '',
accessmethods: (pc.accessmethods || []).map(a => ({ accessmethods: (pc.accessmethods || []).map(a => ({
protocolid: a.protocolid, protocolid: a.protocolid,
portoverride: a.portoverride ?? null portoverride: a.portoverride ?? null
@@ -496,6 +539,12 @@ onMounted(async () => {
levelid: pc.levelid ?? null, levelid: pc.levelid ?? null,
ipaddress: primaryComm?.ipaddress || '' ipaddress: primaryComm?.ipaddress || ''
} }
// The model list is loaded filtered to category PC, which the backend
// inner-joins against ModelType - so an untyped model is absent. If this
// PC's saved model is one of those rows, fetch it by id and merge it in,
// or its dropdown would render blank and saving would clear the field.
await ensureModelLoaded(form.value.modelnumberid)
} }
} catch (err) { } catch (err) {
console.error('Error loading data:', err) console.error('Error loading data:', err)
@@ -555,6 +604,7 @@ async function savePC() {
locationid: form.value.locationid || null, locationid: form.value.locationid || null,
osid: form.value.osid || null, osid: form.value.osid || null,
loggedinuser: form.value.loggedinuser || null, loggedinuser: form.value.loggedinuser || null,
azuredeviceid: form.value.azuredeviceid || null,
accessmethods: form.value.accessmethods, accessmethods: form.value.accessmethods,
notes: form.value.notes || null, notes: form.value.notes || null,
ipaddress: form.value.ipaddress || null, ipaddress: form.value.ipaddress || null,

View File

@@ -0,0 +1,106 @@
<template>
<div class="scope-node">
<div class="scope-node-row">
<select v-model="node.type" class="form-control scope-type" @change="onTypeChange">
<option value="true">Matches everything</option>
<option value="pctype">PC type is</option>
<option value="app">App is installed</option>
<option value="and">ALL of (AND)</option>
<option value="or">ANY of (OR)</option>
<option value="not">NOT</option>
</select>
<select v-if="node.type === 'pctype'" v-model.number="node.id" class="form-control">
<option :value="undefined" disabled>Select PC type...</option>
<option v-for="pt in pctypes" :key="pt.computertypeid" :value="pt.computertypeid">
{{ pt.computertype }}
</option>
</select>
<select v-if="node.type === 'app'" v-model.number="node.id" class="form-control">
<option :value="undefined" disabled>Select app...</option>
<option v-for="app in apps" :key="app.appid" :value="app.appid">
{{ app.appname }}
</option>
</select>
<button v-if="removable" type="button" class="btn btn-danger btn-sm" @click="$emit('remove')">
Remove
</button>
</div>
<div v-if="node.type === 'and' || node.type === 'or'" class="scope-children">
<ScopeNodeEditor
v-for="(child, index) in node.nodes"
:key="index"
:node="child"
:pctypes="pctypes"
:apps="apps"
:removable="true"
@remove="removeChild(index)"
/>
<button type="button" class="btn btn-secondary btn-sm" @click="addChild">+ Add condition</button>
</div>
<div v-if="node.type === 'not'" class="scope-children">
<ScopeNodeEditor
:node="node.node"
:pctypes="pctypes"
:apps="apps"
:removable="false"
/>
</div>
</div>
</template>
<script setup>
const props = defineProps({
node: { type: Object, required: true },
pctypes: { type: Array, default: () => [] },
apps: { type: Array, default: () => [] },
removable: { type: Boolean, default: false }
})
defineEmits(['remove'])
function onTypeChange() {
const node = props.node
// Reset payload keys so a switched node never carries stale fields from its
// previous type. Keep only what the new type needs.
delete node.id
delete node.nodes
delete node.node
if (node.type === 'and' || node.type === 'or') {
node.nodes = [{ type: 'true' }]
} else if (node.type === 'not') {
node.node = { type: 'true' }
}
}
function addChild() {
props.node.nodes.push({ type: 'true' })
}
function removeChild(index) {
props.node.nodes.splice(index, 1)
if (props.node.nodes.length === 0) {
props.node.nodes.push({ type: 'true' })
}
}
</script>
<script>
export default { name: 'ScopeNodeEditor' }
</script>
<style scoped>
.scope-node { margin: 4px 0; }
.scope-node-row { display: flex; gap: 8px; align-items: center; }
.scope-type { max-width: 220px; }
.scope-children {
margin-left: 24px;
padding-left: 12px;
border-left: 2px solid var(--border-color, #444);
margin-top: 6px;
}
</style>

View File

@@ -0,0 +1,40 @@
"""Add azuredeviceid to computers (Entra/Azure device-group imports).
The core chain created computers WITHOUT an azuredeviceid column. This plugin
revision adds it so BOTH fresh installs (core chain builds the table, then this
adds the column) and existing installs get it. Guarded/idempotent: skips when
the table is absent (plugin disabled) or the column already exists (e.g. a test
DB built by db.create_all() from the model).
Revision ID: computers0002azureid
Revises: computers0001anchor
"""
from alembic import op
import sqlalchemy as sa
revision = 'computers0002azureid'
down_revision = 'computers0001anchor'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'computers' not in insp.get_table_names():
return
cols = {c['name'] for c in insp.get_columns('computers')}
if 'azuredeviceid' not in cols:
op.add_column('computers',
sa.Column('azuredeviceid', sa.String(length=64), nullable=True))
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'computers' not in insp.get_table_names():
return
cols = {c['name'] for c in insp.get_columns('computers')}
if 'azuredeviceid' in cols:
op.drop_column('computers', 'azuredeviceid')

View File

@@ -0,0 +1,45 @@
"""Add azureobjectid and azuredevicecategory to computers.
The Azure device-group import now also records the Entra directory object id
(the CSV 'id' column) and an operator-supplied device category (the group
membership label, e.g. SFLD_DYN_Device_Group_...). This revision adds both
columns. Guarded/idempotent: skips when the table is absent (plugin disabled)
or a column already exists (e.g. a test DB built by db.create_all()).
Revision ID: computers0003azurefields
Revises: computers0002azureid
"""
from alembic import op
import sqlalchemy as sa
revision = 'computers0003azurefields'
down_revision = 'computers0002azureid'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'computers' not in insp.get_table_names():
return
cols = {c['name'] for c in insp.get_columns('computers')}
if 'azureobjectid' not in cols:
op.add_column('computers',
sa.Column('azureobjectid', sa.String(length=64), nullable=True))
if 'azuredevicecategory' not in cols:
op.add_column('computers',
sa.Column('azuredevicecategory', sa.String(length=255), nullable=True))
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'computers' not in insp.get_table_names():
return
cols = {c['name'] for c in insp.get_columns('computers')}
if 'azuredevicecategory' in cols:
op.drop_column('computers', 'azuredevicecategory')
if 'azureobjectid' in cols:
op.drop_column('computers', 'azureobjectid')

View File

@@ -0,0 +1,162 @@
"""Add Configuration and Business Policy tables (RMM-style policies).
Creates configurationpolicies, configurationpolicyactions, businesspolicies,
and businesspolicyitems. These back the DT-authored policy feature that is gated
by the computers_configpolicies_enabled setting; the tables land regardless so
the switch can be flipped without a schema change, and the downgrade drops all
four for a clean rollback of the whole scope.
Idempotent: each create_table is skipped when the table already exists (e.g. a
test DB built by db.create_all()).
Revision ID: computers0004policies
Revises: computers0003azurefields
"""
from alembic import op
import sqlalchemy as sa
revision = 'computers0004policies'
down_revision = 'computers0003azurefields'
branch_labels = None
depends_on = None
# TEXT rather than sa.JSON on purpose. MySQL 5.6 has no JSON type - it arrived
# in 5.7 - and this migration failed there with a bare syntax error near the
# word JSON. Every other JSON-bearing column in the product is TEXT for the same
# reason (guides.blocksjson, geenforce.manifestjson); the model reads and writes
# dicts through the JSONText type decorator, so nothing above the database
# changes.
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
existing = set(insp.get_table_names())
if 'configurationpolicies' not in existing:
op.create_table(
'configurationpolicies',
sa.Column('configurationpolicyid', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=150), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('scopejson', sa.Text(), nullable=True),
sa.Column('source', sa.String(length=50), nullable=False,
server_default='local'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.PrimaryKeyConstraint('configurationpolicyid'),
sa.UniqueConstraint('name', name='uq_configurationpolicy_name'),
)
op.create_index('idx_configpolicy_source', 'configurationpolicies',
['source'])
op.create_index('idx_configpolicy_active', 'configurationpolicies',
['isactive'])
if 'configurationpolicyactions' not in existing:
op.create_table(
'configurationpolicyactions',
sa.Column('configurationpolicyactionid', sa.Integer(),
nullable=False),
sa.Column('configurationpolicyid', sa.Integer(), nullable=False),
sa.Column('action', sa.String(length=100), nullable=False),
sa.Column('mode', sa.String(length=20), nullable=False,
server_default='gate'),
sa.Column('desiredstate', sa.Text(), nullable=True),
sa.Column('params', sa.Text(), nullable=True),
sa.Column('intervalminutes', sa.Integer(), nullable=True),
sa.Column('sortorder', sa.Integer(), nullable=False,
server_default='0'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.ForeignKeyConstraint(
['configurationpolicyid'],
['configurationpolicies.configurationpolicyid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('configurationpolicyactionid'),
sa.UniqueConstraint('configurationpolicyid', 'action',
name='uq_configpolicyaction'),
)
op.create_index('idx_configpolicyaction_policy',
'configurationpolicyactions',
['configurationpolicyid'])
if 'businesspolicies' not in existing:
op.create_table(
'businesspolicies',
sa.Column('businesspolicyid', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=150), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('priority', sa.Integer(), nullable=False,
server_default='100'),
sa.Column('source', sa.String(length=50), nullable=False,
server_default='local'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.PrimaryKeyConstraint('businesspolicyid'),
sa.UniqueConstraint('name', name='uq_businesspolicy_name'),
)
op.create_index('idx_businesspolicy_priority', 'businesspolicies',
['priority'])
op.create_index('idx_businesspolicy_active', 'businesspolicies',
['isactive'])
if 'businesspolicyitems' not in existing:
op.create_table(
'businesspolicyitems',
sa.Column('businesspolicyitemid', sa.Integer(), nullable=False),
sa.Column('businesspolicyid', sa.Integer(), nullable=False),
sa.Column('configurationpolicyid', sa.Integer(), nullable=False),
sa.Column('sortorder', sa.Integer(), nullable=False,
server_default='0'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.ForeignKeyConstraint(
['businesspolicyid'], ['businesspolicies.businesspolicyid'],
ondelete='CASCADE'),
sa.ForeignKeyConstraint(
['configurationpolicyid'],
['configurationpolicies.configurationpolicyid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('businesspolicyitemid'),
sa.UniqueConstraint('businesspolicyid', 'configurationpolicyid',
name='uq_businesspolicyitem'),
)
op.create_index('idx_bpitem_business', 'businesspolicyitems',
['businesspolicyid'])
op.create_index('idx_bpitem_config', 'businesspolicyitems',
['configurationpolicyid'])
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
existing = set(insp.get_table_names())
if 'businesspolicyitems' in existing:
op.drop_index('idx_bpitem_config', table_name='businesspolicyitems')
op.drop_index('idx_bpitem_business', table_name='businesspolicyitems')
op.drop_table('businesspolicyitems')
if 'businesspolicies' in existing:
op.drop_index('idx_businesspolicy_active', table_name='businesspolicies')
op.drop_index('idx_businesspolicy_priority', table_name='businesspolicies')
op.drop_table('businesspolicies')
if 'configurationpolicyactions' in existing:
op.drop_index('idx_configpolicyaction_policy',
table_name='configurationpolicyactions')
op.drop_table('configurationpolicyactions')
if 'configurationpolicies' in existing:
op.drop_index('idx_configpolicy_active',
table_name='configurationpolicies')
op.drop_index('idx_configpolicy_source',
table_name='configurationpolicies')
op.drop_table('configurationpolicies')

View File

@@ -7,6 +7,15 @@ from .computer import (
AccessProtocol, AccessProtocol,
ComputerAccess, ComputerAccess,
) )
from .policy import (
ConfigurationPolicy,
ConfigurationPolicyAction,
BusinessPolicy,
BusinessPolicyItem,
POLICY_SOURCE_LOCAL,
ACTION_MODE_DESIRED,
ACTION_MODE_GATE,
)
__all__ = [ __all__ = [
'Computer', 'Computer',
@@ -14,4 +23,11 @@ __all__ = [
'ComputerInstalledApp', 'ComputerInstalledApp',
'AccessProtocol', 'AccessProtocol',
'ComputerAccess', 'ComputerAccess',
'ConfigurationPolicy',
'ConfigurationPolicyAction',
'BusinessPolicy',
'BusinessPolicyItem',
'POLICY_SOURCE_LOCAL',
'ACTION_MODE_DESIRED',
'ACTION_MODE_GATE',
] ]

View File

@@ -85,6 +85,23 @@ class Computer(BaseModel):
lastreporteddate = db.Column(db.DateTime, nullable=True) lastreporteddate = db.Column(db.DateTime, nullable=True)
lastboottime = db.Column(db.DateTime, nullable=True) lastboottime = db.Column(db.DateTime, nullable=True)
# Entra/Azure device identity (set by Azure device-group imports)
azuredeviceid = db.Column(
db.String(64),
nullable=True,
comment='Entra/Azure deviceId GUID'
)
azureobjectid = db.Column(
db.String(64),
nullable=True,
comment='Entra/Azure directory object id GUID'
)
azuredevicecategory = db.Column(
db.String(255),
nullable=True,
comment='Entra device category / group membership label set at import'
)
# Remote access is now modeled per-protocol via the accessmethods # Remote access is now modeled per-protocol via the accessmethods
# relationship (AccessProtocol / ComputerAccess), replacing isvnc/iswinrm. # relationship (AccessProtocol / ComputerAccess), replacing isvnc/iswinrm.

View File

@@ -0,0 +1,44 @@
"""JSON stored in a TEXT column.
MySQL 5.6 has no JSON type - it arrived in 5.7 - and a site is still running
5.6, where `sa.JSON()` fails at migration time with a bare syntax error near the
word JSON. Every other JSON-bearing column in this product is already TEXT for
that reason: guides' `blocksjson`, GE-Enforce's `manifestjson`.
This keeps the Python side identical. A model attribute still holds a dict or a
list, still assigns and reads like one; only the storage changes. The
alternative - json.dumps and json.loads at seventeen call sites - is the same
thing written out seventeen times, with seventeen chances to forget one.
Note the SQLite behaviour is deliberately the same as MySQL's. Tests run on
SQLite and production does not, so a type that behaved differently across the
two would hide exactly the failure this exists to prevent.
"""
import json
from sqlalchemy import Text
from sqlalchemy.types import TypeDecorator
class JSONText(TypeDecorator):
"""A dict or list on the Python side, TEXT in the database."""
impl = Text
cache_ok = True
def process_bind_param(self, value, dialect):
if value is None:
return None
return json.dumps(value, separators=(',', ':'))
def process_result_value(self, value, dialect):
if value is None or value == '':
return None
try:
return json.loads(value)
except (TypeError, ValueError):
# A row written before this column was JSON, or hand-edited. Hand
# back the raw text rather than raising: a policy that cannot be
# parsed should be visible and fixable, not an exception on read.
return value

View File

@@ -0,0 +1,198 @@
"""Configuration and business policy models (RMM-style).
DT staff author reusable Configuration Policies (a single rule binding
allowlisted endpoint actions to endpoints matched by a boolean scope) and group
them into ordered Business Policies assigned to a fleet. When an endpoint reports
in, the server folds the matching Business Policies in priority order to compute
that endpoint's effective policy (first match wins per action).
These models live in the computers plugin so policies sit next to ComputerType.
The action VOCABULARY stays authoritative in plugins/endpoints/commands.py; this
layer references action NAMES only. The `source` column is the seam for a future
GE-Enforce sync (local-authored rows vs imported/managed rows).
The whole surface is gated by the computers_configpolicies_enabled setting; the
models register unconditionally (SQLAlchemy needs them), but routes, panels, and
enforcement stay inert while the flag is off, and the migration downgrade drops
every table for a clean rollback.
"""
from shopdb.api import db, BaseModel
from .jsontext import JSONText
# Provenance of a policy row.
# leaves room for `geenforce` (or another manager) to own rows later without a
# schema change - an imported row is read-only in the UI and reconciled by its
# owner. Kept a plain string, not an enum column, so adding a source is data.
POLICY_SOURCE_LOCAL = 'local'
class ConfigurationPolicy(BaseModel):
"""A single reusable policy: a scope plus the actions it applies.
Scope is a boolean expression (AND/OR/NOT over PC types and applications)
stored as a small typed JSON AST in scopejson; see services/policyscope.py
for the evaluator. Actions live in ConfigurationPolicyAction rows.
"""
__tablename__ = 'configurationpolicies'
configurationpolicyid = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), unique=True, nullable=False)
description = db.Column(db.Text)
# Boolean target AST: {"type":"and","nodes":[{"type":"pctype","id":3},
# {"type":"not","node":{"type":"app","id":12}}]}. Validated server-side
# (known node types, depth cap) before it is stored - see policyscope.
scopejson = db.Column(JSONText, nullable=True)
# GE-Enforce interop seam (see POLICY_SOURCE_LOCAL).
source = db.Column(db.String(50), default=POLICY_SOURCE_LOCAL, nullable=False)
# createddate, modifieddate, isactive come from BaseModel.
actions = db.relationship(
'ConfigurationPolicyAction',
back_populates='configurationpolicy',
cascade='all, delete-orphan',
)
__table_args__ = (
db.Index('idx_configpolicy_source', 'source'),
db.Index('idx_configpolicy_active', 'isactive'),
)
def __repr__(self):
return f"<ConfigurationPolicy {self.name}>"
# What a policy action does to a matched endpoint.
# desired: reconciled desired-state. desiredstate says what it should be
# (e.g. running=true for mtconnect); the server enqueues the
# drift-correcting command when telemetry disagrees.
# gate: allowlist-gating only. The action is PERMITTED (and shown) on
# matched endpoints; it constrains what may be enqueued, it does not
# drive anything on its own.
ACTION_MODE_DESIRED = 'desired'
ACTION_MODE_GATE = 'gate'
class ConfigurationPolicyAction(BaseModel):
"""One allowlisted action carried by a ConfigurationPolicy.
`action` is a name from plugins/endpoints/commands.py COMMAND_ALLOWLIST;
it is validated against that allowlist at write time. `mode` selects
desired-state reconciliation vs allowlist-gating.
"""
__tablename__ = 'configurationpolicyactions'
configurationpolicyactionid = db.Column(db.Integer, primary_key=True)
configurationpolicyid = db.Column(
db.Integer,
db.ForeignKey('configurationpolicies.configurationpolicyid',
ondelete='CASCADE'),
nullable=False,
)
# Allowlisted action name (endpoints commands.py). Not an FK - the allowlist
# is code, not a table - so it is validated in the API layer.
action = db.Column(db.String(100), nullable=False)
mode = db.Column(db.String(20), default=ACTION_MODE_GATE, nullable=False)
# Desired-state target for mode=desired, as JSON so different actions can
# express different targets (e.g. {"running": true}). Null for gate mode.
desiredstate = db.Column(JSONText, nullable=True)
# Optional action parameters passed through to the command (JSON), and an
# optional reconcile cadence in minutes (null = reconcile on every report).
params = db.Column(JSONText, nullable=True)
intervalminutes = db.Column(db.Integer, nullable=True)
sortorder = db.Column(db.Integer, default=0, nullable=False)
configurationpolicy = db.relationship(
'ConfigurationPolicy', back_populates='actions')
__table_args__ = (
db.UniqueConstraint('configurationpolicyid', 'action',
name='uq_configpolicyaction'),
db.Index('idx_configpolicyaction_policy', 'configurationpolicyid'),
)
def __repr__(self):
return f"<ConfigurationPolicyAction {self.action} mode={self.mode}>"
class BusinessPolicy(BaseModel):
"""An ordered collection of Configuration Policies assigned to a fleet.
Priority orders Business Policies against each other when several match one
endpoint: lower priority number is evaluated first, and the first policy to
speak on a given action name wins (first-match-wins per action).
"""
__tablename__ = 'businesspolicies'
businesspolicyid = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), unique=True, nullable=False)
description = db.Column(db.Text)
# Lower runs first. Ties broken by businesspolicyid for a stable order.
priority = db.Column(db.Integer, default=100, nullable=False)
source = db.Column(db.String(50), default=POLICY_SOURCE_LOCAL, nullable=False)
# createddate, modifieddate, isactive come from BaseModel.
items = db.relationship(
'BusinessPolicyItem',
back_populates='businesspolicy',
cascade='all, delete-orphan',
order_by='BusinessPolicyItem.sortorder',
)
__table_args__ = (
db.Index('idx_businesspolicy_priority', 'priority'),
db.Index('idx_businesspolicy_active', 'isactive'),
)
def __repr__(self):
return f"<BusinessPolicy {self.name} priority={self.priority}>"
class BusinessPolicyItem(BaseModel):
"""Membership of a Configuration Policy in a Business Policy, ordered.
sortorder sets the walk order WITHIN a Business Policy; combined with the
Business Policy priority it gives a fully deterministic fold order for the
effective-policy resolver.
"""
__tablename__ = 'businesspolicyitems'
businesspolicyitemid = db.Column(db.Integer, primary_key=True)
businesspolicyid = db.Column(
db.Integer,
db.ForeignKey('businesspolicies.businesspolicyid', ondelete='CASCADE'),
nullable=False,
)
configurationpolicyid = db.Column(
db.Integer,
db.ForeignKey('configurationpolicies.configurationpolicyid',
ondelete='CASCADE'),
nullable=False,
)
sortorder = db.Column(db.Integer, default=0, nullable=False)
businesspolicy = db.relationship('BusinessPolicy', back_populates='items')
configurationpolicy = db.relationship('ConfigurationPolicy')
__table_args__ = (
db.UniqueConstraint('businesspolicyid', 'configurationpolicyid',
name='uq_businesspolicyitem'),
db.Index('idx_bpitem_business', 'businesspolicyid'),
db.Index('idx_bpitem_config', 'configurationpolicyid'),
)
def __repr__(self):
return (f"<BusinessPolicyItem bp={self.businesspolicyid} "
f"cp={self.configurationpolicyid}>")

View File

@@ -12,8 +12,16 @@ from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.api import db, AssetType from shopdb.api import db, AssetType
from .models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess from .models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
from .models import (
ConfigurationPolicy,
ConfigurationPolicyAction,
BusinessPolicy,
BusinessPolicyItem,
)
from .api import computers_bp from .api import computers_bp
from .policyflag import policy_flag_enabled as _policy_flag_enabled
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Marker stamped on PC->measuringtool "controls" links this collector creates # Marker stamped on PC->measuringtool "controls" links this collector creates
@@ -32,6 +40,14 @@ MEASURINGTOOL_LINK_ORIGIN = 'collector:measuringtool'
# touched. # touched.
MACHINE_LINK_ORIGIN = 'collector:machine' MACHINE_LINK_ORIGIN = 'collector:machine'
# Marker stamped on the PC->machine link built from a CSV import's machine
# number column. A DISTINCT origin from the collector's so the two provenances
# stay separable: an import establishes a link once from a spreadsheet, the
# collector maintains it continuously from the floor, and a site may use one,
# the other, or both. Same archive discipline - only rows carrying this label
# are touched by a re-import.
MACHINE_LINK_IMPORT_ORIGIN = 'import:machine'
# Marker stamped on both links a part-marker PC produces: PC controls marker, # Marker stamped on both links a part-marker PC produces: PC controls marker,
# and marker partof the operation it serves. Same archive discipline again. # and marker partof the operation it serves. Same archive discipline again.
PARTMARKER_LINK_ORIGIN = 'collector:partmarker' PARTMARKER_LINK_ORIGIN = 'collector:partmarker'
@@ -84,12 +100,229 @@ class ComputersPlugin(BasePlugin):
def get_models(self) -> List[Type]: def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes.""" """Return list of SQLAlchemy model classes."""
return [Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess] return [Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess,
ConfigurationPolicy, ConfigurationPolicyAction, BusinessPolicy, BusinessPolicyItem]
def init_app(self, app: Flask, db_instance) -> None: def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app.""" """Initialize plugin with Flask app."""
self._register_import_hook()
logger.info(f"Computers plugin initialized (v{self.meta.version})") logger.info(f"Computers plugin initialized (v{self.meta.version})")
# -- bulk-import machine link -------------------------------------------
def _register_import_hook(self):
"""Own a `machineassetnumber` column on computers.csv.
A site with a spreadsheet knows "this PC drives machine 3015" and wants
to write that number in the PC row, not hand-build an assetrelationships
sheet keyed on ids it never sees. The core importer stays ignorant of
what the column means: it accepts the header, keeps it out of the
computers write, and hands the value to this hook after each PC row is
written, so the PC's asset id exists. Registration is idempotent, so a
plugin reload or a test re-init does not double-fire.
"""
from shopdb.api import register_import_hook
register_import_hook(
'computers',
{'machineassetnumber':
'the asset number of the machine this PC drives (from '
'machines.csv); builds the PC-controls-machine link. A number '
'that has no machine, or one already driven by another PC, is '
'reported as a warning and skipped - the PC still imports.'},
self._import_machine_link)
def _import_machine_link(self, rowvalues, virtualvalues, line):
"""Build a PC->machine controls link from an import row's machine number.
Runs inside the importer's transaction - a dry run rolls it back - so it
reuses the collector's mature _sync_machine_link rather than inventing a
second linker, but under the import origin (so an import never archives
a collector's rows and vice versa) and in preview mode (so a bulk import
never emails an alert per row; the findings are the report's amber
warnings instead).
Returns RowWarning list, never raises: a machine number that resolves to
no asset is an amber note beside a PC that still imports, not a failed
file. Every warning names the PC, the number, and the one fix.
"""
from shopdb.api import RowWarning
machinenumber = (virtualvalues.get('machineassetnumber') or '').strip()
if not machinenumber:
return []
assetid = rowvalues.get('assetid')
comp = Computer.query.filter_by(assetid=assetid).first() if assetid else None
if comp is None:
# The PC row was written but its Computer extension is not readable
# here - nothing to link to. Amber, not fatal.
return [RowWarning(
line, 'machineassetnumber',
reason='PC row not found to link to machine {!r}'.format(
machinenumber),
action='re-run the import; if it recurs, check the computers '
'row imported',
target=machinenumber)]
hostname = comp.hostname or (assetid and str(assetid)) or '?'
# IN-FILE FAN-OUT GUARD. Two rows in one computers.csv both naming the
# same machine is a spreadsheet mistake - a copied cell, a wrong
# workstation number - not a swap. But _incumbent_has_yielded reads a
# freshly-imported PC (no lastreporteddate) as having yielded, so
# without this the SECOND row would silently archive the first row's
# link and steal the machine, and the file would import clean with the
# wrong PC on the bay. So the first claim on a machine this run wins and
# is remembered; a later row for the same machine is warned and skipped.
# The claim set lives on db.session.info, which Flask-SQLAlchemy resets
# at request teardown, so it is scoped to exactly this one import and
# never leaks into the next.
machine = self._asset_by_number(machinenumber)
if machine is not None and machine.assetid == assetid:
# The number names this very PC. Happens on a PC first seen before
# the machine-number convention, whose own asset number IS the
# machine number. Skip - a PC does not control itself.
return [RowWarning(
line, 'machineassetnumber',
reason='machine number {!r} is this PC\'s own asset number; '
'not linked'.format(machinenumber),
action='give this PC a hostname-based asset number, or point '
'machineassetnumber at the machine it drives',
subject=hostname, target=machinenumber)]
if machine is not None:
# A location-only machine is a virtual bay marker, not a real
# machine, so nothing controls it. Skip rather than build a link
# that would read as a PC driving a location.
skip = self._location_only_skip(
machine, hostname, machinenumber, line)
if skip is not None:
return [skip]
if machine is not None and machine.assetid != assetid:
# ALREADY-CONTROLS-DIFFERENT. Unlike the collector - which reads a
# new machine number as a PC moved to a new bay and silently
# re-bays it - an import naming a machine when this PC already
# actively controls a DIFFERENT one is far more likely a wrong
# workstation number in the sheet than a real move. Warn and skip
# rather than archive a link a person may have set on purpose.
other = self._active_controlled_machine(assetid, machine.assetid)
if other is not None:
return [RowWarning(
line, 'machineassetnumber',
reason='{} already controls machine {}; not moved to {}'
.format(hostname, other, machine.assetnumber),
action='if this PC really moved, clear the existing link '
'first; otherwise correct the machine number on '
'this row',
subject=hostname, target=machinenumber)]
if machine is not None and machine.assetid != assetid:
claimed = db.session.info.setdefault('import_machine_claims', {})
# Keyed by machine asset id -> (claimant PC asset id, its hostname).
# The id is what the guard compares on (the same PC re-stating its
# own link is not a conflict); the hostname is only for the message.
firstclaimant = claimed.get(machine.assetid)
if firstclaimant is not None and firstclaimant[0] != assetid:
return [RowWarning(
line, 'machineassetnumber',
reason='machine {} was already claimed by {} earlier in '
'this import; {} not linked'.format(
machine.assetnumber, firstclaimant[1], hostname),
action='a machine drives one PC - correct the machine '
'number on one of these rows so each PC names its '
'own machine',
subject=hostname, target=machinenumber)]
claimed[machine.assetid] = (assetid, hostname)
# _sync_machine_link appends plain strings to its warnings list. Collect
# them and translate to structured, actionable RowWarnings so the UI can
# show the PC, the machine number, and the corrective step.
rawwarnings = []
self._sync_machine_link(
comp, machinenumber, rawwarnings,
origin_label=MACHINE_LINK_IMPORT_ORIGIN, preview=True)
out = []
for text in rawwarnings:
out.append(RowWarning(
line, 'machineassetnumber',
reason=text,
action=self._machine_link_action(text, machinenumber),
subject=hostname, target=machinenumber))
return out
@staticmethod
def _active_controlled_machine(pcassetid, excludeassetid):
"""Asset number of a machine this PC actively controls under the import
origin, other than excludeassetid, or None.
Scoped to MACHINE_LINK_IMPORT_ORIGIN so an import only reasons about its
own links: a collector link or a hand-made one is the collector's or a
person's to move, not the import's to block on.
"""
from shopdb.api import AssetRelationship, RelationshipType, Asset
controls = RelationshipType.query.filter_by(
relationshiptype='controls').first()
if not controls:
return None
rel = AssetRelationship.query.filter(
AssetRelationship.sourceassetid == pcassetid,
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
AssetRelationship.label == MACHINE_LINK_IMPORT_ORIGIN,
AssetRelationship.targetassetid != excludeassetid,
AssetRelationship.isactive.is_(True),
).first()
if rel is None:
return None
asset = db.session.get(Asset, rel.targetassetid)
return asset.assetnumber if asset else str(rel.targetassetid)
@staticmethod
def _location_only_skip(machine, hostname, machinenumber, line):
"""A RowWarning if the resolved asset is a location-only marker, else None.
A machine flagged islocationonly is a virtual bay, not a real machine,
so a PC does not control it. Guarded behind the machines plugin being
installed - a site without it cannot have a location-only machine, and
the import must not fail because the model is absent.
"""
from shopdb.api import RowWarning
try:
from plugins.machines.models import Machine
except ImportError:
return None
row = Machine.query.filter_by(assetid=machine.assetid).first()
if row is None or not row.islocationonly:
return None
return RowWarning(
line, 'machineassetnumber',
reason='machine {} is a location-only marker, not a real machine; '
'not linked'.format(machine.assetnumber),
action='point machineassetnumber at a real machine, or clear it '
'for this PC',
subject=hostname, target=machinenumber)
@staticmethod
def _machine_link_action(text, machinenumber):
"""The one corrective step for a machine-link warning, by its shape."""
lowered = text.lower()
if 'no asset for machine number' in lowered:
return ('create the machine asset with number {!r} (import '
'machines.csv first), or correct the number on this PC '
'row'.format(machinenumber))
if 'still held by' in lowered:
return ('confirm which PC drives this machine; retire or move the '
'old PC off In Use to hand the machine over')
if 'taken over from' in lowered:
return ('expected on a PC swap; set the old PC to Retired, '
'Inventory or In Repair')
if 'relationship type missing' in lowered:
return 'run flask seed reference-data, then re-import'
return 'review this PC and its machine number'
# -- ADR-006 collector contract ----------------------------------------- # -- ADR-006 collector contract -----------------------------------------
def get_collector_schema(self) -> Optional[Dict]: def get_collector_schema(self) -> Optional[Dict]:
@@ -173,6 +406,36 @@ class ComputersPlugin(BasePlugin):
'site\'s URL and ranges', 'site\'s URL and ranges',
'position': 26, 'position': 26,
}, },
] + self._policy_settings_cards()
def _policy_settings_cards(self):
"""Policy admin cards, shown only while the feature flag is on.
Gating the cards on the switch keeps the settings menu honest: the
Configuration/Business Policy screens appear exactly when the feature is
enabled and vanish on rollback, matching the 404 the routes return.
"""
if not self._configpolicies_enabled_safe():
return []
return [
{
'group': 'Computers',
'to': '/settings/configuration-policies',
'icon': 'shield',
'title': 'Configuration policies',
'description': 'Reusable rules binding endpoint actions to PC '
'types and applications',
'position': 27,
},
{
'group': 'Computers',
'to': '/settings/business-policies',
'icon': 'layers',
'title': 'Business policies',
'description': 'Ordered collections of configuration policies '
'assigned by priority',
'position': 28,
},
] ]
def get_settings_defaults(self) -> List[dict]: def get_settings_defaults(self) -> List[dict]:
@@ -214,8 +477,41 @@ class ComputersPlugin(BasePlugin):
'Off while machine numbers are shared between ' 'Off while machine numbers are shared between '
'PCs; the collector response still warns.', 'PCs; the collector response still warns.',
}, },
{
'key': 'computers_configpolicies_enabled',
'value': 'false',
'valuetype': 'boolean',
'category': 'computers',
'description': 'Master switch for RMM-style Configuration and '
'Business Policies. Off by default: models exist '
'but the CRUD routes, WebUI, and endpoint '
'enforcement stay inert so the whole feature can '
'be rolled back without a schema change.',
},
] ]
def _configpolicies_enabled(self):
"""Whether the Configuration/Business Policy feature is turned on.
Off by default. This is the single exit gate for the policy scope: the
tables and models load regardless (SQLAlchemy needs them), but every
route, panel, and enforcement path checks this first and no-ops while it
is false, so the feature can be disabled without a migration.
"""
return _policy_flag_enabled()
def _configpolicies_enabled_safe(self):
"""Flag read that never raises when the settings table is absent.
get_settings_cards can be called in contexts without a database (the
plugin contract tests build hooks against a bare app), so a missing
settings table must read as "off" rather than propagate.
"""
try:
return _policy_flag_enabled()
except Exception:
return False
def _machinelink_alerts_enabled(self): def _machinelink_alerts_enabled(self):
"""Whether the PC-to-machine alerts may be sent. """Whether the PC-to-machine alerts may be sent.
@@ -534,7 +830,8 @@ class ComputersPlugin(BasePlugin):
'with POST /api/printers/assignments/seed-from-observed.' 'with POST /api/printers/assignments/seed-from-observed.'
% ' and '.join(sent)) % ' and '.join(sent))
def _sync_machine_link(self, comp, machinenumber, warnings): def _sync_machine_link(self, comp, machinenumber, warnings,
origin_label=MACHINE_LINK_ORIGIN, preview=False):
"""Link a PC to the machine it drives, from the reported machine number. """Link a PC to the machine it drives, from the reported machine number.
Until this existed the machine number was collected and then discarded, Until this existed the machine number was collected and then discarded,
@@ -556,6 +853,15 @@ class ComputersPlugin(BasePlugin):
cadence, alerting on every pass. The incumbent therefore keeps the cadence, alerting on every pass. The incumbent therefore keeps the
machine while it is still alive, and the challenger is recorded as a machine while it is still alive, and the challenger is recorded as a
dormant link. See _incumbent_has_yielded for what alive means. dormant link. See _incumbent_has_yielded for what alive means.
origin_label separates provenance: the collector stamps
MACHINE_LINK_ORIGIN, a CSV import stamps MACHINE_LINK_IMPORT_ORIGIN, and
each only ever archives its own rows so the two sources coexist.
preview=True is for a dry-run import: DB writes still happen in the
caller's transaction (which it rolls back), but the alert fan-out - the
one side effect a rollback cannot undo - is suppressed so a preview
never emails anyone.
""" """
from shopdb.api import AssetRelationship, RelationshipType, Asset from shopdb.api import AssetRelationship, RelationshipType, Asset
@@ -587,7 +893,7 @@ class ComputersPlugin(BasePlugin):
mine = AssetRelationship.query.filter( mine = AssetRelationship.query.filter(
AssetRelationship.sourceassetid == pcasset.assetid, AssetRelationship.sourceassetid == pcasset.assetid,
AssetRelationship.relationshiptypeid == controls.relationshiptypeid, AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
AssetRelationship.label == MACHINE_LINK_ORIGIN, AssetRelationship.label == origin_label,
).all() ).all()
link = None link = None
@@ -597,11 +903,23 @@ class ComputersPlugin(BasePlugin):
else: else:
rel.isactive = False rel.isactive = False
# The unique constraint on assetrelationships is (source, target, type)
# and does NOT include label, so an import (import:machine) and the
# collector (collector:machine) cannot each hold their OWN row for the
# same PC->machine control. If our label-scoped lookup found nothing but
# a row for this exact triple exists under the other provenance, adopt
# it rather than insert a duplicate that would violate the constraint.
if link is None:
link = AssetRelationship.query.filter_by(
sourceassetid=pcasset.assetid,
targetassetid=machine.assetid,
relationshiptypeid=controls.relationshiptypeid).first()
# Whoever actively holds this machine now, if it is not this PC. # Whoever actively holds this machine now, if it is not this PC.
held = AssetRelationship.query.filter( held = AssetRelationship.query.filter(
AssetRelationship.targetassetid == machine.assetid, AssetRelationship.targetassetid == machine.assetid,
AssetRelationship.relationshiptypeid == controls.relationshiptypeid, AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
AssetRelationship.label == MACHINE_LINK_ORIGIN, AssetRelationship.label == origin_label,
AssetRelationship.sourceassetid != pcasset.assetid, AssetRelationship.sourceassetid != pcasset.assetid,
AssetRelationship.isactive.is_(True), AssetRelationship.isactive.is_(True),
).all() ).all()
@@ -624,9 +942,10 @@ class ComputersPlugin(BasePlugin):
sourceassetid=pcasset.assetid, sourceassetid=pcasset.assetid,
targetassetid=machine.assetid, targetassetid=machine.assetid,
relationshiptypeid=controls.relationshiptypeid, relationshiptypeid=controls.relationshiptypeid,
label=MACHINE_LINK_ORIGIN, label=origin_label,
isactive=False)) isactive=False))
self._alert_machine_contested(names, comp, machine) if not preview:
self._alert_machine_contested(names, comp, machine)
else: else:
link.isactive = False link.isactive = False
return [{'assetid': machine.assetid, return [{'assetid': machine.assetid,
@@ -640,7 +959,7 @@ class ComputersPlugin(BasePlugin):
sourceassetid=pcasset.assetid, sourceassetid=pcasset.assetid,
targetassetid=machine.assetid, targetassetid=machine.assetid,
relationshiptypeid=controls.relationshiptypeid, relationshiptypeid=controls.relationshiptypeid,
label=MACHINE_LINK_ORIGIN, label=origin_label,
isactive=True) isactive=True)
db.session.add(link) db.session.add(link)
else: else:
@@ -654,7 +973,8 @@ class ComputersPlugin(BasePlugin):
warnings.append( warnings.append(
'machine {} was taken over from {}; check that PC'.format( 'machine {} was taken over from {}; check that PC'.format(
machine.assetnumber, oldname)) machine.assetnumber, oldname))
self._alert_pc_superseded(oldname, comp, machine) if not preview:
self._alert_pc_superseded(oldname, comp, machine)
return [{'assetid': machine.assetid, return [{'assetid': machine.assetid,
'machinenumber': machine.assetnumber, 'machinenumber': machine.assetnumber,
@@ -757,8 +1077,8 @@ class ComputersPlugin(BasePlugin):
# #
# A row made by hand carries neither label and is deliberately NOT # A row made by hand carries neither label and is deliberately NOT
# counted: a person's link is not the collector's to archive. # counted: a person's link is not the collector's to archive.
ourlabels = (label, MACHINE_LINK_ORIGIN, MEASURINGTOOL_LINK_ORIGIN, ourlabels = (label, MACHINE_LINK_ORIGIN, MACHINE_LINK_IMPORT_ORIGIN,
PARTMARKER_LINK_ORIGIN) MEASURINGTOOL_LINK_ORIGIN, PARTMARKER_LINK_ORIGIN)
others = AssetRelationship.query.filter( others = AssetRelationship.query.filter(
AssetRelationship.targetassetid == deviceasset.assetid, AssetRelationship.targetassetid == deviceasset.assetid,
AssetRelationship.relationshiptypeid == controls.relationshiptypeid, AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
@@ -1310,15 +1630,17 @@ class ComputersPlugin(BasePlugin):
sourceassetid=pcasset.assetid, sourceassetid=pcasset.assetid,
targetassetid=adopted.assetid, targetassetid=adopted.assetid,
relationshiptypeid=controls.relationshiptypeid).first() relationshiptypeid=controls.relationshiptypeid).first()
if claimed is not None and claimed.label == MACHINE_LINK_ORIGIN: if claimed is not None and claimed.label in (
MACHINE_LINK_ORIGIN, MACHINE_LINK_IMPORT_ORIGIN):
# LEAVE IT ALONE. On a CMM the instrument IS the reported bay, # LEAVE IT ALONE. On a CMM the instrument IS the reported bay,
# so the machine sync has already made the row for this exact # so the machine sync (or a CSV import's machine link) has
# triple - and it finds its own rows BY that label. Relabelling # already made the row for this exact triple - and each finds
# hid the link from it, so the next cycle built a second row for # its own rows BY that label. Relabelling hid the link from it,
# the same triple and MySQL rejected it: the PC reported 200, # so the next cycle built a second row for the same triple and
# then 500 forever after. The row's lifecycle (including staying # MySQL rejected it: the PC reported 200, then 500 forever
# dormant while a bay is contested) belongs to the machine sync; # after. The row's lifecycle (including staying dormant while a
# adoption only needs the identity, which it has. # bay is contested) belongs to the machine sync; adoption only
# needs the identity, which it has.
pass pass
elif claimed is not None: elif claimed is not None:
# Relabelling is what adoption MEANS for any other row: the link # Relabelling is what adoption MEANS for any other row: the link

View File

@@ -0,0 +1,19 @@
"""Single source of truth for the Configuration/Business Policy feature gate.
Kept in its own module (not on the plugin class) so the CRUD routes, the panel
surface, and the endpoints enforcement path can all consult the same switch
without importing ComputersPlugin. The flag is the computers_configpolicies_enabled
Setting row seeded by the plugin's get_settings_defaults; while it is off, every
policy code path no-ops, which is the exit gate for rolling the feature back.
"""
def policy_flag_enabled():
"""True when computers_configpolicies_enabled is set truthy."""
from shopdb.api import Setting
setting = Setting.query.filter_by(
key='computers_configpolicies_enabled').first()
if not setting:
return False
return (setting.value or '').strip().lower() in ('true', '1', 'yes')

View File

@@ -0,0 +1 @@
"""Computers plugin services: pure policy logic (scope + resolution)."""

View File

@@ -0,0 +1,168 @@
"""Effective-policy resolution: the single swappable policy entry point.
This module answers one question for the rest of the system: "given an endpoint,
what actions apply and how?" It is the boundary the endpoints allowlist seam
calls - never the policy tables directly - so the policy ASSEMBLY ENGINE can be
swapped without touching enforcement. Today it folds the local computers-plugin
tables; a future GE-Enforce integration can replace build_effective_policy (or
feed it) and every consumer keeps working unchanged.
Resolution model (per the design):
- Build an endpoint context (PC type + installed appids) from the Computer row.
- Walk every active Business Policy in priority order (lower first, ties by id).
- Within each, walk its Configuration Policies in sortorder.
- A Configuration Policy contributes only if its scope matches the endpoint.
- For each action name, FIRST MATCH WINS: once an action is decided it is not
overridden by a later, lower-priority policy.
The result is an EffectivePolicy: a flat, resolved map of action name -> decision
(mode desired/gate, plus desiredstate/params), which is exactly what enforcement
and the allowlist need.
"""
from .policyscope import evaluate_scope
class EffectiveAction:
"""One resolved action decision for an endpoint."""
__slots__ = ('action', 'mode', 'desiredstate', 'params', 'intervalminutes',
'businesspolicyid', 'configurationpolicyid')
def __init__(self, action, mode, desiredstate, params, intervalminutes,
businesspolicyid, configurationpolicyid):
self.action = action
self.mode = mode
self.desiredstate = desiredstate
self.params = params
self.intervalminutes = intervalminutes
self.businesspolicyid = businesspolicyid
self.configurationpolicyid = configurationpolicyid
def to_dict(self):
return {
'action': self.action,
'mode': self.mode,
'desiredstate': self.desiredstate,
'params': self.params,
'intervalminutes': self.intervalminutes,
'businesspolicyid': self.businesspolicyid,
'configurationpolicyid': self.configurationpolicyid,
}
class EffectivePolicy:
"""The folded, resolved policy for a single endpoint.
actions maps action name -> EffectiveAction (first-match-wins already
applied). Convenience views expose the gate allowlist and desired-state set
that enforcement and the endpoints seam consume.
"""
def __init__(self, actions):
self.actions = actions
def allowed_actions(self):
"""Set of action names permitted on this endpoint (gate + desired)."""
return set(self.actions.keys())
def desired_actions(self):
"""action name -> EffectiveAction for mode == 'desired' only."""
return {name: eff for name, eff in self.actions.items()
if eff.mode == 'desired'}
def to_dict(self):
return {name: eff.to_dict() for name, eff in self.actions.items()}
def build_endpoint_context(computer):
"""Resolve a Computer row into the context the scope evaluator needs.
Returns {'computertypeid': int|None, 'appids': set[int]}.
"""
appids = set()
for link in getattr(computer, 'installedapps', None) or ():
if getattr(link, 'isactive', True) and link.appid is not None:
appids.add(link.appid)
return {
'computertypeid': getattr(computer, 'computertypeid', None),
'appids': appids,
}
def _iter_matching_configpolicies(business_policies, context):
"""Yield (businesspolicyid, ConfigurationPolicy) in deterministic order.
Business Policies by (priority, businesspolicyid); their items by sortorder.
Only active policies whose scope matches the context are yielded.
"""
ordered_bp = sorted(
business_policies,
key=lambda bp: (bp.priority, bp.businesspolicyid),
)
for business_policy in ordered_bp:
if not business_policy.isactive:
continue
for item in sorted(business_policy.items,
key=lambda it: (it.sortorder, it.businesspolicyitemid)):
config_policy = item.configurationpolicy
if config_policy is None or not config_policy.isactive:
continue
if evaluate_scope(config_policy.scopejson, context):
yield business_policy.businesspolicyid, config_policy
def build_effective_policy(computer, business_policies):
"""Fold matching policies into an EffectivePolicy for one endpoint.
computer: the Computer row. business_policies: iterable of BusinessPolicy
(with items/configurationpolicy loaded). Pure given its inputs so it stays
unit-testable and reusable by an alternate assembly engine.
"""
context = build_endpoint_context(computer)
resolved = {}
for businesspolicyid, config_policy in _iter_matching_configpolicies(
business_policies, context):
for action_row in config_policy.actions:
if not action_row.isactive:
continue
name = action_row.action
if name in resolved:
# First match wins - a higher-priority policy already decided
# this action; later policies do not override it.
continue
resolved[name] = EffectiveAction(
action=name,
mode=action_row.mode,
desiredstate=action_row.desiredstate,
params=action_row.params,
intervalminutes=action_row.intervalminutes,
businesspolicyid=businesspolicyid,
configurationpolicyid=config_policy.configurationpolicyid,
)
return EffectivePolicy(resolved)
def effective_policy_for(assetid):
"""Resolve the effective policy for an endpoint by its assetid.
This is the public entry point the endpoints plugin calls. It returns an
empty EffectivePolicy when the feature is off or the asset is not a known
computer, so callers get a safe "no policy" answer without special-casing.
NOTE for GE-Enforce interop: this function is the seam. To hand policy
assembly to GE-Enforce later, replace the body below (or dispatch on source)
while keeping this signature and the EffectivePolicy return type intact.
"""
from ..policyflag import policy_flag_enabled
if not policy_flag_enabled():
return EffectivePolicy({})
from ..models import Computer, BusinessPolicy
computer = Computer.query.filter_by(assetid=assetid).first()
if computer is None:
return EffectivePolicy({})
business_policies = BusinessPolicy.query.filter_by(isactive=True).all()
return build_effective_policy(computer, business_policies)

View File

@@ -0,0 +1,96 @@
"""Boolean scope evaluation for Configuration Policies.
A policy's scopejson is a small typed boolean AST over two endpoint dimensions,
PC type and installed application. This module is the ONLY place that interprets
that AST: it validates the shape (known node types, bounded depth) and evaluates
a validated tree against a resolved endpoint context.
Node grammar (all dict nodes carry a "type"):
{"type": "and", "nodes": [<node>, ...]}
{"type": "or", "nodes": [<node>, ...]}
{"type": "not", "node": <node>}
{"type": "pctype", "id": <computertypeid:int>}
{"type": "app", "id": <appid:int>}
{"type": "true"} # matches everything (an unscoped/global policy)
Kept pure and dependency-free (no db, no Flask) so it is trivially unit-tested
and so a future GE-Enforce assembly engine can reuse the exact same evaluator on
the exact same AST when it composes policies.
"""
# Bound the tree so a hand-crafted or imported AST cannot cause pathological
# recursion. Ten levels is far past any policy a human would author.
MAX_SCOPE_DEPTH = 10
_LOGICAL = {'and', 'or', 'not'}
_LEAF = {'pctype', 'app', 'true'}
class ScopeError(ValueError):
"""Raised when a scope AST is malformed. Carries a human-readable reason."""
def validate_scope(node, _depth=0):
"""Validate a scope AST, raising ScopeError on any problem.
Returns None on success. Call this at write time (CRUD) so a bad tree never
reaches the database; evaluate_scope then trusts the stored shape.
"""
if _depth > MAX_SCOPE_DEPTH:
raise ScopeError(f'scope nested deeper than {MAX_SCOPE_DEPTH} levels')
if not isinstance(node, dict):
raise ScopeError('scope node must be an object')
ntype = node.get('type')
if ntype not in _LOGICAL and ntype not in _LEAF:
raise ScopeError(f'unknown scope node type: {ntype!r}')
if ntype in ('and', 'or'):
nodes = node.get('nodes')
if not isinstance(nodes, list) or not nodes:
raise ScopeError(f'{ntype} requires a non-empty "nodes" list')
for child in nodes:
validate_scope(child, _depth + 1)
elif ntype == 'not':
child = node.get('node')
if child is None:
raise ScopeError('not requires a "node"')
validate_scope(child, _depth + 1)
elif ntype in ('pctype', 'app'):
ident = node.get('id')
if not isinstance(ident, int) or isinstance(ident, bool):
raise ScopeError(f'{ntype} requires an integer "id"')
# 'true' carries no payload.
def evaluate_scope(node, context):
"""Evaluate a validated scope AST against an endpoint context.
context is expected to expose:
context['computertypeid'] -> int or None
context['appids'] -> set/collection of installed appids
Returns True when the endpoint falls in scope. An empty/None scope means the
policy is unscoped and matches everything (same as {"type": "true"}), which
keeps a freshly-created policy usable before a scope is drawn.
"""
if node is None:
return True
ntype = node['type']
if ntype == 'true':
return True
if ntype == 'and':
return all(evaluate_scope(child, context) for child in node['nodes'])
if ntype == 'or':
return any(evaluate_scope(child, context) for child in node['nodes'])
if ntype == 'not':
return not evaluate_scope(node['node'], context)
if ntype == 'pctype':
return context.get('computertypeid') == node['id']
if ntype == 'app':
return node['id'] in (context.get('appids') or ())
# validate_scope should have caught this; be strict rather than silently
# matching an endpoint against an unknown node.
raise ScopeError(f'unknown scope node type at evaluation: {ntype!r}')

View File

@@ -0,0 +1,274 @@
# Endpoint agent contract (C# build reference)
Companion to DESIGN-DECISIONS-agent.md. This is the concrete wire contract the
future C# agent targets, derived from `EndpointsPlugin.get_collector_schema()`
and the generic collector dispatcher in `shopdb/core/api/collector.py`. When the
schema changes, update this file in the same change.
## 1. Transport
- Method/URL: `POST {baseurl}/api/collector/endpoints`
- Auth header: `X-API-Key: <collector key>` (Bearer token also accepted). The
key is a UI-managed, endpoints-only service token minted from Settings ->
Endpoint agent key (section 6). A bootstrap `.env` collector key still works
until the first UI key is generated, then it is switched off (section 6). No
querystring fallback.
- Body: `application/json`, a single flat object (NOT an array). One POST per
report per endpoint.
- Identity field: `hostname` (required, case-insensitive).
## 2. Request payload
Only `hostname` is required. Every other field is optional; the server upserts
only the keys that are present, so a lightweight report can omit inventory
arrays. `adopt` is first-contact only.
```jsonc
{
"hostname": "SHOP-PC-014", // required, resolved to computers.hostname
"adopt": true, // first run ONLY; delegates PC creation to computers plugin
"loggedinuser": "j.smith",
"serialnumber": "5CD1234ABC", // adoption subset (forwarded to computers)
"pctype": "CNC Controller", // adoption subset
"machinenumber": "12345", // adoption subset; "" or "9999" == none
"agentversion": "1.0.0",
"lastboottime": "2025-01-14T06:12:00Z", // ISO-8601 UTC
"uptimeseconds": 86400,
"idleseconds": 120,
"cpupercent": 12.5,
"memorypercent": 47.0,
"services": [ /* opaque array, stored as JSON */ ],
"ports": [ /* opaque array */ ],
"interfaces": [
{
"alias": "Intel(R) Ethernet Connection", // adapter description
"ipv4": "10.20.30.40", // "" if none
"mac": "AA:BB:CC:DD:EE:FF",
"gateway": "10.20.30.1", // "" for the static machine-IPS NIC
"subnet": "255.255.255.0",
"dhcp": false
}
],
"scheduledtasks": [ /* opaque array */ ],
"events": [
{
"eventid": 6008, // required per item; non-catalog IDs are dropped, not errored
"eventchannel": "System",
"eventsource": "EventLog",
"eventtime": "2025-01-14T06:11:58Z",
"eventmessage": "The previous system shutdown was unexpected.",
"fields": { "any": "extra" } // opaque object, stored as JSON
}
]
}
```
Notes:
- `adopt=true` is sent ONLY by the installer's first run. Steady-state reports
omit it (or send `false`). After adoption, an unknown hostname is a hard
validation error again (DESIGN-DECISIONS-agent.md 1a).
- The adoption subset (`serialnumber`, `pctype`, `machinenumber`, plus
`loggedinuser`) is forwarded to the `computers` collector so the newly created
PC is not a bare shell. Send them on first contact.
- `services`/`ports`/`interfaces`/`scheduledtasks` are stored verbatim as JSON;
the server does not validate their inner shape yet. Keep them stable so the UI
can render them later. `interfaces` reports EVERY hardware NIC (not just the
primary) so the gatewayless static NIC that links a PC to its machine's IPS is
preserved (DESIGN-DECISIONS-agent.md 9.3); each entry is
`{alias, ipv4, mac, gateway, subnet, dhcp}`.
- `machinenumber` is discovered on-box by the agent from the eDNC registry
(`HKLM\SOFTWARE\[WOW6432Node\]GE Aircraft Engines\DNC\General:MachineNo`) or
the UDC `udc_settings_*.json` (`GeneralSettings.MachineNumber`), in that order
(DESIGN-DECISIONS-agent.md 9.4/9.5). `""` or `"9999"` means none.
## 3. Response
Success (HTTP 200), ADR-006 envelope:
```jsonc
{
"status": "ok",
"action": "created", // "created" first time, "updated" thereafter
"assetid": 4821,
"identityvalue": "SHOP-PC-014",
"warnings": [
"adopted new computer for hostname: 'SHOP-PC-014'" // present only on adoption
]
}
```
Validation failure (HTTP 400) - e.g. unknown hostname with `adopt` absent:
```jsonc
{ "status": "error", "error": { "code": "VALIDATION_ERROR",
"message": "could not resolve hostname to a computer: 'SHOP-PC-014'" } }
```
Agent handling: treat non-200 as retryable except a `VALIDATION_ERROR` that
names an unresolved hostname on a NON-first-contact report - that means the PC
was removed server-side; surface it, do not spin.
## 4. C# DTOs
Match the JSON keys EXACTLY (lowercase, no underscores) to satisfy the naming
rule; use attributes so C# properties can stay PascalCase without a camelCase
serializer changing the wire keys.
```csharp
using System.Text.Json.Serialization;
public sealed class EndpointReport
{
[JsonPropertyName("hostname")] public string Hostname { get; set; } = "";
[JsonPropertyName("adopt")] public bool? Adopt { get; set; }
[JsonPropertyName("loggedinuser")] public string? LoggedInUser { get; set; }
[JsonPropertyName("serialnumber")] public string? SerialNumber { get; set; }
[JsonPropertyName("pctype")] public string? PcType { get; set; }
[JsonPropertyName("machinenumber")] public string? MachineNumber { get; set; }
[JsonPropertyName("agentversion")] public string? AgentVersion { get; set; }
[JsonPropertyName("lastboottime")] public DateTimeOffset? LastBootTime { get; set; }
[JsonPropertyName("uptimeseconds")] public long? UptimeSeconds { get; set; }
[JsonPropertyName("idleseconds")] public long? IdleSeconds { get; set; }
[JsonPropertyName("cpupercent")] public double? CpuPercent { get; set; }
[JsonPropertyName("memorypercent")] public double? MemoryPercent { get; set; }
[JsonPropertyName("services")] public object[]? Services { get; set; }
[JsonPropertyName("ports")] public object[]? Ports { get; set; }
[JsonPropertyName("interfaces")] public object[]? Interfaces { get; set; }
[JsonPropertyName("scheduledtasks")]public object[]? ScheduledTasks { get; set; }
[JsonPropertyName("events")] public EndpointEventRecord[]? Events { get; set; }
}
public sealed class EndpointEventRecord
{
[JsonPropertyName("eventid")] public int EventId { get; set; }
[JsonPropertyName("eventchannel")] public string? EventChannel { get; set; }
[JsonPropertyName("eventsource")] public string? EventSource { get; set; }
[JsonPropertyName("eventtime")] public DateTimeOffset? EventTime { get; set; }
[JsonPropertyName("eventmessage")] public string? EventMessage { get; set; }
[JsonPropertyName("fields")] public Dictionary<string, object>? Fields { get; set; }
}
public sealed class CollectorResponse
{
[JsonPropertyName("status")] public string Status { get; set; } = "";
[JsonPropertyName("action")] public string? Action { get; set; }
[JsonPropertyName("assetid")] public int? AssetId { get; set; }
[JsonPropertyName("identityvalue")] public string? IdentityValue { get; set; }
[JsonPropertyName("warnings")] public string[]? Warnings { get; set; }
}
```
Serializer settings: `DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull`
so omitted fields are not sent (server upserts only present keys). Send UTC ISO-8601.
## 5. "Build site agent" settings flow (build cadence)
Goal: an operator in ShopDB Settings produces an instance-locked `.exe` that
knows its server URL and its collector key, needing no manual config on the
client.
1. Settings -> "Build site agent": operator picks/creates a collector-scoped
managed token (the same token accepted by `X-API-Key` on
`/api/collector/endpoints`). This binds the build to one ShopDB instance.
2. Server stamps a small config (base URL + token + reporting cadence) into the
agent build artifact. Instance-locked = the token only works against that
server, and the server can revoke it to kill a leaked build.
3. First run on a client:
- Collect the ShopDB PowerShell asset-collector data set (minus the CSV
emit): hostname, logged-in user, serial number, pc type, machine number,
boot time, plus the curated event tail.
- POST once with `adopt=true`. The endpoints plugin delegates PC creation to
the `computers` collector, then writes the first `EndpointState` in the
same commit. The endpoint appears in the Fleet UI immediately - no second
call, no separate enrollment endpoint.
4. Subsequent runs POST on the configured cadence with `adopt` omitted; strict
hostname resolution applies from then on.
Open item (from DESIGN-DECISIONS-agent.md 1a): whether `adopt` stays a
per-report flag or is promoted to a site setting (strict vs self-adopting
fleet). Default proposed: agent sends `adopt=true` on first run only.
## 6. UI-managed agent key (kill switch)
The endpoint agent authenticates with a single UI-managed key scoped to ONLY
`collector.ingest.endpoints`, so rotating or revoking it stops all
endpoint-agent traffic without affecting the computers or geenforce collectors.
- Settings -> Endpoint agent key (admin only) generates the key. The full secret
is shown ONCE at generation and stored only as a hash afterwards; configure it
as the agent `X-API-Key`.
- Generating a key is a hard cutover: any previous endpoint key is revoked in the
same request, and the bootstrap `.env` fallback for endpoints is switched off
(`plugin.endpoints.envkeyfallback` -> false), so the managed key becomes the
sole credential.
- Regenerate to rotate (old key dies immediately). Revoke to stop all
endpoint-agent reporting until a new key is generated - the kill switch for a
leaked agent.
- Backend: `GET/POST /api/endpoints/agentkey`, `POST
/api/endpoints/agentkey/regenerate`, `DELETE /api/endpoints/agentkey`. These
use the `mint_service_token`/`list_service_tokens`/`revoke_service_tokens`
contract helpers (shopdb.api, contract 0.22.0).
The bootstrap `.env` key (`COLLECTOR_API_KEY[_ENDPOINTS]`) remains only as a
first-run path before a UI key exists; it cannot be the runtime kill switch
because container env is injected at boot and cannot be rotated live.
## 7. Command channel (WebUI -> agent)
The WebUI can send an endpoint an ALLOWLISTED command (never an arbitrary
command string). Delivery is agent-initiated long-poll, so shop-floor PCs open
no inbound port: the agent makes an outbound request the server holds open until
a command is queued, runs it locally, and posts the result back. All three
routes reuse the same endpoints collector key (section 6) via `X-API-Key`, so
the command channel shares the kill switch and adds no second secret.
Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
- `mtconnect_start` - run the MTConnect UDC agent start script
(`C:\ProgramData\UDC\MTConnect_UDC\Agent\run.bat`).
- `mtconnect_stop` - stop the running MTConnect UDC agent process.
- `mtconnect_status` - report whether the MTConnect UDC agent is running.
### 7.1 Claim the next command (long-poll)
`GET {baseurl}/api/endpoints/commands/next?hostname=<hostname>`
- Auth: `X-API-Key` (endpoints collector key).
- The server holds the request open up to ~25s. If a command is queued it is
atomically claimed (marked running) and returned; only one poll ever claims a
given command.
- Command queued (HTTP 200):
```jsonc
{ "status": "ok", "data": { "endpointcommandid": 91, "action": "mtconnect_start" } }
```
- Nothing queued within the hold window: HTTP 204 (no body). Re-poll.
### 7.2 Report a command result
`POST {baseurl}/api/endpoints/commands/<endpointcommandid>/result`
- Auth: `X-API-Key` (endpoints collector key).
- Body:
```jsonc
{
"succeeded": true, // required; false marks the command failed
"message": "MTConnect started", // short summary, optional (<= 2000 chars)
"mtconnectrunning": true, // optional; observed run state after the action
"detail": { "any": "extra" } // optional structured detail, stored as JSON
}
```
- When `mtconnectrunning` is present the server caches it on the endpoint's
`EndpointState` (`mtconnectrunning` + `mtconnectcheckedat`) so the WebUI toggle
shows a real state between polls. Every `mtconnect_*` action SHOULD probe and
report the resulting run state so the toggle never lies.
### 7.3 Enqueue (WebUI side, for reference)
`POST /api/endpoints/<assetid>/commands` with `{ "action": "mtconnect_start" }`,
gated by user JWT + `endpoints.command`. Only one command may be in flight per
endpoint (a second enqueue returns HTTP 409 until the first finalizes).

View File

@@ -0,0 +1,443 @@
# Agent Design Decisions (running preference list)
Server-side decisions made while building the `endpoints` plugin that the
future C# agent (and the ServiceNow ticketing surface) must honor. This is a
living document: add to it as the agent build raises questions. ASCII only, no
em-dashes, no emojis, per repo style.
## Scope of this document
The `endpoints` plugin (server) is the authoritative contract. The C# agent is
a separate deliverable in its own solution/repo. Anything the agent sends must
match what this plugin ingests; when the two disagree, this plugin wins and the
agent changes.
## 1. Transport and identity
- Agent posts one JSON object per host to `POST /api/collector/endpoints`
(the generic ADR-006 collector route, auto-registered by the core loader).
- Auth is a collector-scoped credential in the request HEADER, not a user JWT
and not a querystring. Server reads `COLLECTOR_API_KEY_ENDPOINTS` first, then
falls back to `COLLECTOR_API_KEY`. Provision the key the same way the
computers reporter does: out of band, e.g. `HKLM:\SOFTWARE\GE\ShopDB
CollectorKey`. The key is never baked into a distributed script/binary.
- Identity field is `hostname` (case-insensitive). The server resolves it to an
existing computer asset via the `computers` plugin. In STEADY STATE an unknown
hostname is REJECTED with a validation error, never auto-created by this
plugin. Decision: the fleet is provisioned in ShopDB first (imports/Azure
device sync) OR adopted at first contact via the delegation shim in section
1a; the `endpoints` plugin itself does not get to invent assets.
## 1a. First-contact adoption (delegation shim, not a design reversal)
The agent installer's first run reaches a PC that may not exist in ShopDB yet.
Rather than letting `endpoints` create assets (which would break ADR-001 and the
section 1 rule that this plugin never invents assets), first contact DELEGATES
adoption to the `computers` collector, which is already the sole authoritative
creator of computer Assets and PC rows.
Decision:
- The `endpoints` plugin NEVER constructs an `Asset` or a `Computer` row itself.
Section 1 stands. This is a clarification, not a reversal.
- When `apply_collector_payload` cannot resolve the hostname AND the payload
opts in to adoption (see the `adopt` flag below), the plugin calls the
`computers` plugin's `apply_collector_payload` with the PC-identifying subset
of the payload, flushes, then re-resolves the hostname and proceeds with
telemetry. All of this runs inside the ONE transaction the generic collector
dispatcher owns (`shopdb/core/api/collector.py`): a `ValueError` still rolls
the whole thing back, so a half-adopted PC can never be committed.
- Steady-state reports send `adopt=false` (or omit it once a site chooses strict
mode); only the installer's first run sends `adopt=true`. After adoption the
strict "must already resolve" invariant holds for every subsequent report.
First-contact payload (the PC-identifying subset the shim forwards to the
`computers` collector - every field except `hostname` is optional but recommended
so the adopted PC is not a bare shell):
- `hostname` (required), `loggedinuser`, `serialnumber`, `pctype`,
`machinenumber` (only when the PC is bound to a machine; `''`/`'9999'` are
treated as none by the computers collector).
Net effect for the agent:
- ONE POST to `/api/collector/endpoints` with `adopt=true` on first run creates
the Asset + PC (via the shim) and writes the first `EndpointState` in the same
commit, so the endpoint appears in the UI immediately (no second call, no
separate enrollment endpoint).
- OPEN: whether to keep `adopt` per-report or promote it to a site setting
(strict vs. self-adopting fleet). Default proposed: adoption allowed, agent
sends `adopt=true` only on first run.
## 2. Report shape (current server contract)
Top-level payload fields the server currently reads (see
`plugins/endpoints/plugin.py` `get_collector_schema`):
- `hostname` (required), `loggedinuser`, `agentversion`
- Metrics: `lastboottime` (ISO-8601), `uptimeseconds`, `idleseconds`,
`cpupercent`, `memorypercent`
- Inventory arrays (stored as latest-snapshot JSON, not history):
`services`, `ports`, `interfaces`, `scheduledtasks`
- `events`: array of curated Windows Event Log records (see section 4)
Decision: inventory and metrics are a SNAPSHOT overwrite (one `EndpointState`
row per asset), not time-series. The agent sends the full current list each
cycle; the server does not diff or version them. If per-sample history is later
required, it goes in a new append-only table and the contract stays additive.
## 3. Reporting rhythm
- Default report interval: 15 minutes (`report_interval_minutes` in the plugin
manifest settings; a site setting can override). Agent design should make the
interval configurable and jittered to avoid a fleet-wide thundering herd.
- There is NO separate heartbeat. `lastreportedat` is stamped on every report;
"quiet" endpoints are those past the quiet-report window (default 24h, site
setting `endpoints_quietreporthours`). Agent does not need a distinct ping.
## 4. Curated Windows Event catalog (LOCKED to the server list)
The server stores ONLY these IDs; anything else in `events` is ignored (not an
error). Source of truth: `CURATED_EVENT_IDS` in `plugins/endpoints/plugin.py`.
| Event ID | Channel | Meaning |
|----------|--------------|--------------------------------------------|
| 4624 | Security | Successful logon |
| 4625 | Security | Failed logon |
| 4634 | Security | Logoff |
| 4672 | Security | Special privileges assigned to new logon |
| 4697 | Security | Service installed |
| 4698 | Security | Scheduled task created |
| 41 | System | Kernel-Power, unexpected shutdown |
| 4618 | Security | Monitored security event pattern |
| 55 | System | NTFS file system corruption |
| 7011 | System | Service Control Manager timeout |
| 10016 | System | DCOM permission error |
| 1001 | Application | Application error / bugcheck report |
- Per event, the agent sends: `eventid` (required), `eventchannel`,
`eventsource`, `eventtime` (ISO-8601, event time not report time),
`eventmessage`, and `fields` (an object of extracted structured values, e.g.
target account on a 4625). `fields` is stored as `payloadjson`.
- OPEN QUESTION for the agent build: which IDs need structured `fields` parsing
vs. raw message pass-through. Start with pass-through message; add structured
parsing per ID as alerting rules demand it. The teammate flagged event parsing
as the hard part; keep the catalog fixed and small until baselined.
- Scheduled-task history being disabled on some hosts means 4698 and task
"last run" may be sparse; the agent should read current task state directly,
not rely on event history alone.
## 5. Monitored services/processes
Known targets to watch (non-exhaustive, confirm during agent build): UDC, eDnc,
Intune-related services. Server stores whatever list the agent sends in
`servicesjson` without a fixed schema yet. Decision deferred: once the watched
set is final, consider promoting it to a server-known enum for alerting.
## 6. ServiceNow ticketing surface (future, creds confirmed available)
Not built server-side yet. Recorded here so the agent and a future server
endpoint agree:
- Interactive C# piece (system-tray/desktop launcher) owns the 5-click-max flow:
network precheck -> issue dropdown -> optional screenshot -> submit.
- Fixed dropdown reasons (from the source conversation): Can't logon to CSF;
Data collections down; Can't download program; Machine communication failing;
Network drives down; Other (free text).
- Screenshot saved to local `Pictures\Screenshots` before submission.
- OPEN: whether ticket submission goes direct to the ServiceNow API from the
agent, or is proxied through a ShopDB server endpoint (preferred: proxy, so
ServiceNow creds live server-side and the agent carries only its collector
key). Decide before wiring the integration.
## 7. Runtime split (from the planning conversation)
- Interactive bits (tray launcher, ticketing, screenshot): C# / .NET, likely a
Windows service + tray app.
- Collections MAY be locally hosted PowerShell scripts the service invokes,
where that is simpler than reimplementing WMI/CIM queries in C#.
- Code signing and Intune deployment are agent-side concerns, out of scope for
the server plugin but must be planned before fleet rollout.
### 7a. Tray deployment and on-demand "Send Status" (built)
The tray icon is a PER-USER process, not a service. A Windows service runs in
session 0 and cannot draw a tray icon in the operator's session, so the tray
ships as a second executable (`shopdb-endpoint-tray.exe`) that the installer
copies next to `shopdbagent.exe`, registers for logon autostart via the HKCU
Run key (`ShopDbEndpointTray`), and launches for the current user. Because the
Run key is per-user, the installer must be run from the operator's own session;
`-NoTray` yields a headless, service-only install.
DECISION - "Send Status now" reuses `--once`, it does not reimplement collection.
The tray's Send Status action shells out to `shopdbagent.exe --once`, the same
production collect-and-report path the service uses on its cadence. Consequences:
the tray holds NO collector key (the agent exe reads it from registry/appsettings
itself), there is one collection code path to maintain, and an operator can force
the ShopDB monitor page to refresh immediately instead of waiting for the next
cadence tick. The tray surfaces the agent's exit code as a balloon tip (0 =
sent, non-zero = check the rolling agent log). Note: `--once` honors
`adoptonfirstrun`, so an on-demand send can carry `adopt=true`; the server dedups
on hostname, so this is safe.
The ServiceNow ticket action stays a scaffold (section 6). A future desktop
shortcut to the same tray exe is the intended explicitly-launchable entry point
for that workflow; nothing about the Send Status wiring blocks it.
## 8. Web UI display (server-side, built)
The collected data surfaces through ShopDB's existing declaration-driven UI
contract (ADR-010). The plugin declares WHAT to show; generic core components
render it. No plugin-specific Vue components were written. Audience is DT
support / IT, NOT the shop-floor operator (the operator's surface is the
agent-side ticket tool).
Surfaces implemented:
- Per-PC asset panels (computer assets only), on the PC detail page:
- Endpoint status (keyvalue): logged-in user, uptime, idle, last boot,
CPU/memory %, agent version, last reported. Feeds
`GET /api/endpoints/asset/{assetid}/status`.
- Services and processes (list + badge): monitored services with a green/red
run-state badge. Feeds `.../services`.
- Recent events (list + severity badge): last 10 curated events. Feeds
`.../recentevents`.
- Dashboard exception cards (hide when empty):
- Endpoints gone quiet (warning): `.../dashboard/quiet`.
- Endpoints with crashes or shutdowns (critical): `.../dashboard/stability`,
bounded to a 48h window and the critical event catalog.
DECISION - freshness and cost. There is NO live streaming and NO polling. The
freshness ceiling is the agent report cadence (~15 min), so a websocket/poll
buys nothing but load. Data is fetched on page/dashboard load only, matching the
existing computers-quiet card. Asset panels are single-asset indexed reads
(negligible). Dashboard cards run once per dashboard load, cap at 50 rows, and
the stability card is time-bounded so it does not grow with total history. If
fleet-wide aggregation ever gets heavy, memoize dashboard aggregates with the
core `cache` helper for ~60s - documented lever, not built yet.
Agent implication: the agent does not drive any UI refresh. Its only job is to
report on its rhythm; the UI reflects whatever the last report wrote. The
`services` payload should send a per-service run STATE (e.g. running/stopped) so
the badge can color it; a missing/unknown state renders as a gray Unknown badge.
## 9. Shop-floor collection knowledge base (reference, NOT a schema change)
This section is a body of reference, not a change request. Nothing here alters
the shape of ShopDB as it stands. It records WHAT a shop-floor endpoint really
is, WHICH signals identify it, and WHY each signal matters to the enterprise, so
that whoever grows the collector understands the stakes behind each field before
touching the wire contract. The source material is the site's own extant
tooling: `scripts/site_imports/assetcollector/Collect-ShopDB.ps1` (the mature
CSV collector), the migration/assessment flow under `scripts/.temp`
(`Assess-Orchestrator.ps1`, `Setup_ShopPC.ps1`, `Setup_InspectionPC.ps1`,
`eDncRegToUDC.ps1`, `machinetypeselector.bat`), and the eDNC/UDC/MTConnect config
formats those scripts read. Treat it as the "why" that the terse contract in
sections 1 through 8 cannot carry.
### 9.0 Why this matters beyond one plugin
This collector is a wedge into an enterprise-wide body of knowledge. A shop-floor
PC is never just a PC: it is the human-facing half of a machine tool, the thing
that feeds NC programs to a controller and streams data back off it. Getting its
identity, hardware, network, and application picture right lets ShopDB answer
questions that today require a person to walk the floor: which controller does
this box talk to, is its data path healthy, is the hardware under warranty, what
is it actually running. Every signal below earns its place by answering one of
those questions.
### 9.1 The endpoint taxonomy (how the floor classifies its own PCs)
`machinetypeselector.bat` plus the two `Setup_*` scripts show the site already
thinks in three roles. The collector should be able to DERIVE the role from
on-box evidence rather than ask an operator:
- Shop PC: runs UDC (Universal Data Collector) and/or eDNC, wired to one CNC
machine (FANUC / Siemens / Okuma controller). This is the ~2/3 majority.
- Inspection PC: metrology stack. Marked by `C:\GE Aerospace\Genspect\genspect.true`
(a flag file `Setup_InspectionPC.ps1` writes) and/or `C:\GE Aerospace\Gagecal`
(GageCal 6.4).
- Non-shop-floor PC: office box, no machine link, minimal shop apps.
Role is derivable from application/marker presence (section 9.4), which is a more
honest signal than the raw WMI `Model` string the current `pctype` probe sends.
### 9.2 Hardware facts, and WHY
`Collect-ShopDB.ps1 :: Get-PcFacts` reads `Win32_ComputerSystem`,
`Win32_OperatingSystem`, and `Win32_BIOS` for: vendor/manufacturer, model, OS
caption, serial (BIOS serial first, ComputerSystem serial as fallback), last
boot, logged-in user.
Why hardware is not cosmetic:
- Dell Warranty API linkage. Vendor + model family + service tag (BIOS serial)
is exactly the tuple the Dell Warranty API keys on. Capturing it lets ShopDB
resolve warranty status and hardware line remotely, so a support tech can
diagnose a hardware-level fault (failing disk, EOL platform, out-of-warranty
chassis) without physically finding the machine. The serial is the join key;
the model family is the diagnostic context.
- Remote hardware diagnosis generally. Knowing the exact hardware line turns
"some PC is flaky" into "this is a known-bad model/BIOS combination," which is
an enterprise-scale pattern once the fleet reports uniformly.
Collection note: prefer BIOS `SerialNumber` (the Dell service tag) and capture
manufacturer + model as distinct fields, since the warranty join needs the raw
service tag, not a normalized `pctype`.
### 9.3 Network interfaces, and WHY (the second-NIC reality)
`Get-PcFacts` and `Assess-Orchestrator.ps1 :: Get-EthernetNetworkSnapshot`
enumerate hardware adapters only and capture alias, IPv4, MAC, gateway, and
subnet (the orchestrator even reconstructs the mask from prefix length).
Why the interface list is first-class, not trivia:
- The second interface IS the machine link. Upwards of two-thirds of shop-floor
PCs have a SECOND network interface with a STATIC, manually mapped IP address
whose sole purpose is to talk to the related machine's onboard IPS
(industrial PC / controller network). That private NIC is physical evidence of
the PC-to-machine relationship and often the only place the machine-side
address is recorded. Losing it loses the wiring diagram of the floor.
- MAC as stable identity. MAC is the most stable hardware identifier across
re-image and rename, which is why every extant script grabs it. It is the
durable anchor when hostname/asset number churn during migration.
Collection note: send ALL hardware interfaces (alias, IPv4, MAC, and where
available gateway/subnet), not just the primary. The primary/gateway NIC is the
corporate LAN; the gatewayless static NIC is very likely the machine IPS link and
is the interesting one.
### 9.4 Installed applications, and WHY (the extrapolation engine)
`Get-PcFacts` reads the registry Uninstall keys (name, version, publisher,
skipping `SystemComponent`). On these boxes the installed-app set is small and
highly diagnostic: they primarily run a handful of known apps.
Why the app inventory is the richest signal we have:
- Apps imply role (section 9.1): UDC/eDNC => Shop PC; Genspect/GageCal =>
Inspection PC; neither => office.
- Apps imply the MACHINE binding. This is the crux. If UDC or eDNC (or MTConnect)
is installed, its config file carries the four-digit number ShopDB calls the
Machine Number, and from it we can extrapolate the ShopDB `machines` entry the
PC should be related to. The same value appears under different names across
the app generations:
- eDNC `.reg` / live registry: `MachineNo` (under
`HKLM\SOFTWARE\[WOW6432Node\]GE Aircraft Engines\DNC\General`).
- UDC `udc_settings_*.json`: `GeneralSettings.MachineNumber`.
- MTConnect: workstation / workstation ID / MachineNo, carried in the
adapter/agent config under the fixed directory tree documented in section
9.9.
Treat "MachineNo", "workstation", "workstation ID", and "MachineNumber" as the
same concept: the ShopDB Machine Number. Version and publisher are worth
keeping too (they date the app generation, which tells you which config format
to parse).
Collection note: the app inventory is not just an asset list; it is the pointer
that tells the collector WHICH config file to open next to find the machine
binding.
### 9.5 The machine binding (DNC/UDC/MTConnect config), and WHY
`Collect-ShopDB.ps1 :: Resolve-DncSource` resolves the machine config with a
SOURCE-PRIORITY chain, then three parsers normalize the formats into one shape:
- Priority: explicit JSON path -> explicit `.reg` path -> live registry
(`HKLM\...\GE Aircraft Engines\DNC`) -> local `.reg` export -> local
`udc_settings_*.json`. This ordering makes one probe work across both the
eDNC-era and UDC-era boxes.
- Parsers: `ConvertFrom-RegExport` (parses exported `.reg`, including
`dword:` decoding), `ConvertFrom-LiveRegistry`, `ConvertFrom-UdcJson`.
- Extracted: machine number, site, control type, DNC type, and connection detail
(TCP address/port for ethernet; COM port/baud for serial).
- Controller vendor is DERIVED, not stored raw: `Resolve-ControllerVendor` maps
`NcIF = EFOCAS -> FANUC`, `Cnc = SIEMENS -> Siemens`, `Cnc = OKUMA -> Okuma`.
`eDncRegToUDC.ps1` confirms the same mapping and the eFocas/PPDCS key layout.
Why it matters: this binding is what upgrades a bare endpoint into "the PC that
controls machine 1234 over eFOCAS at 10.x.x.x." That relationship is the point of
the whole exercise; the hardware and app facts are supporting evidence for it.
### 9.6 Printers, and WHY
`Assess-Orchestrator.ps1 :: Get-PrinterSnapshot` / `Get-PrinterPortSnapshot`
capture name, driver, port, host address, shared/published, and the default
flag, filtering out virtual outputs (Print to PDF, XPS, Fax, OneNote). Shop and
inspection PCs are tied to specific physical/label printers, so the printer set
is part of a workstation's operational identity and a common migration break
point (a replaced PC that lost its label printer is a floor stoppage).
### 9.7 Patterns from the extant tooling worth carrying forward
Two engineering habits in these scripts are worth honoring in the collector,
because they encode hard-won migration lessons:
- Field-level idempotent merge (`Merge-CsvStore`): an empty incoming cell NEVER
overwrites a previously captured value; later partial runs fill gaps without
wiping earlier data. The server's endpoints ingest already mirrors this instinct
(`if 'field' in payload`), and it should stay that way: a partial report means
"leave the rest alone," not "clear it."
- Normalized intermediate shape + sidecars: heterogeneous sources (registry,
`.reg`, JSON, WMI) all converge on one normalized record before import, with
phase-2 data (interfaces, installed apps, PC-to-machine link) written as JSON
sidecars. The same decoupling lets a future non-agent source (Intune/Azure
`Get-MgDevice`, a MES/CNC database) feed the same model without a new pipeline.
### 9.9 MTConnect footprint, and WHY (three parallel stacks per box)
Source: `scripts/.temp/Install_MTConnect_ExisDir.ps1`,
`Install_MTConnect_ExisDir_BatConvert.ps1`, its usage guide
`Guide-Install_MTConnect_ExisDir_BatConvert.md`, and
`Firewall_Rules_MTConnect_All.bat`. MTConnect is the standardized data-off-the-
machine layer, and the install scripts reveal its exact on-disk shape, which is
what a collector needs to detect it reliably.
The critical structural fact: an endpoint can host up to THREE parallel MTConnect
stacks, one per data path, each an Adapter (talks to the machine) plus an Agent
(serves the MTConnect HTTP feed):
- Machine-direct: `C:\MTConnect\Adapter` + `C:\MTConnect\Agent` (Agent exe
`C:\MTConnect\Agent\Agent.exe`).
- eDNC-fed: `C:\MTConnect_eDNC\Adapter` + `C:\MTConnect_eDNC\Agent`
(`...\Agent\agent.exe`). The eDNC adapter is `DNCAdapter.exe` driven by
`DNCAdapter.ini`, tying this path back to the eDNC config world in section 9.5.
- UDC-fed: `C:\ProgramData\udc\mtconnect_udc\agent` (`...\agent\agent.exe`,
launched via `run.bat`). This binds MTConnect to the UDC install and therefore
to the same `udc_settings_*.json` that carries `GeneralSettings.MachineNumber`.
Why the collector should care about these paths:
- Presence is role/path evidence. Which of the three trees exist tells you HOW the
machine's data is being lifted (direct vs eDNC vs UDC), which is a real
operational distinction, not a cosmetic one.
- The MTConnect Agent is a network service. The firewall bat and the
`Ensure-FirewallProgramRule` calls open inbound rules named
`Allow_MTConnect_Machine`, `Allow_MTConnect_UDC`, `Allow_MTConnect_eDNC` for
each `Agent.exe`. That means each agent listens on an HTTP port, so its live
MTConnect feed (which publishes a device UUID / station identity) is itself a
future data source the collector could probe, not just a process to count.
- Machine Number lives here too. Because the eDNC path is `DNCAdapter.ini` and the
UDC path is the UDC settings JSON, the MTConnect footprint is a second route to
the same four-digit Machine Number described in 9.4/9.5, cross-checkable against
the eDNC/UDC values.
Collection note: detect MTConnect by the three directory trees, capture which
Agent exes exist and whether their service/process is running (section 5 already
covers MTConnect as a monitored service), and record the data path
(direct/eDNC/UDC).
(`Install_MTConnect_ExisDir_BatConvert.ps1`) is a deployment concern, not a
collection concern, but it explains why these `install.bat`/`start.bat` files
were rewritten to self-elevate without a `pause`: so they can run unattended
under DSC on locked-down machines. Do not treat a self-elevating batch as
tampering.
### 9.8 Boundary (explicit)
None of section 9 is a ShopDB schema proposal. Where a signal above has no home
in the current model, that is fine: it stays reference until a separate, deliberate
decision (its own ADR / contract bump) decides to store it. The value here is
comprehension: anyone extending the collector should know that hardware feeds
warranty, the second NIC is the machine link, and the installed apps are the
thread that leads to the Machine Number. Build the agent with that understanding;
do not let this document, by itself, move the schema.

View File

@@ -0,0 +1,5 @@
"""Endpoints plugin package."""
from .plugin import EndpointsPlugin
__all__ = ['EndpointsPlugin']

View File

@@ -0,0 +1,5 @@
"""Endpoints plugin API."""
from .routes import endpoints_bp
__all__ = ['endpoints_bp']

View File

@@ -0,0 +1,724 @@
"""Endpoints plugin API endpoints (read + dashboard).
Ingest is NOT here: agent reports arrive on the generic collector route
POST /api/collector/endpoints (ADR-006), authorized by a collector-scoped
service token, and are handled by EndpointsPlugin.apply_collector_payload.
These routes are the human-facing read surface, gated by user JWT + RBAC.
"""
import json
import time
from datetime import datetime, timedelta, timezone
from flask import Blueprint, request, current_app
from flask_jwt_extended import current_user
from shopdb.api import (
db, Asset, Setting, success_response, error_response, paginated_response,
ErrorCodes, get_pagination_params, paginate_query, require_permission,
require_role, mint_service_token, list_service_tokens, revoke_service_tokens,
AuditLog, collector_authorized,
)
from ..models import EndpointState, EndpointEvent, EndpointCommand
from ..commands import COMMAND_ALLOWLIST, is_allowed_action, ALLOWED_ACTIONS_FOR, row_actions_for, requires_target, action_targetkind, elevated_permission_for
from ..eventcatalog import CRITICAL_EVENT_IDS, event_severity, event_title
endpoints_bp = Blueprint('endpoints', __name__)
def _naive_utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
def _format_uptime(seconds):
"""Whole-seconds uptime -> 'Nd Nh Nm', or None."""
if seconds is None:
return None
try:
seconds = int(seconds)
except (TypeError, ValueError):
return None
days, rem = divmod(seconds, 86400)
hours, rem = divmod(rem, 3600)
minutes = rem // 60
parts = []
if days:
parts.append('{}d'.format(days))
if hours:
parts.append('{}h'.format(hours))
parts.append('{}m'.format(minutes))
return ' '.join(parts)
# =============================================================================
# Endpoint state
# =============================================================================
@endpoints_bp.route('/', methods=['GET'])
@require_permission('endpoints.view')
def list_endpoints():
"""List reported endpoint states.
Joins through Computer so each row carries computerid and can deep-link
into the existing PC detail page (/pcs/{computerid}). Endpoint state only
exists for hostnames resolved to a Computer, so the join is not lossy.
"""
from plugins.computers.models import Computer
page, per_page = get_pagination_params(request)
query = (db.session.query(EndpointState, Computer.computerid)
.join(Asset, Asset.assetid == EndpointState.assetid)
.join(Computer, Computer.assetid == EndpointState.assetid)
.filter(Asset.isactive.is_(True)))
if search := request.args.get('search'):
query = query.filter(EndpointState.hostname.ilike(f'%{search}%'))
query = query.order_by(EndpointState.lastreportedat.desc())
items, total = paginate_query(query, page, per_page)
data = []
for state, computerid in items:
row = state.to_dict()
row['computerid'] = computerid
row['uptimedisplay'] = _format_uptime(state.uptimeseconds)
data.append(row)
return paginated_response(data, page, per_page, total)
@endpoints_bp.route('/<int:asset_id>', methods=['GET'])
@require_permission('endpoints.view')
def get_endpoint(asset_id: int):
"""Get the current state for one endpoint by asset id."""
state = EndpointState.query.filter_by(assetid=asset_id).first()
if not state:
return error_response(
ErrorCodes.NOT_FOUND,
f'No endpoint state for asset {asset_id}',
http_code=404,
)
return success_response(state.to_dict())
@endpoints_bp.route('/<int:asset_id>/events', methods=['GET'])
@require_permission('endpoints.view')
def list_endpoint_events(asset_id: int):
"""List curated Windows events for one endpoint, newest first."""
page, per_page = get_pagination_params(request)
query = EndpointEvent.query.filter_by(assetid=asset_id)
if eventid := request.args.get('eventid'):
try:
query = query.filter(EndpointEvent.eventid == int(eventid))
except (TypeError, ValueError):
return error_response(ErrorCodes.VALIDATION_ERROR,
'eventid must be an integer')
query = query.order_by(EndpointEvent.eventtime.desc())
items, total = paginate_query(query, page, per_page)
data = [e.to_dict() for e in items]
return paginated_response(data, page, per_page, total)
# =============================================================================
# Asset panels (ADR-010) - shaped for the generic PluginAssetPanels renderer
# =============================================================================
@endpoints_bp.route('/asset/<int:asset_id>/status', methods=['GET'])
@require_permission('endpoints.view')
def panel_status(asset_id: int):
"""Keyvalue panel: the endpoint's current status snapshot.
Single indexed lookup on endpointstates.assetid. Returns the {fields:[...]}
shape the keyvalue renderer expects; an absent state yields no fields so the
panel hides itself.
"""
state = EndpointState.query.filter_by(assetid=asset_id).first()
if not state:
return success_response({'fields': []})
def field(label, value, mono=False):
return {'label': label, 'value': value, 'mono': mono}
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),
field('CPU %', state.cpupercent),
field('Memory %', state.memorypercent),
field('Agent', state.agentversion, mono=True),
field('Last reported',
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None),
]
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):
"""Lightweight freshness probe for the panels.
Returns just the endpoint's lastreportedat so the WebUI can tell whether a
genuinely new telemetry snapshot has landed after it forces a collect. The
UI captures this before enqueuing collect_now (or a row Start/Stop), then
polls until the stamp advances - a command reaching 'succeeded' only means
the agent ran it, not that it has yet posted the fresh services snapshot, so
gating the refresh on this timestamp is what avoids re-rendering stale rows.
"""
state = EndpointState.query.filter_by(assetid=asset_id).first()
stamp = (state.lastreportedat.isoformat() + 'Z'
if state and state.lastreportedat else None)
return success_response({'lastreportedat': stamp})
@endpoints_bp.route('/asset/<int:asset_id>/services', methods=['GET'])
@require_permission('endpoints.view')
def panel_services(asset_id: int):
"""List panel: monitored services/processes with a run-state badge per row.
Reshapes the latest servicesjson snapshot into rows the list renderer maps
to name + status badge. Tolerant of a missing or unexpected snapshot shape:
an unknown state renders as an Unknown badge rather than failing.
"""
state = EndpointState.query.filter_by(assetid=asset_id).first()
if not state or not state.servicesjson:
return success_response([])
try:
services = json.loads(state.servicesjson)
except (TypeError, ValueError):
return success_response([])
rows = []
for entry in (services or []):
if isinstance(entry, dict):
name = entry.get('name') or entry.get('service') or 'Unknown'
raw = (entry.get('state') or entry.get('status') or '').strip().lower()
app = entry.get('app')
kind = entry.get('kind')
else:
name, raw, app, kind = str(entry), '', None, None
running = raw in ('running', 'started', 'up', 'ok', 'active')
row = {
'name': name,
'kind': (kind or '').lower() or None,
'statelabel': 'Running' if running else (raw.title() or 'Unknown'),
'statecolor': 'green' if running else ('red' if raw else 'gray'),
}
targets = row_actions_for(app, kind, name)
if targets:
row['action'] = {
'running': running,
'start': targets['start'],
'stop': targets['stop'],
'status': targets['status'],
'target': targets['target'],
'targetkind': targets['targetkind'],
}
rows.append(row)
return success_response(rows)
@endpoints_bp.route('/asset/<int:asset_id>/recentevents', methods=['GET'])
@require_permission('endpoints.view')
def panel_recent_events(asset_id: int):
"""List panel: the endpoint's most recent curated events, newest first.
Indexed range scan on (assetid, eventtime), capped small - this is the
at-a-glance history card, not the full report (that is the events route
with pagination).
"""
events = (EndpointEvent.query
.filter_by(assetid=asset_id)
.order_by(EndpointEvent.eventtime.desc())
.limit(10)
.all())
rows = [{
'title': event_title(e.eventid),
'severity': event_severity(e.eventid),
'eventtime': e.eventtime.isoformat() + 'Z' if e.eventtime else None,
} for e in events]
return success_response(rows)
@endpoints_bp.route('/asset/<int:asset_id>/interfaces', methods=['GET'])
@require_permission('endpoints.view')
def panel_interfaces(asset_id: int):
"""Table panel: every hardware NIC the agent last reported.
Reshapes the latest interfacesjson snapshot into one row per adapter with
the {alias, ipv4, mac, gateway, subnet, dhcp} shape the collector emits. A
gatewayless adapter is flagged as the likely machine-IPS link (see
DESIGN-DECISIONS-agent.md 9.3): those NICs speak to the machine, not the
corporate LAN. Tolerant of a missing or unexpected snapshot: an unreadable
payload yields no rows so the panel hides itself.
"""
state = EndpointState.query.filter_by(assetid=asset_id).first()
if not state or not state.interfacesjson:
return success_response([])
try:
interfaces = json.loads(state.interfacesjson)
except (TypeError, ValueError):
return success_response([])
rows = []
for entry in (interfaces or []):
if not isinstance(entry, dict):
continue
gateway = (entry.get('gateway') or '').strip()
ipv4 = (entry.get('ipv4') or '').strip()
rows.append({
'alias': entry.get('alias') or 'Unknown',
'ipv4': ipv4 or '-',
'mac': (entry.get('mac') or '').strip() or '-',
'gateway': gateway or '-',
'subnet': (entry.get('subnet') or '').strip() or '-',
'dhcp': 'DHCP' if entry.get('dhcp') else 'Static',
# 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',
})
return success_response(rows)
# =============================================================================
# Dashboard
# =============================================================================
@endpoints_bp.route('/dashboard/summary', methods=['GET'])
@require_permission('endpoints.view')
def dashboard_summary():
"""High-level fleet counts for the endpoints dashboard."""
total = db.session.query(EndpointState).join(
Asset, Asset.assetid == EndpointState.assetid
).filter(Asset.isactive.is_(True)).count()
hours = _quiet_hours()
cutoff = _naive_utcnow() - timedelta(hours=hours)
reporting = db.session.query(EndpointState).join(
Asset, Asset.assetid == EndpointState.assetid
).filter(
Asset.isactive.is_(True),
EndpointState.lastreportedat.isnot(None),
EndpointState.lastreportedat >= cutoff,
).count()
return success_response({
'total': total,
'reporting': reporting,
'quiet': total - reporting,
'quiethours': hours,
})
@endpoints_bp.route('/dashboard/quiet', methods=['GET'])
@require_permission('endpoints.view')
def dashboard_quiet():
"""Endpoints that reported before, then went quiet past the window.
Never-reported endpoints are excluded: those are records without an agent,
not agents that stopped. Silence is the only signal - there is no heartbeat
separate from the report.
"""
hours = _quiet_hours()
cutoff = _naive_utcnow() - timedelta(hours=hours)
now = _naive_utcnow()
from plugins.computers.models import Computer
rows = (db.session.query(EndpointState, Computer.computerid)
.join(Asset, Asset.assetid == EndpointState.assetid)
.join(Computer, Computer.assetid == EndpointState.assetid)
.filter(Asset.isactive.is_(True),
EndpointState.lastreportedat.isnot(None),
EndpointState.lastreportedat < cutoff)
.all())
out = [{
'computerid': computerid,
'assetid': s.assetid,
'hostname': s.hostname,
'lastreported': s.lastreportedat.isoformat() + 'Z',
'quietdays': (now - s.lastreportedat).days,
} for s, computerid in rows]
out.sort(key=lambda r: -r['quietdays'])
return success_response(out[:50])
@endpoints_bp.route('/dashboard/stability', methods=['GET'])
@require_permission('endpoints.view')
def dashboard_stability():
"""Endpoints with recent crash/shutdown/corruption events.
Bounded to a fixed 48h window and the critical event catalog, capped at 50
rows: cost is a range scan on eventtime filtered to a short id list, not a
scan of all history. Joins through Computer so each row links to the PC
detail page.
"""
from plugins.computers.models import Computer
if not CRITICAL_EVENT_IDS:
return success_response([])
cutoff = _naive_utcnow() - timedelta(hours=48)
rows = (db.session.query(
Computer.computerid,
EndpointState.hostname,
db.func.count(EndpointEvent.endpointeventid),
db.func.max(EndpointEvent.eventtime),
)
.select_from(EndpointEvent)
.join(Asset, Asset.assetid == EndpointEvent.assetid)
.join(Computer, Computer.assetid == EndpointEvent.assetid)
.outerjoin(EndpointState,
EndpointState.assetid == EndpointEvent.assetid)
.filter(Asset.isactive.is_(True),
EndpointEvent.eventid.in_(CRITICAL_EVENT_IDS),
EndpointEvent.eventtime.isnot(None),
EndpointEvent.eventtime >= cutoff)
.group_by(Computer.computerid, EndpointState.hostname)
.all())
out = [{
'computerid': computerid,
'hostname': hostname,
'eventcount': count,
'lastevent': lasttime.isoformat() + 'Z' if lasttime else None,
} for computerid, hostname, count, lasttime in rows]
out.sort(key=lambda r: -r['eventcount'])
return success_response(out[:50])
def _quiet_hours() -> int:
"""Quiet-report window in hours, from a site setting, default 24."""
hours = 24
setting = Setting.query.filter_by(key='endpoints_quietreporthours').first()
if setting and (setting.value or '').strip():
try:
hours = int(setting.value)
except (TypeError, ValueError):
pass
return hours
# =============================================================================
# Endpoint agent key (UI-managed collector.ingest.endpoints kill switch)
# =============================================================================
# The single permission the endpoint agent key authorizes. A managed token
# scoped to ONLY this authorizes the endpoints collector and nothing else, so
# rotating/revoking it stops endpoint-agent traffic without touching the
# computers/geenforce collectors.
AGENT_KEY_SCOPE = 'collector.ingest.endpoints'
AGENT_KEY_NAME = 'Endpoint agent key'
def _agentkey_status_payload():
"""Current state of the endpoint agent key for the settings page.
Reports whether an active managed key exists (and its non-secret metadata)
plus whether the bootstrap .env fallback is still accepted. Never exposes a
secret; the full secret is only ever returned once, at regenerate time.
"""
tokens = list_service_tokens(AGENT_KEY_SCOPE)
active = tokens[0] if tokens else None
envfallback = Setting.get('plugin.endpoints.envkeyfallback', default=True)
provisioned = Setting.get('plugin.endpoints.agentkeyprovisioned',
default=False)
return {
'provisioned': bool(provisioned) or active is not None,
'envkeyfallback': bool(envfallback),
'key': active.to_dict() if active is not None else None,
}
@endpoints_bp.route('/agentkey', methods=['GET'])
@require_role('admin')
def get_agentkey():
"""Report the endpoint agent key state (no secret)."""
return success_response(_agentkey_status_payload())
@endpoints_bp.route('/agentkey/regenerate', methods=['POST'])
@require_role('admin')
def regenerate_agentkey():
"""Mint a fresh endpoint agent key, revoking any previous one.
Hard cutover: the old key is deactivated in the same call, and the .env
fallback is switched off, so the new managed token becomes the sole
credential and revoking it is a true kill switch. The full secret is
returned ONCE here and never again.
"""
try:
token, secret = mint_service_token(
AGENT_KEY_SCOPE, AGENT_KEY_NAME, current_user.userid)
except PermissionError as error:
return error_response(ErrorCodes.FORBIDDEN, str(error), http_code=403)
Setting.set('plugin.endpoints.agentkeyprovisioned', True,
valuetype='boolean', category='plugin.endpoints')
Setting.set('plugin.endpoints.envkeyfallback', False,
valuetype='boolean', category='plugin.endpoints')
result = _agentkey_status_payload()
# The secret appears here and NOWHERE else, ever. Not stored, not logged.
result['secret'] = secret
result['warning'] = ('Save this key now. It will not be shown again. '
'Configure it as the agent X-API-Key.')
return success_response(result, message='Endpoint agent key generated',
http_code=201)
@endpoints_bp.route('/agentkey', methods=['DELETE'])
@require_role('admin')
def revoke_agentkey():
"""Revoke the endpoint agent key: stop all managed-token endpoint traffic.
Leaves envkeyfallback off (the kill switch stays armed). To restore
reporting an operator regenerates a new key.
"""
revoked = revoke_service_tokens(AGENT_KEY_SCOPE)
Setting.set('plugin.endpoints.agentkeyprovisioned', False,
valuetype='boolean', category='plugin.endpoints')
result = _agentkey_status_payload()
result['revoked'] = revoked
return success_response(result, message='Endpoint agent key revoked')
# =============================================================================
# Endpoint commands (WebUI -> agent, allowlisted actions only)
# =============================================================================
#
# Operators enqueue a named, server-allowlisted action for one endpoint (JWT +
# endpoints.command). The agent long-polls /commands/next with its collector
# token, claims the single next pending command, runs it locally, and reports
# the outcome to /commands/<id>/result. The wire never carries a command string
# - only an allowlisted action name - so a compromised WebUI cannot ask an
# endpoint to run something arbitrary.
# 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.
_CLAIM_HOLD_SECONDS = 25
_CLAIM_POLL_INTERVAL = 1.0
def _collector_auth_or_error():
"""Authorize an agent-facing command route on the endpoints collector token.
Reuses the collector auth path so the command channel shares the endpoint
agent key (kill switch) and introduces no second secret.
"""
return collector_authorized('endpoints')
def _resolve_asset_for_hostname(hostname):
"""Resolve a reported hostname to its asset id via the computers plugin."""
from plugins.computers.models import Computer
computer = (Computer.query
.filter(db.func.lower(Computer.hostname) == hostname.lower())
.first())
return computer.assetid if computer is not None else None
@endpoints_bp.route('/<int:asset_id>/commands', methods=['GET'])
@require_permission('endpoints.view')
def list_endpoint_commands(asset_id: int):
"""Recent commands for one endpoint, newest first (operator view)."""
rows = (EndpointCommand.query
.filter_by(assetid=asset_id)
.order_by(EndpointCommand.createdat.desc())
.limit(20)
.all())
return success_response([row.to_dict() for row in rows])
@endpoints_bp.route('/<int:asset_id>/commands', methods=['POST'])
@require_permission('endpoints.command')
def enqueue_endpoint_command(asset_id: int):
"""Queue an allowlisted command for one endpoint.
Rejects unknown actions and refuses to stack a second pending/running
command on the same endpoint, so the queue stays at-most-one-in-flight and
the WebUI toggle cannot race itself.
"""
payload = request.get_json(silent=True) or {}
action = (payload.get('action') or '').strip()
if not is_allowed_action(action):
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Unknown or disallowed action: {action!r}')
# Destructive actions (for example system_restart) demand a stricter grant
# ON TOP OF endpoints.command, so a remote reboot cannot ride the routine
# toggle permission.
elevated = elevated_permission_for(action)
if elevated and not current_user.haspermission(elevated):
return error_response(
ErrorCodes.FORBIDDEN,
f'Action {action!r} requires the {elevated} permission',
http_code=403)
if action not in ALLOWED_ACTIONS_FOR(assetid=asset_id):
return error_response(
ErrorCodes.VALIDATION_ERROR,
f'Action not permitted by policy for this endpoint: {action!r}')
# Generic actions (service_* / process_*) must carry the subject to act on;
# fixed-target actions (mtconnect_*) must not. Validate both directions so a
# caller cannot send a targetless generic action (nothing to act on) or
# smuggle a target onto a pinned action.
target = (payload.get('target') or '').strip() or None
targetkind = action_targetkind(action)
if requires_target(action):
if not target:
return error_response(
ErrorCodes.VALIDATION_ERROR,
f'Action {action!r} requires a target')
elif target is not None:
return error_response(
ErrorCodes.VALIDATION_ERROR,
f'Action {action!r} does not accept a target')
state = EndpointState.query.filter_by(assetid=asset_id).first()
if state is None:
return error_response(ErrorCodes.NOT_FOUND,
f'No endpoint state for asset {asset_id}',
http_code=404)
pending = (EndpointCommand.query
.filter(EndpointCommand.assetid == asset_id,
EndpointCommand.status.in_((
EndpointCommand.STATUS_PENDING,
EndpointCommand.STATUS_RUNNING)))
.first())
if pending is not None:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'A command is already in flight for this endpoint',
http_code=409)
command = EndpointCommand(
assetid=asset_id,
action=action,
target=target,
targetkind=targetkind if target else None,
status=EndpointCommand.STATUS_PENDING,
requestedby=getattr(current_user, 'userid', None),
)
db.session.add(command)
AuditLog.log('created', 'EndpointCommand',
entityname=action,
details=f'action={action} target={target} asset={asset_id}')
db.session.commit()
return success_response(command.to_dict(),
message='Command queued', http_code=201)
@endpoints_bp.route('/commands/next', methods=['GET'])
def claim_next_command():
"""Agent long-poll: claim the next pending command for a hostname.
Collector-token auth (not JWT). Holds the request open briefly and returns
the single oldest pending command for the caller's endpoint, atomically
marking it running so no other poll claims it. Returns 204 when nothing is
queued within the hold window - the agent simply re-polls.
"""
error = _collector_auth_or_error()
if error is not None:
return error
hostname = (request.args.get('hostname') or '').strip()
if not hostname:
return error_response(ErrorCodes.VALIDATION_ERROR,
'hostname is required')
asset_id = _resolve_asset_for_hostname(hostname)
if asset_id is None:
return error_response(ErrorCodes.NOT_FOUND,
f'Unknown hostname: {hostname!r}', http_code=404)
deadline = time.monotonic() + _CLAIM_HOLD_SECONDS
while True:
command = (EndpointCommand.query
.filter_by(assetid=asset_id,
status=EndpointCommand.STATUS_PENDING)
.order_by(EndpointCommand.createdat.asc())
.first())
if command is not None:
command.status = EndpointCommand.STATUS_RUNNING
command.claimedat = _naive_utcnow()
db.session.commit()
return success_response({
'endpointcommandid': command.endpointcommandid,
'action': command.action,
'target': command.target,
'targetkind': command.targetkind,
})
# Nothing queued: release the session so a concurrent enqueue is visible
# on the next poll, wait a beat, then re-check until the hold expires.
db.session.rollback()
if time.monotonic() >= deadline:
return ('', 204)
time.sleep(_CLAIM_POLL_INTERVAL)
@endpoints_bp.route('/commands/<int:command_id>/result', methods=['POST'])
def report_command_result(command_id: int):
"""Agent reports a claimed command's outcome (collector-token auth).
Finalizes the command row and, for MTConnect actions that observed the run
state, caches it on the EndpointState so the WebUI toggle reflects reality
between polls. Both writes land in one commit.
"""
error = _collector_auth_or_error()
if error is not None:
return error
command = db.session.get(EndpointCommand, command_id)
if command is None:
return error_response(ErrorCodes.NOT_FOUND,
f'No command {command_id}', http_code=404)
payload = request.get_json(silent=True) or {}
succeeded = bool(payload.get('succeeded'))
command.status = (EndpointCommand.STATUS_SUCCEEDED if succeeded
else EndpointCommand.STATUS_FAILED)
command.resultmessage = (payload.get('message') or '')[:2000] or None
detail = payload.get('detail')
command.resultjson = json.dumps(detail, separators=(',', ':')) \
if detail is not None else None
command.completedat = _naive_utcnow()
# Cache the observed MTConnect state so the toggle shows a real value. The
# agent sends mtconnectrunning on any mtconnect_* result that probed it.
if 'mtconnectrunning' in payload:
state = EndpointState.query.filter_by(assetid=command.assetid).first()
if state is not None:
running = payload.get('mtconnectrunning')
state.mtconnectrunning = bool(running) if running is not None else None
state.mtconnectcheckedat = _naive_utcnow()
db.session.commit()
return success_response(command.to_dict(), message='Result recorded')

View File

@@ -0,0 +1,198 @@
"""Server-side allowlist of endpoint agent commands.
The WebUI never sends an arbitrary command string. It sends an allowlisted
ACTION NAME; the concrete on-endpoint behavior (which script to run, how to stop
a process) lives in the agent. This module is the single source of truth for
which action names are legal and what they mean, so both the enqueue route and
the settings/UI surface validate against the same set.
The structure anticipates per-PC-type allowlists: today every computer may run
every action in COMMAND_ALLOWLIST, but ALLOWED_ACTIONS_FOR is the seam a later
change swaps for a type-scoped lookup (inspection PCs vs shopfloor PCs).
"""
# action name -> operator-facing metadata. label/description feed the UI; the
# agent maps the action name to its local handler (see CommandExecutor).
#
# Two families of action:
# * Named MTConnect actions - fixed target, no parameters. The original,
# bay-specific controls kept for the inline services-panel toggle.
# * Generic RMM actions (service_* / process_*) - the target is supplied by
# the policy action's params ('target' = a Windows service name or a process
# image like 'DncMain.exe'). This is what populates the Configuration Policy
# builder with reusable controls that any scope (PC type or application) can
# attach. The 'targetkind' hint tells the builder which target field to show.
# NOTE: agent-side execution of the generic actions is not wired yet; the
# server allowlist and policy plumbing land first (the whole feature is
# flag-gated), so a generic action saved today is authored and gated but is
# a no-op on the endpoint until the CommandExecutor handlers ship.
COMMAND_ALLOWLIST = {
'service_start': {
'label': 'Start service',
'description': 'Start a Windows service by name on the endpoint.',
'targetkind': 'service',
},
'service_stop': {
'label': 'Stop service',
'description': 'Stop a running Windows service by name on the endpoint.',
'targetkind': 'service',
},
'service_status': {
'label': 'Check service',
'description': 'Report whether a named Windows service is running.',
'targetkind': 'service',
},
'process_start': {
'label': 'Start process',
'description': 'Launch a process by executable path on the endpoint.',
'targetkind': 'process',
},
'process_stop': {
'label': 'Stop process',
'description': 'Stop a running process by image name on the endpoint.',
'targetkind': 'process',
},
'process_status': {
'label': 'Check process',
'description': 'Report whether a named process is running.',
'targetkind': 'process',
},
'mtconnect_start': {
'label': 'Start MTConnect',
'description': 'Run the MTConnect UDC agent start script on the endpoint.',
},
'mtconnect_stop': {
'label': 'Stop MTConnect',
'description': 'Stop the running MTConnect UDC agent on the endpoint.',
},
'mtconnect_status': {
'label': 'Check MTConnect',
'description': 'Report whether the MTConnect UDC agent is running.',
},
'system_restart': {
'label': 'Restart computer',
'description': 'Restart the endpoint computer immediately.',
},
'collect_now': {
'label': 'Collect now',
'description': 'Force the endpoint agent to report fresh telemetry off-cadence.',
},
}
# Actions that demand a permission stricter than the base endpoints.command.
# The enqueue route requires the caller to ALSO hold the mapped permission, so a
# destructive action (a remote reboot) is a heavier grant than a routine toggle.
ELEVATED_ACTION_PERMISSIONS = {
'system_restart': 'endpoints.restart',
}
def elevated_permission_for(action):
"""The extra permission an action requires, or None for routine actions."""
return ELEVATED_ACTION_PERMISSIONS.get(action)
# Maps a monitored services/processes row to the start/stop/status actions that
# control it, so the services panel can render an inline toggle on exactly the
# rows that have a registered command action. The key is the agent-reported
# 'app' label; 'kind' pins the toggle to the authoritative row (for MTConnect
# the agent.exe PROCESS is the source of truth - run.bat launches it and the
# Windows service state is not reliable), so the service row stays badge-only.
ROW_COMMAND_TARGETS = {
('MTConnect', 'process'): {
'start': 'mtconnect_start',
'stop': 'mtconnect_stop',
'status': 'mtconnect_status',
},
}
def row_actions_for(app, kind, name=None):
"""Return the {start, stop, status, target, targetkind} map for a row.
Two paths:
* A row registered in ROW_COMMAND_TARGETS (MTConnect) keeps its fixed,
named actions with no wire target - agent.exe is the source of truth and
the behavior is pinned in the agent.
* Any other process/service row maps to the generic primitives
(process_* / service_*) with the row's OWN reported name as the wire
target. This is what lets a new app light up start/stop/status without
an agent rebuild: the target rides the wire.
Rows with no usable kind return None and render as a plain status badge.
"""
if not app and not name:
return None
fixed = ROW_COMMAND_TARGETS.get((app, (kind or '').lower()))
if fixed:
return {**fixed, 'target': None, 'targetkind': None}
normalized = (kind or '').lower()
if normalized == 'process' and name:
return {
'start': 'process_start',
'stop': 'process_stop',
'status': 'process_status',
'target': name,
'targetkind': 'process',
}
if normalized == 'service' and name:
return {
'start': 'service_start',
'stop': 'service_stop',
'status': 'service_status',
'target': name,
'targetkind': 'service',
}
return None
def is_allowed_action(action):
"""True when action is a known, allowlisted command name."""
return action in COMMAND_ALLOWLIST
def action_targetkind(action):
"""The targetkind an action requires ('service'/'process'), or None.
Generic actions declare a targetkind in COMMAND_ALLOWLIST; fixed-target
actions (mtconnect_*) declare none and must not carry a wire target.
"""
meta = COMMAND_ALLOWLIST.get(action) or {}
return meta.get('targetkind')
def requires_target(action):
"""True when the action is generic and needs a wire target."""
return action_targetkind(action) is not None
def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
"""The action names an endpoint may run.
Base allowlist is flat (every action for every computer). When the computers
Configuration/Business Policy feature is enabled AND an assetid is given, the
result is narrowed to the actions the endpoint's effective policy permits -
this is the allowlist-gating half of the policy design. With the feature off
(or no assetid), it returns the full base allowlist, so nothing changes until
a site opts in. Policy resolution is imported lazily to keep this module free
of a hard dependency on the computers plugin.
"""
base = set(COMMAND_ALLOWLIST.keys())
if assetid is None:
return base
try:
from plugins.computers.services.policyresolver import effective_policy_for
except Exception:
return base
effective = effective_policy_for(assetid)
allowed = effective.allowed_actions()
if not allowed:
# No policy spoke for this endpoint. With the feature off the resolver
# returns empty and we fall back to the base allowlist (unchanged
# behavior). This is intentionally permissive; deny-by-default would be
# a separate, explicit setting.
return base
# Never widen past the code-level allowlist: policy can only gate within it.
return base & allowed

View File

@@ -0,0 +1,46 @@
"""Curated Windows event catalog: id -> human title and severity.
Single server-side source of truth shared by the recent-events asset panel and
the stability dashboard card, so a 4625 reads as "Failed logon (warning)" in
both places without either restating the mapping. The set of IDs here MUST stay
in step with CURATED_EVENT_IDS in plugin.py; a test asserts they match.
Severity vocabulary matches the dashboard card renderer: 'critical', 'warning',
'info'. The IDs marked critical are the ones an operator/tech should act on
without being asked - unexpected shutdown, crash, filesystem corruption, SCM
timeout. Logon activity is informational history unless it is a failure.
"""
# id -> (title, severity, channel)
EVENT_CATALOG = {
4624: ('Successful logon', 'info', 'Security'),
4625: ('Failed logon', 'warning', 'Security'),
4634: ('Logoff', 'info', 'Security'),
4672: ('Special privileges assigned', 'info', 'Security'),
4697: ('Service installed', 'warning', 'Security'),
4698: ('Scheduled task created', 'warning', 'Security'),
41: ('Unexpected shutdown', 'critical', 'System'),
4618: ('Monitored security event', 'warning', 'Security'),
55: ('File system corruption', 'critical', 'System'),
7011: ('Service timeout', 'critical', 'System'),
10016: ('DCOM permission error', 'warning', 'System'),
1001: ('Application crash', 'critical', 'Application'),
}
# The IDs a tech should see surfaced fleet-wide on the dashboard stability card.
CRITICAL_EVENT_IDS = tuple(
eventid for eventid, (_t, severity, _c) in EVENT_CATALOG.items()
if severity == 'critical'
)
def event_title(eventid):
"""Human title for a curated event id, or a generic fallback."""
entry = EVENT_CATALOG.get(eventid)
return entry[0] if entry else 'Event {}'.format(eventid)
def event_severity(eventid):
"""Severity ('critical'|'warning'|'info') for a curated event id."""
entry = EVENT_CATALOG.get(eventid)
return entry[1] if entry else 'info'

View File

@@ -0,0 +1,26 @@
/**
* Endpoints plugin routes
*
* Groundwork for the fleet view. There is no endpoint-specific detail route
* yet: rows link into the existing PC detail page (/pcs/:id) so telemetry
* stays interoperable with the computers plugin instead of duplicating it.
*/
export default [
{
path: 'endpoints',
name: 'endpoints',
component: () => import('./views/EndpointsList.vue'),
meta: { plugin: 'endpoints' }
},
{
path: 'settings/endpoints/agentkey',
name: 'endpoints-agentkey-settings',
component: () => import('./views/AgentKeySettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
}
]
// No full-screen routes: every view in this plugin renders inside
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
// without Rollup warning about a missing export.
export const toplevel = []

View File

@@ -0,0 +1,293 @@
<template>
<div>
<div class="page-header">
<h2>Endpoints</h2>
</div>
<p class="setting-description">
Manage how shop-floor endpoint agents authenticate to this server, build
the agent for this site, and set what it reports and how often.
</p>
<!-- ====================================================================
Key management
================================================================= -->
<section class="settings-section">
<h3>Key management</h3>
<p class="field-hint">
The endpoint agent reports with a single managed key scoped to endpoint
telemetry only. It is separate from every other collector, so rotating
or revoking it stops all endpoint-agent traffic immediately without
touching the PC or GE-Enforce collectors.
</p>
<div class="card form-card">
<div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="loading" class="field-hint">Loading...</div>
<template v-else>
<div class="form-group">
<label>Status</label>
<p v-if="key" class="field-hint">
Active key <code class="mono">{{ key.tokenprefix }}...</code>,
created {{ formatDate(key.createddate) }}.
<template v-if="key.lastusedat">
Last used {{ formatDate(key.lastusedat) }}.
</template>
<template v-else>Not used yet.</template>
</p>
<p v-else class="field-hint">
No endpoint agent key is active. Generate one to let agents report
with a managed credential.
</p>
</div>
<div class="form-group">
<label>Bootstrap .env fallback</label>
<p class="field-hint">
<template v-if="envkeyfallback">
<strong>On.</strong> The bootstrap COLLECTOR_API_KEY still
authorizes endpoint reports. Generating a key turns this off so
the managed key becomes the only credential.
</template>
<template v-else>
<strong>Off.</strong> Only the managed key above authorizes
endpoint reports, so revoking it is an instant kill switch.
</template>
</p>
</div>
<div class="button-row">
<button class="btn btn-primary" @click="regenerate"
:disabled="busy">
{{ busy ? 'Working...' : (key ? 'Regenerate key' : 'Generate key') }}
</button>
<button v-if="key" class="btn btn-danger" @click="revoke"
:disabled="busy">
Revoke key
</button>
</div>
</template>
</div>
<div v-if="secret" class="card form-card secret-card">
<h4>Save this key now</h4>
<p class="field-hint">
It will not be shown again. Configure it as the agent
<code>X-API-Key</code> header value.
</p>
<p class="mono secret-value">{{ secret }}</p>
<button class="btn btn-secondary" @click="copySecret">
{{ copied ? 'Copied' : 'Copy to clipboard' }}
</button>
</div>
<div class="card form-card">
<h4>How it works</h4>
<ul class="field-hint info-list">
<li>
<strong>Generate</strong> mints a fresh key and, on the first
generation, turns off the bootstrap <code>.env</code> fallback so
the managed key becomes the only accepted credential. The full
secret is shown once - store it in the agent config as the
<code>X-API-Key</code> header.
</li>
<li>
<strong>Regenerate</strong> is a hard cutover: the previous key is
revoked in the same moment the new one is minted. Every deployed
agent must be reconfigured with the new key before it can report
again.
</li>
<li>
<strong>Revoke</strong> deactivates the key and leaves it off. All
endpoint-agent reporting stops until you generate a new key. This is
the kill switch.
</li>
</ul>
<h4>When to rotate</h4>
<ul class="field-hint info-list">
<li>
<strong>An installer or agent left the secure environment.</strong>
If a build carrying the key is copied off the shop floor - onto an
engineer's laptop, a USB stick, or a machine that leaves the site -
treat the key as compromised and regenerate immediately.
</li>
<li>
<strong>A device is lost, stolen, or decommissioned</strong> while
still holding the key, and you cannot confirm the key was wiped.
</li>
<li>
<strong>Staff offboarding.</strong> Someone who could read the key
from a config or build pipeline is leaving; rotate as part of the
offboarding checklist.
</li>
<li>
<strong>Scheduled hygiene.</strong> Rotate on a routine cadence
(for example quarterly) so a silently leaked key has a bounded
lifetime.
</li>
<li>
<strong>Suspicious traffic.</strong> The Fleet view shows reports
from a host you do not recognize - revoke now, investigate, then
regenerate and redeploy to known-good endpoints.
</li>
</ul>
</div>
</section>
<!-- ====================================================================
Build site agent (placeholder)
================================================================= -->
<section class="settings-section">
<h3>Build site agent</h3>
<div class="card form-card">
<p class="field-hint">
Produce an instance-locked agent executable stamped with this server's
URL, the endpoint agent key, and the reporting cadence, so it deploys
with no manual configuration on the client.
</p>
<p class="field-hint">
<strong>Coming soon.</strong> The build pipeline is not wired up yet -
the C# agent runtime is still under development. Generate a key above
and configure it manually as the agent <code>X-API-Key</code> for now.
</p>
<button class="btn btn-secondary" type="button" disabled>
Build agent (coming soon)
</button>
</div>
</section>
<!-- ====================================================================
Agent configuration (placeholder)
================================================================= -->
<section class="settings-section">
<h3>Agent configuration</h3>
<div class="card form-card">
<p class="field-hint">
Define what the agent collects and how often: reporting cadence,
monitored processes and services, the curated Windows event list, and
whether unknown hosts self-adopt on first contact. These settings are
stamped into the build above so a deployed agent needs no local edits.
</p>
<p class="field-hint">
<strong>Coming soon.</strong> These controls arrive with the C# agent
runtime. Until then the agent uses its built-in defaults.
</p>
<button class="btn btn-secondary" type="button" disabled>
Edit configuration (coming soon)
</button>
</div>
</section>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '@/api'
const loading = ref(true)
const busy = ref(false)
const key = ref(null)
const envkeyfallback = ref(true)
const secret = ref('')
const copied = ref(false)
const message = ref('')
const error = ref('')
function formatDate(value) {
if (!value) return ''
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString()
}
function applyStatus(data) {
key.value = data.key || null
envkeyfallback.value = !!data.envkeyfallback
}
onMounted(async () => {
try {
const response = await api.get('/endpoints/agentkey')
applyStatus(response.data.data || {})
} catch (loadError) {
error.value = loadError.response?.status === 403
? 'Only an administrator can manage the endpoint agent key'
: 'Could not load the endpoint agent key status'
console.error(loadError)
} finally {
loading.value = false
}
})
async function regenerate() {
if (key.value &&
!window.confirm('Regenerate the key? The current key stops working '
+ 'immediately and every agent must be reconfigured.')) {
return
}
busy.value = true
message.value = ''
error.value = ''
secret.value = ''
copied.value = false
try {
const response = await api.post('/endpoints/agentkey/regenerate')
const data = response.data.data || {}
applyStatus(data)
secret.value = data.secret || ''
message.value = 'Endpoint agent key generated.'
} catch (regenError) {
error.value = regenError.response?.data?.data?.error?.message
|| 'Could not generate the endpoint agent key'
} finally {
busy.value = false
}
}
async function revoke() {
if (!window.confirm('Revoke the key? All endpoint-agent reporting stops '
+ 'until a new key is generated.')) {
return
}
busy.value = true
message.value = ''
error.value = ''
secret.value = ''
try {
const response = await api.delete('/endpoints/agentkey')
applyStatus(response.data.data || {})
message.value = 'Endpoint agent key revoked.'
} catch (revokeError) {
error.value = revokeError.response?.data?.data?.error?.message
|| 'Could not revoke the endpoint agent key'
} finally {
busy.value = false
}
}
async function copySecret() {
try {
await navigator.clipboard.writeText(secret.value)
copied.value = true
} catch (copyError) {
console.error(copyError)
}
}
</script>
<style scoped>
.mono { font-family: monospace; word-break: break-all; }
.form-card h3 { margin-top: 0; }
.form-card h4 { margin-top: 1rem; margin-bottom: 0.25rem; }
.form-card h4:first-child { margin-top: 0; }
.button-row { display: flex; gap: 0.5rem; }
.secret-card { border: 1px solid var(--color-warning, #b8860b); }
.secret-value { padding: 0.5rem; background: var(--color-surface-2, #1e1e1e); }
.settings-section { margin-bottom: 2rem; }
.settings-section > h3 { margin-bottom: 0.25rem; }
.info-list { margin: 0.25rem 0 0; padding-left: 1.25rem; }
.info-list li { margin-bottom: 0.5rem; }
</style>

View File

@@ -0,0 +1,153 @@
<template>
<div>
<div class="page-header">
<h2>Fleet</h2>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search endpoints..."
@input="debouncedSearch"
/>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Hostname</th>
<th>Logged-in User</th>
<th>Uptime</th>
<th>CPU</th>
<th>Memory</th>
<th>Last Reported</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in endpoints"
:key="item.assetid"
class="clickable-row"
:class="{ disabled: !item.computerid }"
@click="openPc(item)"
>
<td>{{ item.hostname || '-' }}</td>
<td>{{ item.loggedinuser || '-' }}</td>
<td>{{ item.uptimedisplay || '-' }}</td>
<td>{{ formatPercent(item.cpupercent) }}</td>
<td>{{ formatPercent(item.memorypercent) }}</td>
<td>{{ formatTime(item.lastreportedat) }}</td>
</tr>
<tr v-if="endpoints.length === 0">
<td colspan="6" style="text-align: center; color: var(--text-light);">
No endpoints found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { endpointsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
const router = useRouter()
const endpoints = ref([])
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadEndpoints })
const totalPages = ref(1)
const perPage = ref(20)
let searchTimeout = null
onMounted(() => {
loadEndpoints()
})
async function loadEndpoints() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value
}
if (search.value) params.search = search.value
const response = await endpointsApi.list(params)
endpoints.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading endpoints:', error)
} finally {
loading.value = false
}
}
// Interoperate with PC Details instead of a bespoke detail page: a row only
// navigates when it resolved to a computerid.
function openPc(item) {
if (item.computerid) {
router.push(`/pcs/${item.computerid}`)
}
}
function formatPercent(value) {
if (value === null || value === undefined) return '-'
return `${Math.round(value)}%`
}
function formatTime(value) {
if (!value) return '-'
return new Date(value).toLocaleString()
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
setSearch(search.value)
loadEndpoints()
}, 300)
}
function goToPage(p) {
setPage(p)
loadEndpoints()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
setPage(1)
loadEndpoints()
}
</script>
<style scoped>
.clickable-row.disabled {
cursor: default;
opacity: 0.6;
}
</style>

View File

@@ -0,0 +1,21 @@
{
"name": "endpoints",
"version": "0.1.0",
"description": "Endpoint RMM telemetry: metrics, services, inventory, and curated Windows event records reported by the ShopDB agent",
"author": "ShopDB Team",
"dependencies": ["computers"],
"core_version": ">=0.20.0,<1.0.0",
"api_prefix": "/api/endpoints",
"provides": {
"features": [
"endpoint_metrics",
"service_state",
"inventory_snapshot",
"event_records"
]
},
"settings": {
"report_interval_minutes": 15,
"quiet_report_hours": 24
}
}

View File

@@ -0,0 +1,15 @@
"""Alembic environment for the endpoints plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which filters
the metadata to this plugin's tables and drives Alembic against the per-plugin
version table alembic_version_endpoints. See ADR-008 for the ownership model.
This plugin is NEW: its 0001 baseline really CREATES its tables, because the
core chain never built them.
"""
import os
os.environ['PLUGIN_NAME'] = 'endpoints'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
run_migrations()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,96 @@
"""endpoints plugin baseline (real create).
A NEW plugin: the core chain never knew about endpointstates or endpointevents,
so this per-plugin chain is their sole authoritative creator.
Emits explicit Alembic ops rather than using the create_plugin_tables helper:
the helper builds a per-plugin MetaData filtered to the plugin's own tables, so
the foreign key to the core assets table cannot resolve at CreateTable-compile
time (NoReferencedTableError). Tables inherit the connection's default charset,
matching how the core chain creates its own.
"""
from alembic import op
from shopdb.plugins.alembic_template import (
create_index_if_missing,
create_table_if_missing,
)
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0001baseline'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
create_table_if_missing(
'endpointstates',
sa.Column('endpointstateid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('hostname', sa.String(length=100), nullable=True),
sa.Column('loggedinuser', sa.String(length=100), nullable=True),
sa.Column('agentversion', sa.String(length=40), nullable=True),
sa.Column('lastboottime', sa.DateTime(), nullable=True),
sa.Column('uptimeseconds', sa.BigInteger(), nullable=True),
sa.Column('idleseconds', sa.BigInteger(), nullable=True),
sa.Column('cpupercent', sa.Float(), nullable=True),
sa.Column('memorypercent', sa.Float(), nullable=True),
sa.Column('servicesjson', sa.Text(length=16777215), nullable=True),
sa.Column('portsjson', sa.Text(length=16777215), nullable=True),
sa.Column('interfacesjson', sa.Text(length=16777215), nullable=True),
sa.Column('scheduledtasksjson', sa.Text(length=16777215),
nullable=True),
sa.Column('lastreportedat', sa.DateTime(), nullable=True),
sa.Column('createdat', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('endpointstateid'),
sa.UniqueConstraint('assetid'),
)
create_index_if_missing('ix_endpointstates_assetid',
'endpointstates', ['assetid'])
create_index_if_missing('ix_endpointstates_lastreportedat',
'endpointstates', ['lastreportedat'])
create_table_if_missing(
'endpointevents',
sa.Column('endpointeventid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('eventid', sa.Integer(), nullable=False),
sa.Column('eventchannel', sa.String(length=50), nullable=True),
sa.Column('eventsource', sa.String(length=255), nullable=True),
sa.Column('eventtime', sa.DateTime(), nullable=True),
sa.Column('eventmessage', sa.Text(), nullable=True),
sa.Column('payloadjson', sa.Text(length=16777215), nullable=True),
sa.Column('recordedat', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('endpointeventid'),
)
create_index_if_missing('ix_endpointevents_assetid', 'endpointevents',
['assetid'])
create_index_if_missing('ix_endpointevents_eventid', 'endpointevents',
['eventid'])
create_index_if_missing('ix_endpointevents_eventtime', 'endpointevents',
['eventtime'])
# Alerting/history reads by "this endpoint's events over time", so the
# composite is the index that actually gets used.
create_index_if_missing('idx_endpointevents_assetevent', 'endpointevents',
['assetid', 'eventtime'])
def downgrade():
op.drop_index('idx_endpointevents_assetevent',
table_name='endpointevents')
op.drop_index('ix_endpointevents_eventtime', table_name='endpointevents')
op.drop_index('ix_endpointevents_eventid', table_name='endpointevents')
op.drop_index('ix_endpointevents_assetid', table_name='endpointevents')
op.drop_table('endpointevents')
op.drop_index('ix_endpointstates_lastreportedat',
table_name='endpointstates')
op.drop_index('ix_endpointstates_assetid', table_name='endpointstates')
op.drop_table('endpointstates')

View File

@@ -0,0 +1,84 @@
"""endpoint commands queue + cached MTConnect state.
Adds the endpointcommands queue (one WebUI-issued, server-allowlisted action
per row, claimed and finalized by the agent long-poll) and two cached MTConnect
run-state columns on endpointstates so the WebUI toggle can show a real state
between command polls.
Idempotent: create_table_if_missing guards the new table, and the column adds
check the live schema first so a db.create_all() site (which already built the
columns) re-runs clean on strict MySQL 8.
"""
from alembic import op
from shopdb.plugins.alembic_template import (
create_index_if_missing,
create_table_if_missing,
)
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0002commands'
down_revision = 'endpoints0001baseline'
branch_labels = None
depends_on = None
def _has_column(table_name, column_name):
from sqlalchemy import inspect
inspector = inspect(op.get_bind())
if not inspector.has_table(table_name):
return False
return any(col['name'] == column_name
for col in inspector.get_columns(table_name))
def upgrade():
if not _has_column('endpointstates', 'mtconnectrunning'):
op.add_column('endpointstates',
sa.Column('mtconnectrunning', sa.Boolean(),
nullable=True))
if not _has_column('endpointstates', 'mtconnectcheckedat'):
op.add_column('endpointstates',
sa.Column('mtconnectcheckedat', sa.DateTime(),
nullable=True))
create_table_if_missing(
'endpointcommands',
sa.Column('endpointcommandid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('action', sa.String(length=64), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('requestedby', sa.Integer(), nullable=True),
sa.Column('resultmessage', sa.Text(), nullable=True),
sa.Column('resultjson', sa.Text(length=16777215), nullable=True),
sa.Column('createdat', sa.DateTime(), nullable=False),
sa.Column('claimedat', sa.DateTime(), nullable=True),
sa.Column('completedat', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('endpointcommandid'),
)
create_index_if_missing('ix_endpointcommands_assetid',
'endpointcommands', ['assetid'])
create_index_if_missing('ix_endpointcommands_status',
'endpointcommands', ['status'])
create_index_if_missing('idx_endpointcommands_assetstatus',
'endpointcommands', ['assetid', 'status'])
def downgrade():
op.drop_index('idx_endpointcommands_assetstatus',
table_name='endpointcommands')
op.drop_index('ix_endpointcommands_status',
table_name='endpointcommands')
op.drop_index('ix_endpointcommands_assetid',
table_name='endpointcommands')
op.drop_table('endpointcommands')
if _has_column('endpointstates', 'mtconnectcheckedat'):
op.drop_column('endpointstates', 'mtconnectcheckedat')
if _has_column('endpointstates', 'mtconnectrunning'):
op.drop_column('endpointstates', 'mtconnectrunning')

View File

@@ -0,0 +1,50 @@
"""endpoint command target for generic service/process actions.
Adds nullable target and targetkind columns to endpointcommands so generic
service_* / process_* actions can carry their subject (a Windows service name or
a process image like 'DncMain.exe') over the wire. Fixed-target actions
(mtconnect_*) leave both null. This is the schema half of making the agent
data-driven: a site builds the agent once and every future policy/app is server
data, no agent redistribution.
Idempotent: each add checks the live schema first so a db.create_all() site
(which already built the columns) re-runs clean on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0003cmdtarget'
down_revision = 'endpoints0002commands'
branch_labels = None
depends_on = None
def _has_column(table_name, column_name):
from sqlalchemy import inspect
inspector = inspect(op.get_bind())
if not inspector.has_table(table_name):
return False
return any(col['name'] == column_name
for col in inspector.get_columns(table_name))
def upgrade():
if not _has_column('endpointcommands', 'target'):
op.add_column('endpointcommands',
sa.Column('target', sa.String(length=255),
nullable=True))
if not _has_column('endpointcommands', 'targetkind'):
op.add_column('endpointcommands',
sa.Column('targetkind', sa.String(length=16),
nullable=True))
def downgrade():
if _has_column('endpointcommands', 'targetkind'):
op.drop_column('endpointcommands', 'targetkind')
if _has_column('endpointcommands', 'target'):
op.drop_column('endpointcommands', 'target')

View File

@@ -0,0 +1,5 @@
"""Endpoints plugin models."""
from .endpoint import EndpointState, EndpointEvent, EndpointCommand
__all__ = ['EndpointState', 'EndpointEvent', 'EndpointCommand']

View File

@@ -0,0 +1,248 @@
"""Endpoint RMM telemetry models.
Two shapes, one per concern:
EndpointState The cached CURRENT snapshot for an asset - one row per asset,
overwritten in place on every agent report. Holds the fast-
moving metrics (uptime, idle, last boot, logged-in user) and
the latest inventory snapshots (services/processes, open ports,
interfaces, scheduled tasks) as serialized JSON. This is the
cached-current-value half of the ledger pattern: it moves in
the SAME commit as any EndpointEvent rows the report carries.
EndpointEvent Append-only record of a curated Windows Event Log entry. The
agent only forwards a fixed catalog of IDs (logon/logoff,
privilege use, service install, unexpected shutdown, DCOM,
crash) so this stays a readable security/stability history
rather than a raw event firehose.
Both link to the core Asset via assetid (FK, ON DELETE CASCADE), so an endpoint
is the same physical thing as its Computer/Asset - no duplicate identity. The
agent reports a hostname; the plugin resolves it to the existing Computer.
"""
from datetime import datetime
from shopdb.api import db
def _utciso(value):
"""Naive-UTC datetime -> ISO string marked UTC, or None.
Every datetime this plugin stores is naive UTC. Serializing without the Z
leaves the receiver to guess, and JavaScript guesses browser-local.
"""
return value.isoformat() + 'Z' if value else None
class EndpointState(db.Model):
"""Latest reported state for a single endpoint (one row per asset)."""
__tablename__ = 'endpointstates'
endpointstateid = db.Column(db.Integer, primary_key=True)
# The asset this state belongs to. Unique: exactly one current snapshot per
# endpoint. Resolved from the reported hostname against the computers plugin.
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
unique=True,
nullable=False,
index=True,
)
# Read-only view of the owning asset. No cascade or backref: the asset side
# must not gain a dependency on this plugin.
asset = db.relationship('Asset', lazy='joined', viewonly=True)
hostname = db.Column(db.String(100), nullable=True)
loggedinuser = db.Column(db.String(100), nullable=True)
agentversion = db.Column(db.String(40), nullable=True)
# Fast-moving metrics
lastboottime = db.Column(db.DateTime, nullable=True)
uptimeseconds = db.Column(db.BigInteger, nullable=True)
idleseconds = db.Column(db.BigInteger, nullable=True)
cpupercent = db.Column(db.Float, nullable=True)
memorypercent = db.Column(db.Float, nullable=True)
# Latest inventory snapshots, serialized JSON (MEDIUMTEXT). Kept as the
# newest snapshot rather than one row per sample: the agent reports the full
# list each cycle and history of these lists is not what operators ask for.
servicesjson = db.Column(db.Text(length=16777215), nullable=True)
portsjson = db.Column(db.Text(length=16777215), nullable=True)
interfacesjson = db.Column(db.Text(length=16777215), nullable=True)
scheduledtasksjson = db.Column(db.Text(length=16777215), nullable=True)
# Last-known MTConnect agent run state, cached so the WebUI toggle can show
# a real state between command polls. Set by the agent's mtconnect_status
# result. Null = never reported (unknown).
mtconnectrunning = db.Column(db.Boolean, nullable=True)
mtconnectcheckedat = db.Column(db.DateTime, nullable=True)
# When the agent last successfully reported. There is no heartbeat separate
# from the report, so silence past the quiet-hours window is the only
# evidence an endpoint has stopped checking in.
lastreportedat = db.Column(db.DateTime, nullable=True, index=True)
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
def __repr__(self):
return f"<EndpointState {self.hostname or self.assetid}>"
def to_dict(self):
return {
'endpointstateid': self.endpointstateid,
'assetid': self.assetid,
'hostname': self.hostname,
'loggedinuser': self.loggedinuser,
'agentversion': self.agentversion,
'lastboottime': _utciso(self.lastboottime),
'uptimeseconds': self.uptimeseconds,
'idleseconds': self.idleseconds,
'cpupercent': self.cpupercent,
'memorypercent': self.memorypercent,
'mtconnectrunning': self.mtconnectrunning,
'mtconnectcheckedat': _utciso(self.mtconnectcheckedat),
'lastreportedat': _utciso(self.lastreportedat),
'createdat': _utciso(self.createdat),
}
class EndpointCommand(db.Model):
"""A queued operator command targeting one endpoint agent.
The WebUI enqueues a named action (server allowlist only, never an arbitrary
command string) for an asset. The agent claims the next pending command on
its long-poll, executes it locally, and reports the outcome back, which
moves the row to a terminal status in the same request. This is a small
at-most-one-in-flight queue, not a general job system.
"""
__tablename__ = 'endpointcommands'
# Lifecycle: pending -> running -> succeeded|failed. A command is claimed
# (pending -> running) by exactly one agent poll, then finalized by its
# result post.
STATUS_PENDING = 'pending'
STATUS_RUNNING = 'running'
STATUS_SUCCEEDED = 'succeeded'
STATUS_FAILED = 'failed'
endpointcommandid = db.Column(db.Integer, primary_key=True)
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
index=True,
)
asset = db.relationship('Asset', lazy='joined', viewonly=True)
# The allowlisted action name (for example 'mtconnect_start'). Validated
# against the server allowlist at enqueue time, so only known names land.
action = db.Column(db.String(64), nullable=False)
# Generic actions (service_* / process_*) carry their subject over the wire:
# target is a Windows service name or a process image (for example
# 'DncMain.exe'); targetkind is 'service' or 'process'. Fixed-target actions
# (mtconnect_*) leave both null. This is what lets a site build the agent
# once and drive every future policy/app purely from server data.
target = db.Column(db.String(255), nullable=True)
targetkind = db.Column(db.String(16), nullable=True)
status = db.Column(db.String(20), nullable=False,
default=STATUS_PENDING, index=True)
# Who queued it (userid) for audit; the agent never sees this.
requestedby = db.Column(db.Integer, nullable=True)
# Agent-reported outcome. resultjson holds structured detail (for example
# the observed MTConnect running state); resultmessage is a short summary.
resultmessage = db.Column(db.Text, nullable=True)
resultjson = db.Column(db.Text(length=16777215), nullable=True)
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
claimedat = db.Column(db.DateTime, nullable=True)
completedat = db.Column(db.DateTime, nullable=True)
__table_args__ = (
db.Index('idx_endpointcommands_assetstatus', 'assetid', 'status'),
)
def __repr__(self):
return f"<EndpointCommand {self.action} asset={self.assetid} {self.status}>"
def to_dict(self):
return {
'endpointcommandid': self.endpointcommandid,
'assetid': self.assetid,
'action': self.action,
'target': self.target,
'targetkind': self.targetkind,
'status': self.status,
'requestedby': self.requestedby,
'resultmessage': self.resultmessage,
'createdat': _utciso(self.createdat),
'claimedat': _utciso(self.claimedat),
'completedat': _utciso(self.completedat),
}
class EndpointEvent(db.Model):
"""A single curated Windows Event Log record forwarded by the agent."""
__tablename__ = 'endpointevents'
endpointeventid = db.Column(db.Integer, primary_key=True)
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
index=True,
)
asset = db.relationship('Asset', lazy='joined', viewonly=True)
# The Windows Event ID (for example 4625 failed logon, 41 unexpected
# shutdown, 1001 application crash). Indexed because alerting queries filter
# on it.
eventid = db.Column(db.Integer, nullable=False, index=True)
# Event Log channel the record came from: Security, System, Application.
eventchannel = db.Column(db.String(50), nullable=True)
eventsource = db.Column(db.String(255), nullable=True)
# When the event happened on the endpoint (from the record), not when it
# reached ShopDB.
eventtime = db.Column(db.DateTime, nullable=True, index=True)
eventmessage = db.Column(db.Text, nullable=True)
# Structured fields the agent extracted for this ID (for example the target
# account on a 4625). Advisory index only, not part of dedup.
payloadjson = db.Column(db.Text(length=16777215), nullable=True)
recordedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
__table_args__ = (
db.Index('idx_endpointevents_assetevent', 'assetid', 'eventtime'),
)
def __repr__(self):
return f"<EndpointEvent {self.eventid} asset={self.assetid}>"
def to_dict(self):
return {
'endpointeventid': self.endpointeventid,
'assetid': self.assetid,
'eventid': self.eventid,
'eventchannel': self.eventchannel,
'eventsource': self.eventsource,
'eventtime': _utciso(self.eventtime),
'eventmessage': self.eventmessage,
'recordedat': _utciso(self.recordedat),
}

596
plugins/endpoints/plugin.py Normal file
View File

@@ -0,0 +1,596 @@
"""Endpoints plugin main class.
Endpoint RMM telemetry reported by the ShopDB agent. Accepts collector input
over HTTPS per ADR-006: the agent posts a per-host payload to
POST /api/collector/endpoints, the plugin resolves the reported hostname to an
existing computer asset, overwrites that asset's cached EndpointState, and
appends any curated Windows events the report carried - all in one commit, so
the cached snapshot and the events it summarizes never disagree.
Identity is the hostname, resolved against the computers plugin. An unresolved
hostname is reported as a ValueError (surfaced to the caller as a validation
error), never silently dropped: an unknown host is a real gap between the fleet
and ShopDB, the same discipline the backups collector uses for machine numbers.
"""
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Type
from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.api import db
from .api import endpoints_bp
from .eventcatalog import EVENT_CATALOG
from .models import EndpointState, EndpointEvent, EndpointCommand
logger = logging.getLogger(__name__)
# The fixed catalog of Windows Event IDs the agent forwards. Derived from
# EVENT_CATALOG (eventcatalog.py) so the ingest filter and the display titles
# can never drift. IDs outside this set are ignored on ingest rather than
# stored, keeping endpointevents a readable security/stability history instead
# of a raw event firehose.
CURATED_EVENT_IDS = frozenset(EVENT_CATALOG)
def _parse_dt(value):
"""Best-effort parse of an ISO-8601 string to naive UTC, or None."""
if not value:
return None
if isinstance(value, datetime):
return value.replace(tzinfo=None)
text = str(value).strip().replace('Z', '+00:00')
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is not None:
parsed = parsed.astimezone(tz=None).replace(tzinfo=None)
return parsed
def _as_json(value):
"""Serialize a list/dict to JSON text, or None when nothing was sent."""
if value is None:
return None
return json.dumps(value, separators=(',', ':'), sort_keys=True)
class EndpointsPlugin(BasePlugin):
"""Endpoint RMM telemetry ingest plus read/dashboard surface."""
def __init__(self):
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
manifest_path = Path(__file__).parent / 'manifest.json'
if manifest_path.exists():
with open(manifest_path, 'r') as f:
return json.load(f)
return {}
@property
def meta(self) -> PluginMeta:
return PluginMeta(
name=self._manifest.get('name', 'endpoints'),
version=self._manifest.get('version', '0.1.0'),
description=self._manifest.get(
'description', 'Endpoint RMM telemetry'),
author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', ['computers']),
core_version=self._manifest.get('core_version', '>=0.20.0,<1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/endpoints'),
)
def get_blueprint(self) -> Optional[Blueprint]:
return endpoints_bp
def get_models(self) -> List[Type]:
return [EndpointState, EndpointEvent, EndpointCommand]
def get_permissions(self) -> List:
"""RBAC for the human-facing read routes.
View only. Ingest is not covered here: the collector path authorizes on
a collector.ingest-scoped service token, not a user JWT. There is no
write/delete permission because operators do not edit telemetry - the
agent is the sole author, and history is pruned by policy, not by hand.
"""
return [
('endpoints.view', 'View endpoint telemetry', 'endpoints'),
# Issue an allowlisted command to an endpoint agent (for example the
# MTConnect toggle). Separate from endpoints.view because acting on a
# shop-floor PC is a heavier grant than reading its telemetry.
('endpoints.command',
'Send allowlisted commands to endpoint agents', 'endpoints'),
# Elevated grant for destructive endpoint actions (system_restart).
# Required IN ADDITION to endpoints.command by the enqueue route, so a
# remote reboot is a strictly heavier grant than a routine toggle.
('endpoints.restart',
'Restart endpoint computers (destructive)', 'endpoints'),
# Collector service scope for the endpoint agent. A managed token
# scoped to ONLY this authorizes the endpoints collector and nothing
# else, so rotating/revoking it stops endpoint-agent traffic without
# touching computers/geenforce collectors. Minted from the settings
# UI (see get_settings_cards). Admins hold it via the catalog.
('collector.ingest.endpoints',
'Submit endpoint agent telemetry (fleet reporting)', 'collector'),
]
def get_settings_defaults(self) -> List[Dict]:
"""Settings owned by the endpoints agent-key kill switch.
`envkeyfallback` gates the bootstrap .env collector key: while true the
old COLLECTOR_API_KEY[_ENDPOINTS] still authorizes (back-compat, and the
only path before a UI key exists). The regenerate flow flips it false so
the .env key stops working and the managed token is the sole credential,
making revocation an instant kill switch. `agentkeyprovisioned` records
that a UI-managed key has been minted so the settings page can render
the rotate/revoke state instead of the first-run generate state.
"""
return [
{
'key': 'plugin.endpoints.envkeyfallback',
'value': 'true',
'valuetype': 'boolean',
'category': 'plugin.endpoints',
'description': 'Accept the bootstrap .env collector key for '
'endpoints. Turned off once a UI-managed agent '
'key is provisioned so revocation is a real kill '
'switch.',
},
{
'key': 'plugin.endpoints.agentkeyprovisioned',
'value': 'false',
'valuetype': 'boolean',
'category': 'plugin.endpoints',
'description': 'True once an operator has minted a UI-managed '
'endpoint agent key from the settings page.',
},
]
def get_settings_cards(self) -> List[Dict]:
"""The endpoints management card on the global settings page.
A settings card rather than a docs page because it is the operator's
control surface: manage the agent key (the kill switch), build the site
agent, and configure what it reports. Admin only - key management mints
a collector service credential.
"""
return [
{
'group': 'Endpoints',
'to': '/settings/endpoints/agentkey',
'icon': 'key',
'title': 'Endpoints',
'description': 'Manage the endpoint agent key, build the site '
'agent, and configure what it reports.',
'position': 27,
},
]
def get_navigation_items(self) -> List[Dict]:
"""Sidebar entry for the fleet list view.
Sits just after the PCs item (position 15) because the fleet view is
a telemetry lens over the same computers.
"""
return [
{
'name': 'Fleet',
'icon': 'activity',
'route': '/endpoints',
'position': 16,
'permission': 'endpoints.view',
'section': 'fleet',
},
]
def init_app(self, app: Flask, db_instance) -> None:
logger.info('Endpoints plugin initialized (v%s)', self.meta.version)
# ---- ADR-010 UI hooks -----------------------------------------------
def get_asset_panels(self) -> List[Dict]:
"""Per-PC detail panels, computer assets only.
Panels feeding the generic renderer: the current status snapshot
(keyvalue), monitored services and processes split into two list cards
that share one services snapshot (filtered by kind), the most recent
curated events (list), and the network interfaces (table). Each is a
single-asset indexed read, so the cost is the same profile as the
warranty/backups panels. Panels hide themselves when their endpoint
returns nothing.
"""
return [
{
'id': 'endpoints-status',
'title': 'Endpoint status',
'assettypes': ['computer'],
'endpoint': '/api/endpoints/asset/{assetid}/status',
'render': 'keyvalue',
'empty': 'No agent report yet.',
'position': 10,
},
{
'id': 'endpoints-tracked-services',
'title': 'Tracked Services',
'assettypes': ['computer'],
'endpoint': '/api/endpoints/asset/{assetid}/services',
'render': 'list',
'collapsible': True,
'filter': {'key': 'kind', 'equals': 'service'},
'status': {
'key': 'statecolor',
'levels': {
'green': 'ok',
'yellow': 'warning',
'red': 'critical',
},
},
'map': {
'title': 'name',
'badge': {'label': 'statelabel', 'color': 'statecolor'},
'action': 'action',
},
'commandendpoint': '/api/endpoints/{assetid}/commands',
'permission': 'endpoints.command',
'empty': 'No monitored services reported.',
'position': 12,
},
{
'id': 'endpoints-tracked-processes',
'title': 'Tracked Processes',
'assettypes': ['computer'],
'endpoint': '/api/endpoints/asset/{assetid}/services',
'render': 'list',
'collapsible': True,
'filter': {'key': 'kind', 'equals': 'process'},
'status': {
'key': 'statecolor',
'levels': {
'green': 'ok',
'yellow': 'warning',
'red': 'critical',
},
},
'map': {
'title': 'name',
'badge': {'label': 'statelabel', 'color': 'statecolor'},
'action': 'action',
},
'commandendpoint': '/api/endpoints/{assetid}/commands',
'permission': 'endpoints.command',
'empty': 'No monitored processes reported.',
'position': 13,
},
{
'id': 'endpoints-events',
'title': 'Recent events',
'assettypes': ['computer'],
'endpoint': '/api/endpoints/asset/{assetid}/recentevents',
'render': 'list',
'collapsible': True,
'status': {
'key': 'severity',
'levels': {
'info': 'ok',
'warning': 'warning',
'critical': 'critical',
},
},
'map': {
'title': 'title',
'badge': {
'label': 'severity',
'labelmap': {
'critical': 'Critical',
'warning': 'Warning',
'info': 'Info',
},
},
'meta': [
{'key': 'eventtime', 'label': 'At', 'format': 'date'},
],
},
'empty': 'No recent events.',
'collapsedDefault': True,
'position': 90,
},
{
'id': 'endpoints-interfaces',
'title': 'Network interfaces',
'assettypes': ['computer'],
'endpoint': '/api/endpoints/asset/{assetid}/interfaces',
'render': 'table',
'collapsible': True,
'columns': [
{'key': 'alias', 'label': 'Adapter'},
{'key': 'ipv4', 'label': 'IPv4'},
{'key': 'mac', 'label': 'MAC'},
{'key': 'gateway', 'label': 'Gateway'},
{'key': 'subnet', 'label': 'Subnet'},
{'key': 'dhcp', 'label': 'Assignment'},
{'key': 'role', 'label': 'Role'},
],
'empty': 'No network interfaces reported.',
'position': 16,
},
]
def get_dashboard_widgets(self) -> List[Dict]:
"""Fleet-wide exception cards. Run once on dashboard load, not per PC.
Both cards hide when empty and cap their rows, so a healthy fleet adds
nothing to the dashboard and an unhealthy one adds a bounded list.
"""
return [
{
'id': 'endpoints-quiet',
'viewall': '/pcs',
'title': 'Endpoints gone quiet',
'endpoint': '/api/endpoints/dashboard/quiet',
'render': 'exceptions',
'severity': 'warning',
'permission': 'endpoints.view',
'empty': 'hide',
'position': 22,
'map': {
'title': 'hostname',
'detail': '',
'meta': [{'key': 'quietdays', 'label': 'silent',
'suffix': ' days'}],
'link': '/pcs/{computerid}',
},
},
{
'id': 'endpoints-stability',
'viewall': '/pcs',
'title': 'Endpoints with crashes or shutdowns',
'endpoint': '/api/endpoints/dashboard/stability',
'render': 'exceptions',
'severity': 'critical',
'permission': 'endpoints.view',
'empty': 'hide',
'position': 16,
'map': {
'title': 'hostname',
'detail': '',
'meta': [{'key': 'eventcount', 'suffix': ' events (48h)'}],
'link': '/pcs/{computerid}',
},
},
]
# ---- ADR-006 collector contract -------------------------------------
def get_collector_schema(self) -> Optional[Dict]:
return {
'identityfield': 'hostname',
'fields': {
'type': 'object',
'required': ['hostname'],
'properties': {
'hostname': {
'type': 'string',
'description': 'Endpoint hostname. Resolved against '
'the computers plugin (computers.hostname).',
},
'adopt': {
'type': 'boolean',
'description': 'First-contact only. When true and the '
'hostname does not resolve, adoption is '
'delegated to the computers collector '
'(see DESIGN-DECISIONS-agent.md 1a). This '
'plugin never creates assets itself.',
},
'loggedinuser': {'type': 'string'},
'agentversion': {'type': 'string'},
'lastboottime': {'type': 'string', 'format': 'date-time'},
'uptimeseconds': {'type': 'integer'},
'idleseconds': {'type': 'integer'},
'cpupercent': {'type': 'number'},
'memorypercent': {'type': 'number'},
'services': {
'type': 'array',
'description': 'Monitored services/processes and their '
'run state (UDC, eDnc, Intune, ...).',
},
'ports': {
'type': 'array',
'description': 'Open listening ports.',
},
'interfaces': {
'type': 'array',
'description': 'Network interface information.',
},
'scheduledtasks': {
'type': 'array',
'description': 'Scheduled task inventory + last run.',
},
'events': {
'type': 'array',
'description': 'Curated Windows Event Log records. Only '
'IDs in the server catalog are stored.',
'items': {
'type': 'object',
'required': ['eventid'],
'properties': {
'eventid': {'type': 'integer'},
'eventchannel': {'type': 'string'},
'eventsource': {'type': 'string'},
'eventtime': {'type': 'string',
'format': 'date-time'},
'eventmessage': {'type': 'string'},
'fields': {'type': 'object'},
},
},
},
},
},
}
# PC-identifying and hardware fields forwarded to the computers collector,
# which owns the Asset/Computer row and renders the Hardware and Network
# cards. The endpoints plugin only caches telemetry, so these must be
# relayed or vendor/model/OS/IP never land on the PC (see
# DESIGN-DECISIONS-agent.md 1a).
_COMPUTER_HANDOFF_FIELDS = (
'loggedinuser', 'serialnumber', 'pctype', 'machinenumber',
'vendorname', 'modelnumber', 'osname', 'ipaddress', 'lastboottime',
)
def _forward_to_computers(self, hostname: str, payload: Dict):
"""Return the computers plugin and the PC subset to hand off, or None.
Both adoption and steady-state reports relay the same hardware subset,
so the Hardware and Network cards fill in on every report, not just at
first contact.
"""
from flask import current_app
manager = current_app.extensions.get('plugin_manager')
computers = manager.get_plugin('computers') if manager else None
if computers is None:
return None, None
subset = {'hostname': hostname}
for field in self._COMPUTER_HANDOFF_FIELDS:
if payload.get(field):
subset[field] = payload.get(field)
return computers, subset
def _adopt_computer(self, hostname: str, payload: Dict,
warnings: List[str]) -> None:
"""Delegate first-contact adoption to the computers collector.
This plugin never creates Asset or Computer rows itself (see
DESIGN-DECISIONS-agent.md 1a). The computers plugin is the sole
authority for that, so on an unknown hostname we forward the
PC-identifying subset of the payload to its collector. It runs inside
the same transaction, so a later failure rolls the adoption back.
"""
computers, adoptpayload = self._forward_to_computers(hostname, payload)
if computers is None:
raise ValueError(
'cannot adopt {!r}: computers plugin is not available'
.format(hostname))
computers.apply_collector_payload(adoptpayload)
db.session.flush()
warnings.append('adopted new computer for hostname: {!r}'.format(hostname))
def apply_collector_payload(self, payload: Dict) -> Dict:
"""Idempotently upsert one endpoint's state and append its events.
The cached EndpointState and any EndpointEvent rows are written in the
same transaction (the dispatcher owns the commit), so a reader never
sees a snapshot without the events that produced it.
"""
from plugins.computers.models import Computer
warnings: List[str] = []
hostname = (payload.get('hostname') or '').strip()
if not hostname:
raise ValueError('hostname is required')
computer = (Computer.query
.filter(db.func.lower(Computer.hostname) == hostname.lower())
.first())
if computer is None:
# First-contact adoption is delegated to the computers collector,
# which is the only authority allowed to create Asset + Computer
# rows (DESIGN-DECISIONS-agent.md 1a). This plugin never invents
# assets itself. Adoption runs in the dispatcher's transaction, so a
# later ValueError rolls the whole report back.
if payload.get('adopt'):
self._adopt_computer(hostname, payload, warnings)
computer = (Computer.query
.filter(db.func.lower(Computer.hostname)
== hostname.lower())
.first())
if computer is None:
raise ValueError(
'could not resolve hostname to a computer: {!r}'.format(hostname))
else:
# Already adopted: relay the hardware/identity subset on every
# report so the computers plugin keeps vendor/model/OS/IP current.
# Without this, Hardware and Network stay blank after adoption
# because this plugin only caches telemetry.
computers, subset = self._forward_to_computers(hostname, payload)
if computers is not None and len(subset) > 1:
computers.apply_collector_payload(subset)
db.session.flush()
assetid = computer.assetid
state = EndpointState.query.filter_by(assetid=assetid).first()
action = 'updated'
if state is None:
state = EndpointState(assetid=assetid)
db.session.add(state)
action = 'created'
state.hostname = hostname
if 'loggedinuser' in payload:
state.loggedinuser = payload.get('loggedinuser')
if 'agentversion' in payload:
state.agentversion = payload.get('agentversion')
if 'lastboottime' in payload:
state.lastboottime = _parse_dt(payload.get('lastboottime'))
if 'uptimeseconds' in payload:
state.uptimeseconds = payload.get('uptimeseconds')
if 'idleseconds' in payload:
state.idleseconds = payload.get('idleseconds')
if 'cpupercent' in payload:
state.cpupercent = payload.get('cpupercent')
if 'memorypercent' in payload:
state.memorypercent = payload.get('memorypercent')
if 'services' in payload:
state.servicesjson = _as_json(payload.get('services'))
if 'ports' in payload:
state.portsjson = _as_json(payload.get('ports'))
if 'interfaces' in payload:
state.interfacesjson = _as_json(payload.get('interfaces'))
if 'scheduledtasks' in payload:
state.scheduledtasksjson = _as_json(payload.get('scheduledtasks'))
state.lastreportedat = datetime.utcnow()
stored_events = 0
for record in (payload.get('events') or []):
try:
eventid = int(record.get('eventid'))
except (TypeError, ValueError):
warnings.append('skipped event with non-integer eventid')
continue
if eventid not in CURATED_EVENT_IDS:
# Outside the catalog: ignored on purpose, not an error.
continue
db.session.add(EndpointEvent(
assetid=assetid,
eventid=eventid,
eventchannel=record.get('eventchannel'),
eventsource=record.get('eventsource'),
eventtime=_parse_dt(record.get('eventtime')),
eventmessage=record.get('eventmessage'),
payloadjson=_as_json(record.get('fields')),
))
stored_events += 1
# flush, not commit: the collector dispatcher owns the transaction.
db.session.flush()
return {
'action': action,
'assetid': assetid,
'identityvalue': hostname,
'storedevents': stored_events,
'warnings': warnings,
}

View File

@@ -0,0 +1,171 @@
"""Endpoints plugin smoke + collector-contract tests.
Asserts the plugin loads cleanly, satisfies the framework contract, and that
its ADR-006 collector schema is well formed. Extend with DB-backed ingest tests
as the plugin grows.
"""
from flask import Blueprint
from plugins.endpoints.plugin import EndpointsPlugin, CURATED_EVENT_IDS
from plugins.endpoints.eventcatalog import EVENT_CATALOG, CRITICAL_EVENT_IDS
from plugins.endpoints.commands import (
COMMAND_ALLOWLIST, is_allowed_action, elevated_permission_for,
requires_target,
)
def test_endpoints_plugin_meta_is_valid():
"""meta returns a PluginMeta with the expected name and prefix."""
plugin = EndpointsPlugin()
assert plugin.meta.name == 'endpoints'
assert plugin.meta.api_prefix == '/api/endpoints'
assert 'computers' in plugin.meta.dependencies
def test_endpoints_plugin_get_blueprint_returns_blueprint():
"""get_blueprint returns a Flask Blueprint, not None."""
plugin = EndpointsPlugin()
assert isinstance(plugin.get_blueprint(), Blueprint)
def test_endpoints_plugin_get_models_returns_models():
"""get_models returns the two telemetry tables."""
plugin = EndpointsPlugin()
models = plugin.get_models()
assert len(models) >= 2
for model in models:
assert hasattr(model, '__tablename__')
def test_endpoints_collector_schema_identity_is_hostname():
"""ADR-006 schema declares hostname as the identity field."""
plugin = EndpointsPlugin()
schema = plugin.get_collector_schema()
assert schema is not None
assert schema['identityfield'] == 'hostname'
assert 'hostname' in schema['fields']['required']
def test_endpoints_curated_event_ids_include_core_security_ids():
"""The curated catalog covers the agreed logon/stability IDs."""
for eventid in (4624, 4625, 4634, 4672, 4697, 4698, 41, 4618, 55, 7011,
10016, 1001):
assert eventid in CURATED_EVENT_IDS
def test_curated_ids_match_the_event_catalog():
"""Ingest filter and display catalog cannot drift apart."""
assert CURATED_EVENT_IDS == frozenset(EVENT_CATALOG)
def test_critical_event_ids_are_a_subset_of_the_catalog():
"""Dashboard stability card only queries known critical IDs."""
assert set(CRITICAL_EVENT_IDS)
assert set(CRITICAL_EVENT_IDS).issubset(set(EVENT_CATALOG))
def test_endpoints_asset_panels_are_computer_scoped():
"""Every asset panel targets computer assets only, with an endpoint."""
plugin = EndpointsPlugin()
panels = plugin.get_asset_panels()
assert len(panels) == 5
for panel in panels:
assert panel['assettypes'] == ['computer']
assert panel['endpoint'].startswith('/api/endpoints/asset/{assetid}')
assert panel['render'] in ('keyvalue', 'list', 'table')
def test_services_and_processes_are_split_into_two_filtered_cards():
"""The single services card is now two cards filtered by row kind, both
reading the same services snapshot so a future top-20 collector is just
another filtered panel over the same feed."""
plugin = EndpointsPlugin()
panels = {p['id']: p for p in plugin.get_asset_panels()}
services = panels['endpoints-tracked-services']
processes = panels['endpoints-tracked-processes']
assert services['title'] == 'Tracked Services'
assert processes['title'] == 'Tracked Processes'
assert services['filter'] == {'key': 'kind', 'equals': 'service'}
assert processes['filter'] == {'key': 'kind', 'equals': 'process'}
# Both slice one endpoint feed.
assert services['endpoint'] == processes['endpoint']
assert services['endpoint'].endswith('/services')
# Command wiring carries over to both cards.
assert services['permission'] == 'endpoints.command'
assert processes['permission'] == 'endpoints.command'
def test_recent_events_card_is_last_and_starts_collapsed():
"""Events is the lowest-priority card: forced to the bottom of the column
and collapsed by default."""
plugin = EndpointsPlugin()
panels = plugin.get_asset_panels()
events = next(p for p in panels if p['id'] == 'endpoints-events')
assert events['collapsedDefault'] is True
assert events['position'] == max(p['position'] for p in panels)
def test_endpoints_dashboard_widgets_declared():
"""Quiet + stability exception cards are declared and hide when empty."""
plugin = EndpointsPlugin()
widgets = plugin.get_dashboard_widgets()
ids = {w['id'] for w in widgets}
assert {'endpoints-quiet', 'endpoints-stability'} <= ids
for widget in widgets:
assert widget['render'] == 'exceptions'
assert widget['empty'] == 'hide'
assert widget['permission'] == 'endpoints.view'
def test_endpoints_navigation_item_points_at_fleet_view():
"""The fleet list view is exposed as a single /endpoints nav item."""
plugin = EndpointsPlugin()
items = plugin.get_navigation_items()
routes = {item['route'] for item in items}
assert '/endpoints' in routes
def test_system_restart_is_allowlisted_and_policy_selectable():
"""Restart is a real allowlisted action, so the policy builder (which
reads COMMAND_ALLOWLIST) surfaces it as a selectable action."""
assert is_allowed_action('system_restart')
assert 'system_restart' in COMMAND_ALLOWLIST
assert COMMAND_ALLOWLIST['system_restart']['label'] == 'Restart computer'
def test_system_restart_requires_the_elevated_restart_permission():
"""Restart maps to endpoints.restart so the enqueue route can demand a
heavier grant than the routine endpoints.command toggle. Routine actions
carry no elevated requirement."""
assert elevated_permission_for('system_restart') == 'endpoints.restart'
assert elevated_permission_for('mtconnect_start') is None
def test_endpoints_declares_the_restart_permission():
"""The dedicated restart permission is declared for role-grid seeding."""
plugin = EndpointsPlugin()
permissions = {name for name, *_ in plugin.get_permissions()}
assert 'endpoints.restart' in permissions
assert 'endpoints.command' in permissions
def test_collect_now_is_allowlisted_targetless_and_routine():
"""collect_now forces an off-cadence telemetry report. It is allowlisted so
the enqueue route accepts it, carries no wire target (fixed action), and
needs no elevated grant beyond endpoints.command."""
assert is_allowed_action('collect_now')
assert 'collect_now' in COMMAND_ALLOWLIST
assert not requires_target('collect_now')
assert elevated_permission_for('collect_now') is None
def test_telemetrymeta_route_is_registered_for_freshness_gating():
"""The WebUI gates panel refreshes on lastreportedat advancing, so the
lightweight freshness probe must be exposed on the plugin blueprint."""
from flask import Flask
application = Flask(__name__)
application.register_blueprint(EndpointsPlugin().get_blueprint())
rules = {rule.rule for rule in application.url_map.iter_rules()}
assert any(rule.endswith('/asset/<int:asset_id>/telemetrymeta') for rule in rules)

View File

@@ -73,7 +73,7 @@ def _trusted_client_ip():
- A client reaching waitress directly is not a trusted proxy, so there - A client reaching waitress directly is not a trusted proxy, so there
remote_addr is its own peer address. remote_addr is its own peer address.
The rule is the control. See docs/geenforce-api-cutover.md; the Windows The rule is the control. See the geenforce-api-cutover wiki page; the Windows
installer enables it and verifies it is live at the end of an install. installer enables it and verifies it is live at the end of an install.
""" """
return request.remote_addr or '' return request.remote_addr or ''

Some files were not shown because too many files have changed in this diff Show More