Merge wilm: endpoints plugin, agent, policies, CSV import, Entra fields
Thirteen commits from benjamin.baker plus the review fixes. Everything he built is here: the endpoints plugin and its command queue, the .NET endpoint agent, the computers policies subsystem, the site-asset CSV import with its adapters and collector, Entra device identity, and the catalog hand-merge. His branch predates four commits on main (section ticking, the generator share links, Secure Send copy message, and the smoke-test workflow), so those come through untouched - nothing of ours is removed by this merge. wilm stays as its own branch; this does not close it.
This commit is contained in:
23
.dockerignore
Normal file
23
.dockerignore
Normal 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
|
||||
@@ -64,6 +64,7 @@ ZABBIX_TOKEN=
|
||||
# COLLECTOR_API_KEY_<PLUGINNAME> first, then COLLECTOR_API_KEY as fallback.
|
||||
# COLLECTOR_API_KEY=
|
||||
# COLLECTOR_API_KEY_COMPUTERS=
|
||||
# COLLECTOR_API_KEY_ENDPOINTS=
|
||||
|
||||
# ---- Trusted plugin publisher keys (ADR-013, optional) ----
|
||||
# Public-key PEM paths (OS path separator: ':' on Linux, ';' on Windows) used
|
||||
|
||||
86
.github/agent-workflow.md
vendored
Normal file
86
.github/agent-workflow.md
vendored
Normal 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.
|
||||
|
||||
1
.github/copilot-instructions.md
vendored
1
.github/copilot-instructions.md
vendored
@@ -72,6 +72,7 @@ name. Spell out: `config context manager request response environment utilities`
|
||||
with quantities are standalone tables, not assets.
|
||||
- **Ledger pattern**: a cached `quantityonhand` moves in the SAME commit as the
|
||||
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
|
||||
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -83,3 +83,12 @@ scripts/site_imports/wjf/idmap.json
|
||||
|
||||
# work-PC publication clone: bundle drop folder for the transfer pipeline
|
||||
_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
|
||||
|
||||
19
Dockerfile
19
Dockerfile
@@ -23,6 +23,23 @@ FROM node:20-slim AS frontendbuild
|
||||
|
||||
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 frontend/package*.json ./frontend/
|
||||
RUN cd frontend && npm ci
|
||||
@@ -86,7 +103,7 @@ USER shopdb
|
||||
EXPOSE 5001
|
||||
|
||||
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 '{}' \
|
||||
| grep -q "VALIDATION_ERROR" || exit 1
|
||||
|
||||
|
||||
7
agent/.gitignore
vendored
Normal file
7
agent/.gitignore
vendored
Normal 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
166
agent/README.md
Normal 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.
|
||||
109
agent/ShopDbEndpointAgent.Core/AgentConfig.cs
Normal file
109
agent/ShopDbEndpointAgent.Core/AgentConfig.cs
Normal 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; } = "";
|
||||
}
|
||||
110
agent/ShopDbEndpointAgent.Core/AgentConfigProvider.cs
Normal file
110
agent/ShopDbEndpointAgent.Core/AgentConfigProvider.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
91
agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs
Normal file
91
agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
26
agent/ShopDbEndpointAgent.Core/CollectSignal.cs
Normal file
26
agent/ShopDbEndpointAgent.Core/CollectSignal.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
118
agent/ShopDbEndpointAgent.Core/CollectorClient.cs
Normal file
118
agent/ShopDbEndpointAgent.Core/CollectorClient.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
83
agent/ShopDbEndpointAgent.Core/CommandClient.cs
Normal file
83
agent/ShopDbEndpointAgent.Core/CommandClient.cs
Normal 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();
|
||||
}
|
||||
581
agent/ShopDbEndpointAgent.Core/CommandExecutor.cs
Normal file
581
agent/ShopDbEndpointAgent.Core/CommandExecutor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
143
agent/ShopDbEndpointAgent.Core/CommandWorker.cs
Normal file
143
agent/ShopDbEndpointAgent.Core/CommandWorker.cs
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
92
agent/ShopDbEndpointAgent.Core/Contracts.cs
Normal file
92
agent/ShopDbEndpointAgent.Core/Contracts.cs
Normal 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; }
|
||||
}
|
||||
44
agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs
Normal file
44
agent/ShopDbEndpointAgent.Core/CuratedEventCatalog.cs
Normal 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();
|
||||
}
|
||||
489
agent/ShopDbEndpointAgent.Core/EndpointCollector.cs
Normal file
489
agent/ShopDbEndpointAgent.Core/EndpointCollector.cs
Normal 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];
|
||||
}
|
||||
}
|
||||
164
agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs
Normal file
164
agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
24
agent/ShopDbEndpointAgent.Core/ICollector.cs
Normal file
24
agent/ShopDbEndpointAgent.Core/ICollector.cs
Normal 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);
|
||||
}
|
||||
200
agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs
Normal file
200
agent/ShopDbEndpointAgent.Core/InteractiveProcessLauncher.cs
Normal 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);
|
||||
}
|
||||
114
agent/ShopDbEndpointAgent.Core/PowerShellUtilityRunner.cs
Normal file
114
agent/ShopDbEndpointAgent.Core/PowerShellUtilityRunner.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
129
agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs
Normal file
129
agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
17
agent/ShopDbEndpointAgent.Tray/Program.cs
Normal file
17
agent/ShopDbEndpointAgent.Tray/Program.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
118
agent/ShopDbEndpointAgent.Tray/TicketForm.cs
Normal file
118
agent/ShopDbEndpointAgent.Tray/TicketForm.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
202
agent/ShopDbEndpointAgent.Tray/TrayContext.cs
Normal file
202
agent/ShopDbEndpointAgent.Tray/TrayContext.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
81
agent/ShopDbEndpointAgent.Tray/TrayIcon.cs
Normal file
81
agent/ShopDbEndpointAgent.Tray/TrayIcon.cs
Normal 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);
|
||||
}
|
||||
5
agent/ShopDbEndpointAgent.slnx
Normal file
5
agent/ShopDbEndpointAgent.slnx
Normal 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>
|
||||
164
agent/ShopDbEndpointAgent/Program.cs
Normal file
164
agent/ShopDbEndpointAgent/Program.cs
Normal 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);
|
||||
}
|
||||
40
agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj
Normal file
40
agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj
Normal 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>
|
||||
26
agent/ShopDbEndpointAgent/appsettings.json
Normal file
26
agent/ShopDbEndpointAgent/appsettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
355
agent/installer/install-shopdbagent.ps1
Normal file
355
agent/installer/install-shopdbagent.ps1
Normal 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"
|
||||
@@ -4,6 +4,7 @@
|
||||
"backups",
|
||||
"computers",
|
||||
"employees",
|
||||
"endpoints",
|
||||
"geenforce",
|
||||
"guides",
|
||||
"knowledgebase",
|
||||
|
||||
@@ -61,6 +61,8 @@ services:
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
ZABBIX_URL: ${ZABBIX_URL:-}
|
||||
ZABBIX_TOKEN: ${ZABBIX_TOKEN:-}
|
||||
COLLECTOR_API_KEY: ${COLLECTOR_API_KEY:-}
|
||||
COLLECTOR_API_KEY_ENDPOINTS: ${COLLECTOR_API_KEY_ENDPOINTS:-}
|
||||
ports:
|
||||
- "${API_PORT:-5001}:5001"
|
||||
volumes:
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "shopdb-frontend",
|
||||
"version": "0.7.0",
|
||||
"version": "0.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "shopdb-frontend",
|
||||
"version": "0.7.0",
|
||||
"version": "0.12.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
|
||||
@@ -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)
|
||||
export const computersApi = {
|
||||
list(params = {}) {
|
||||
@@ -187,6 +208,11 @@ export const computersApi = {
|
||||
list(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) {
|
||||
return api.get(`/computers/types/${id}`)
|
||||
},
|
||||
@@ -214,6 +240,50 @@ export const computersApi = {
|
||||
remove(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 = {}) {
|
||||
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) {
|
||||
return api.get(`/locations/${id}`)
|
||||
},
|
||||
@@ -1019,6 +1095,42 @@ export const settingsApi = {
|
||||
form.append('kind', kind)
|
||||
form.append('file', file)
|
||||
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' }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,30 @@
|
||||
on any asset detail page, so a plugin adds detail-page UI with no Vue. -->
|
||||
<template v-for="panel in panels" :key="panel.id">
|
||||
<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">▾</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 -->
|
||||
<div v-if="panel.render === 'list'" class="pap-list">
|
||||
@@ -16,6 +39,13 @@
|
||||
:style="colorStyle(mapBadge(panel, item).color)">
|
||||
{{ mapBadge(panel, item).label }}
|
||||
</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 v-if="mapMeta(panel, item).length" class="pap-meta">
|
||||
<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">
|
||||
{{ panel.manage.label || 'Manage' }}
|
||||
</router-link>
|
||||
<div v-if="actionError[panel.id]" class="pap-action-error">
|
||||
{{ actionError[panel.id] }}
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="pap-empty">
|
||||
<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"
|
||||
:style="colorStyle(b.color)">{{ b.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -100,7 +134,8 @@ import api from '../api'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import {
|
||||
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
|
||||
keyvalueFields, tableColumns, cell, badges, tabSections,
|
||||
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
|
||||
rowAction,
|
||||
} from './pluginAssetPanels'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -115,6 +150,117 @@ const tabs = ref({})
|
||||
function activeTab(panel) { return tabs.value[panel.id] || 0 }
|
||||
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.
|
||||
async function load() {
|
||||
panels.value = []
|
||||
@@ -142,6 +288,10 @@ async function load() {
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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 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-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; }
|
||||
.muted { color: var(--text-light); }
|
||||
</style>
|
||||
|
||||
@@ -29,10 +29,20 @@ export function humanize(key) {
|
||||
|
||||
export function rows(panel) {
|
||||
const data = panel._data
|
||||
if (Array.isArray(data)) return data
|
||||
if (data && Array.isArray(data.rows)) return data.rows
|
||||
if (data && Array.isArray(data.items)) return data.items
|
||||
return []
|
||||
let list
|
||||
if (Array.isArray(data)) list = data
|
||||
else if (data && Array.isArray(data.rows)) list = data.rows
|
||||
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) {
|
||||
@@ -98,6 +108,37 @@ export function badges(panel) {
|
||||
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
|
||||
// time, so a panel with 30 values stays the height of its largest section
|
||||
// instead of dragging one multicol column far past the other.
|
||||
@@ -114,6 +155,30 @@ export function tabSections(panel) {
|
||||
.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) {
|
||||
if (panel.render === 'tabs') return tabSections(panel).length > 0
|
||||
if (panel.render === 'list') return rows(panel).length > 0 || !!panel.empty
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink,
|
||||
keyvalueFields, tableColumns, badges, panelVisible, tabSections,
|
||||
keyvalueFields, tableColumns, badges, panelVisible, tabSections, rollupStatus,
|
||||
rowAction,
|
||||
} from './pluginAssetPanels'
|
||||
|
||||
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', () => {
|
||||
it('keyvalue reads a fields array or a plain object', () => {
|
||||
const fromFields = keyvalueFields({
|
||||
@@ -130,3 +159,72 @@ describe('tabSections', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -150,6 +150,12 @@ export default [
|
||||
component: () => import('../../views/settings/BrandingSettings.vue'),
|
||||
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',
|
||||
name: 'printing-settings',
|
||||
|
||||
@@ -113,7 +113,7 @@ import ToastHost from '../components/ToastHost.vue'
|
||||
import {
|
||||
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
|
||||
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
|
||||
Box, KeyRound, LogOut, Wrench
|
||||
Box, KeyRound, LogOut, Wrench, Activity
|
||||
} from 'lucide-vue-next'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { currentTheme, toggleTheme } from '../stores/theme'
|
||||
@@ -183,6 +183,7 @@ const iconMap = {
|
||||
'ruler': Ruler,
|
||||
'box': Box,
|
||||
'wrench': Wrench,
|
||||
'activity': Activity,
|
||||
}
|
||||
|
||||
// Default navigation (used as fallback if API fails)
|
||||
|
||||
@@ -360,8 +360,10 @@ async function loadFilterOptions(fields) {
|
||||
filterOptions.value.assettypes = response.data.data || []
|
||||
}
|
||||
if (fields.includes('location') && !filterOptions.value.locations.length) {
|
||||
const response = await locationsApi.list({ perpage: 100 })
|
||||
filterOptions.value.locations = response.data.data || []
|
||||
// listAll: 100 is the server-side cap, not a generous limit, and the
|
||||
// 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) {
|
||||
// listAll: 100 is the server-side cap, not a generous limit, and the
|
||||
|
||||
966
frontend/src/views/settings/ImportSiteAssets.vue
Normal file
966
frontend/src/views/settings/ImportSiteAssets.vue
Normal 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->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>
|
||||
@@ -119,7 +119,8 @@ const LABELS = {
|
||||
usb_directory_mode: 'USB Directory Mode',
|
||||
site_timezone: 'Site Timezone',
|
||||
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) {
|
||||
return LABELS[key] || key
|
||||
@@ -228,7 +229,10 @@ async function load() {
|
||||
try {
|
||||
const response = await settingsApi.list()
|
||||
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) {
|
||||
console.error('Error loading site settings:', err)
|
||||
error.value = 'Could not load settings.'
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// Order: site identity + the reference-data catalogs users touch daily come
|
||||
// first, then the platform/system groups (integrations, communication, search,
|
||||
// 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 = [
|
||||
{
|
||||
@@ -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/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/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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Computers plugin API."""
|
||||
|
||||
from .routes import computers_bp
|
||||
from . import policy_routes # noqa: F401 registers policy routes on computers_bp
|
||||
|
||||
__all__ = ['computers_bp']
|
||||
|
||||
399
plugins/computers/api/policy_routes.py
Normal file
399
plugins/computers/api/policy_routes.py
Normal 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())
|
||||
@@ -575,6 +575,9 @@ def create_computer():
|
||||
vendorid=data.get('vendorid'),
|
||||
modelnumberid=data.get('modelnumberid'),
|
||||
loggedinuser=data.get('loggedinuser'),
|
||||
azuredeviceid=data.get('azuredeviceid'),
|
||||
azureobjectid=data.get('azureobjectid'),
|
||||
azuredevicecategory=data.get('azuredevicecategory'),
|
||||
lastreporteddate=data.get('lastreporteddate'),
|
||||
lastboottime=data.get('lastboottime')
|
||||
)
|
||||
@@ -678,8 +681,9 @@ def update_computer(computer_id: int):
|
||||
|
||||
# Update computer fields
|
||||
computer_fields = ['computertypeid', 'hostname', 'osid', 'vendorid',
|
||||
'modelnumberid', 'loggedinuser', 'lastreporteddate',
|
||||
'lastboottime']
|
||||
'modelnumberid', 'loggedinuser', 'azuredeviceid',
|
||||
'azureobjectid', 'azuredevicecategory',
|
||||
'lastreporteddate', 'lastboottime']
|
||||
for key in computer_fields:
|
||||
if key in data:
|
||||
old_val = getattr(comp, key)
|
||||
|
||||
@@ -62,6 +62,18 @@ export default [
|
||||
name: 'access-protocols',
|
||||
component: () => import('./views/AccessProtocolsList.vue'),
|
||||
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' }
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
223
plugins/computers/frontend/views/BusinessPoliciesList.vue
Normal file
223
plugins/computers/frontend/views/BusinessPoliciesList.vue
Normal 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>
|
||||
223
plugins/computers/frontend/views/ConfigurationPoliciesList.vue
Normal file
223
plugins/computers/frontend/views/ConfigurationPoliciesList.vue
Normal 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>
|
||||
@@ -3,6 +3,8 @@
|
||||
<div class="page-header">
|
||||
<h2>Computer Details</h2>
|
||||
<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="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
|
||||
@@ -153,6 +155,28 @@
|
||||
</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>
|
||||
|
||||
<!-- Right Column -->
|
||||
@@ -226,7 +250,7 @@
|
||||
<CustomFieldsSection :assetid="computer.assetid" />
|
||||
|
||||
<!-- Warranty -->
|
||||
<PluginAssetPanels :assetid="computer.assetid" />
|
||||
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" />
|
||||
|
||||
<!-- All relationships (controls, defaultprinter, ...) -->
|
||||
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
|
||||
@@ -253,10 +277,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { colorStyle } from "@/utils/colorStyle"
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computersApi, applicationsApi } from '@/api'
|
||||
import { computersApi, applicationsApi, endpointsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||
@@ -267,15 +291,26 @@ import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { setRecordTitle } from '@/composables/pageTitle'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const authStore = useAuthStore()
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const loading = ref(true)
|
||||
const computer = ref(null)
|
||||
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).
|
||||
const { heroWarranty, warrantyDate: formatWarrantyDate } = useWarrantyBadge(() => computer.value?.assetid)
|
||||
@@ -316,6 +351,108 @@ function formatDate(dateStr) {
|
||||
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
|
||||
// leaves every list and report while its history and relationships stay put.
|
||||
// Reversible by an admin setting isactive back to true.
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
>
|
||||
<option value="">Select vendor...</option>
|
||||
<option
|
||||
v-for="v in vendors"
|
||||
v-for="v in filteredVendors"
|
||||
:key="v.vendorid"
|
||||
:value="v.vendorid"
|
||||
>
|
||||
@@ -223,6 +223,17 @@
|
||||
</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">
|
||||
<label>Remote Access Protocols</label>
|
||||
<div class="protocol-list">
|
||||
@@ -383,6 +394,7 @@ const form = ref({
|
||||
locationid: '',
|
||||
osid: '',
|
||||
loggedinuser: '',
|
||||
azuredeviceid: '',
|
||||
accessmethods: [],
|
||||
notes: '',
|
||||
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(() => {
|
||||
// filter by vendor only (PC type now maps to computertypeid, a different id
|
||||
// space than a model's machinetypeid)
|
||||
const savedId = form.value.modelnumberid
|
||||
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 () => {
|
||||
@@ -448,8 +490,8 @@ onMounted(async () => {
|
||||
computersApi.types.list({ perpage: 100 }),
|
||||
assetsApi.statuses.list(),
|
||||
vendorsApi.list({ perpage: 100 }),
|
||||
modelsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
locationsApi.list({ perpage: 100 }),
|
||||
modelsApi.listAll({ category: 'PC' }), // backend caps perpage at 100; page through all
|
||||
locationsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
operatingsystemsApi.list({ perpage: 100 }),
|
||||
computersApi.protocols.list(),
|
||||
// Handles its own failure: a site without printers still gets a form.
|
||||
@@ -459,7 +501,7 @@ onMounted(async () => {
|
||||
statuses.value = statusRes.data.data || []
|
||||
vendors.value = vendorRes.data.data || []
|
||||
models.value = allModels
|
||||
locations.value = locRes.data.data || []
|
||||
locations.value = locRes || []
|
||||
operatingsystems.value = osRes.data.data || []
|
||||
protocols.value = protoRes.data.data || []
|
||||
|
||||
@@ -486,6 +528,7 @@ onMounted(async () => {
|
||||
locationid: pc.locationid || '',
|
||||
osid: ext.osid || '',
|
||||
loggedinuser: ext.loggedinuser || '',
|
||||
azuredeviceid: ext.azuredeviceid || '',
|
||||
accessmethods: (pc.accessmethods || []).map(a => ({
|
||||
protocolid: a.protocolid,
|
||||
portoverride: a.portoverride ?? null
|
||||
@@ -496,6 +539,12 @@ onMounted(async () => {
|
||||
levelid: pc.levelid ?? null,
|
||||
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) {
|
||||
console.error('Error loading data:', err)
|
||||
@@ -555,6 +604,7 @@ async function savePC() {
|
||||
locationid: form.value.locationid || null,
|
||||
osid: form.value.osid || null,
|
||||
loggedinuser: form.value.loggedinuser || null,
|
||||
azuredeviceid: form.value.azuredeviceid || null,
|
||||
accessmethods: form.value.accessmethods,
|
||||
notes: form.value.notes || null,
|
||||
ipaddress: form.value.ipaddress || null,
|
||||
|
||||
106
plugins/computers/frontend/views/ScopeNodeEditor.vue
Normal file
106
plugins/computers/frontend/views/ScopeNodeEditor.vue
Normal 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>
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
154
plugins/computers/migrations/versions/0004_policies.py
Normal file
154
plugins/computers/migrations/versions/0004_policies.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""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
|
||||
|
||||
|
||||
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.JSON(), 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.JSON(), nullable=True),
|
||||
sa.Column('params', sa.JSON(), 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')
|
||||
@@ -7,6 +7,15 @@ from .computer import (
|
||||
AccessProtocol,
|
||||
ComputerAccess,
|
||||
)
|
||||
from .policy import (
|
||||
ConfigurationPolicy,
|
||||
ConfigurationPolicyAction,
|
||||
BusinessPolicy,
|
||||
BusinessPolicyItem,
|
||||
POLICY_SOURCE_LOCAL,
|
||||
ACTION_MODE_DESIRED,
|
||||
ACTION_MODE_GATE,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'Computer',
|
||||
@@ -14,4 +23,11 @@ __all__ = [
|
||||
'ComputerInstalledApp',
|
||||
'AccessProtocol',
|
||||
'ComputerAccess',
|
||||
'ConfigurationPolicy',
|
||||
'ConfigurationPolicyAction',
|
||||
'BusinessPolicy',
|
||||
'BusinessPolicyItem',
|
||||
'POLICY_SOURCE_LOCAL',
|
||||
'ACTION_MODE_DESIRED',
|
||||
'ACTION_MODE_GATE',
|
||||
]
|
||||
|
||||
@@ -85,6 +85,23 @@ class Computer(BaseModel):
|
||||
lastreporteddate = 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
|
||||
# relationship (AccessProtocol / ComputerAccess), replacing isvnc/iswinrm.
|
||||
|
||||
|
||||
197
plugins/computers/models/policy.py
Normal file
197
plugins/computers/models/policy.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""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
|
||||
|
||||
|
||||
# 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(db.JSON, 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(db.JSON, 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(db.JSON, 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}>")
|
||||
@@ -12,8 +12,16 @@ from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db, AssetType
|
||||
|
||||
from .models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
|
||||
from .models import (
|
||||
ConfigurationPolicy,
|
||||
ConfigurationPolicyAction,
|
||||
BusinessPolicy,
|
||||
BusinessPolicyItem,
|
||||
)
|
||||
from .api import computers_bp
|
||||
|
||||
from .policyflag import policy_flag_enabled as _policy_flag_enabled
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Marker stamped on PC->measuringtool "controls" links this collector creates
|
||||
@@ -32,6 +40,14 @@ MEASURINGTOOL_LINK_ORIGIN = 'collector:measuringtool'
|
||||
# touched.
|
||||
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,
|
||||
# and marker partof the operation it serves. Same archive discipline again.
|
||||
PARTMARKER_LINK_ORIGIN = 'collector:partmarker'
|
||||
@@ -84,12 +100,229 @@ class ComputersPlugin(BasePlugin):
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
"""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:
|
||||
"""Initialize plugin with Flask app."""
|
||||
self._register_import_hook()
|
||||
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 -----------------------------------------
|
||||
|
||||
def get_collector_schema(self) -> Optional[Dict]:
|
||||
@@ -173,6 +406,36 @@ class ComputersPlugin(BasePlugin):
|
||||
'site\'s URL and ranges',
|
||||
'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]:
|
||||
@@ -214,8 +477,41 @@ class ComputersPlugin(BasePlugin):
|
||||
'Off while machine numbers are shared between '
|
||||
'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):
|
||||
"""Whether the PC-to-machine alerts may be sent.
|
||||
|
||||
@@ -534,7 +830,8 @@ class ComputersPlugin(BasePlugin):
|
||||
'with POST /api/printers/assignments/seed-from-observed.'
|
||||
% ' 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.
|
||||
|
||||
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
|
||||
machine while it is still alive, and the challenger is recorded as a
|
||||
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
|
||||
|
||||
@@ -587,7 +893,7 @@ class ComputersPlugin(BasePlugin):
|
||||
mine = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == pcasset.assetid,
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
AssetRelationship.label == MACHINE_LINK_ORIGIN,
|
||||
AssetRelationship.label == origin_label,
|
||||
).all()
|
||||
|
||||
link = None
|
||||
@@ -597,11 +903,23 @@ class ComputersPlugin(BasePlugin):
|
||||
else:
|
||||
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.
|
||||
held = AssetRelationship.query.filter(
|
||||
AssetRelationship.targetassetid == machine.assetid,
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
AssetRelationship.label == MACHINE_LINK_ORIGIN,
|
||||
AssetRelationship.label == origin_label,
|
||||
AssetRelationship.sourceassetid != pcasset.assetid,
|
||||
AssetRelationship.isactive.is_(True),
|
||||
).all()
|
||||
@@ -624,9 +942,10 @@ class ComputersPlugin(BasePlugin):
|
||||
sourceassetid=pcasset.assetid,
|
||||
targetassetid=machine.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid,
|
||||
label=MACHINE_LINK_ORIGIN,
|
||||
label=origin_label,
|
||||
isactive=False))
|
||||
self._alert_machine_contested(names, comp, machine)
|
||||
if not preview:
|
||||
self._alert_machine_contested(names, comp, machine)
|
||||
else:
|
||||
link.isactive = False
|
||||
return [{'assetid': machine.assetid,
|
||||
@@ -640,7 +959,7 @@ class ComputersPlugin(BasePlugin):
|
||||
sourceassetid=pcasset.assetid,
|
||||
targetassetid=machine.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid,
|
||||
label=MACHINE_LINK_ORIGIN,
|
||||
label=origin_label,
|
||||
isactive=True)
|
||||
db.session.add(link)
|
||||
else:
|
||||
@@ -654,7 +973,8 @@ class ComputersPlugin(BasePlugin):
|
||||
warnings.append(
|
||||
'machine {} was taken over from {}; check that PC'.format(
|
||||
machine.assetnumber, oldname))
|
||||
self._alert_pc_superseded(oldname, comp, machine)
|
||||
if not preview:
|
||||
self._alert_pc_superseded(oldname, comp, machine)
|
||||
|
||||
return [{'assetid': machine.assetid,
|
||||
'machinenumber': machine.assetnumber,
|
||||
@@ -757,8 +1077,8 @@ class ComputersPlugin(BasePlugin):
|
||||
#
|
||||
# A row made by hand carries neither label and is deliberately NOT
|
||||
# counted: a person's link is not the collector's to archive.
|
||||
ourlabels = (label, MACHINE_LINK_ORIGIN, MEASURINGTOOL_LINK_ORIGIN,
|
||||
PARTMARKER_LINK_ORIGIN)
|
||||
ourlabels = (label, MACHINE_LINK_ORIGIN, MACHINE_LINK_IMPORT_ORIGIN,
|
||||
MEASURINGTOOL_LINK_ORIGIN, PARTMARKER_LINK_ORIGIN)
|
||||
others = AssetRelationship.query.filter(
|
||||
AssetRelationship.targetassetid == deviceasset.assetid,
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
@@ -1310,15 +1630,17 @@ class ComputersPlugin(BasePlugin):
|
||||
sourceassetid=pcasset.assetid,
|
||||
targetassetid=adopted.assetid,
|
||||
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,
|
||||
# so the machine sync has already made the row for this exact
|
||||
# triple - and it finds its own rows BY that label. Relabelling
|
||||
# hid the link from it, so the next cycle built a second row for
|
||||
# the same triple and MySQL rejected it: the PC reported 200,
|
||||
# then 500 forever after. The row's lifecycle (including staying
|
||||
# dormant while a bay is contested) belongs to the machine sync;
|
||||
# adoption only needs the identity, which it has.
|
||||
# so the machine sync (or a CSV import's machine link) has
|
||||
# already made the row for this exact triple - and each finds
|
||||
# its own rows BY that label. Relabelling hid the link from it,
|
||||
# so the next cycle built a second row for the same triple and
|
||||
# MySQL rejected it: the PC reported 200, then 500 forever
|
||||
# after. The row's lifecycle (including staying dormant while a
|
||||
# bay is contested) belongs to the machine sync; adoption only
|
||||
# needs the identity, which it has.
|
||||
pass
|
||||
elif claimed is not None:
|
||||
# Relabelling is what adoption MEANS for any other row: the link
|
||||
|
||||
19
plugins/computers/policyflag.py
Normal file
19
plugins/computers/policyflag.py
Normal 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')
|
||||
1
plugins/computers/services/__init__.py
Normal file
1
plugins/computers/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Computers plugin services: pure policy logic (scope + resolution)."""
|
||||
168
plugins/computers/services/policyresolver.py
Normal file
168
plugins/computers/services/policyresolver.py
Normal 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)
|
||||
96
plugins/computers/services/policyscope.py
Normal file
96
plugins/computers/services/policyscope.py
Normal 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}')
|
||||
274
plugins/endpoints/AGENT-CONTRACT.md
Normal file
274
plugins/endpoints/AGENT-CONTRACT.md
Normal 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).
|
||||
|
||||
443
plugins/endpoints/DESIGN-DECISIONS-agent.md
Normal file
443
plugins/endpoints/DESIGN-DECISIONS-agent.md
Normal 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.
|
||||
5
plugins/endpoints/__init__.py
Normal file
5
plugins/endpoints/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Endpoints plugin package."""
|
||||
|
||||
from .plugin import EndpointsPlugin
|
||||
|
||||
__all__ = ['EndpointsPlugin']
|
||||
5
plugins/endpoints/api/__init__.py
Normal file
5
plugins/endpoints/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Endpoints plugin API."""
|
||||
|
||||
from .routes import endpoints_bp
|
||||
|
||||
__all__ = ['endpoints_bp']
|
||||
724
plugins/endpoints/api/routes.py
Normal file
724
plugins/endpoints/api/routes.py
Normal 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')
|
||||
|
||||
198
plugins/endpoints/commands.py
Normal file
198
plugins/endpoints/commands.py
Normal 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
|
||||
|
||||
46
plugins/endpoints/eventcatalog.py
Normal file
46
plugins/endpoints/eventcatalog.py
Normal 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'
|
||||
26
plugins/endpoints/frontend/routes.js
Normal file
26
plugins/endpoints/frontend/routes.js
Normal 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 = []
|
||||
293
plugins/endpoints/frontend/views/AgentKeySettings.vue
Normal file
293
plugins/endpoints/frontend/views/AgentKeySettings.vue
Normal 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>
|
||||
153
plugins/endpoints/frontend/views/EndpointsList.vue
Normal file
153
plugins/endpoints/frontend/views/EndpointsList.vue
Normal 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>
|
||||
21
plugins/endpoints/manifest.json
Normal file
21
plugins/endpoints/manifest.json
Normal 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
|
||||
}
|
||||
}
|
||||
15
plugins/endpoints/migrations/env.py
Normal file
15
plugins/endpoints/migrations/env.py
Normal 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()
|
||||
24
plugins/endpoints/migrations/script.py.mako
Normal file
24
plugins/endpoints/migrations/script.py.mako
Normal 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"}
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
5
plugins/endpoints/models/__init__.py
Normal file
5
plugins/endpoints/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Endpoints plugin models."""
|
||||
|
||||
from .endpoint import EndpointState, EndpointEvent, EndpointCommand
|
||||
|
||||
__all__ = ['EndpointState', 'EndpointEvent', 'EndpointCommand']
|
||||
248
plugins/endpoints/models/endpoint.py
Normal file
248
plugins/endpoints/models/endpoint.py
Normal 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
596
plugins/endpoints/plugin.py
Normal 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,
|
||||
}
|
||||
171
plugins/endpoints/tests/test_plugin.py
Normal file
171
plugins/endpoints/tests/test_plugin.py
Normal 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)
|
||||
@@ -123,7 +123,7 @@
|
||||
>
|
||||
<option value="">Select vendor...</option>
|
||||
<option
|
||||
v-for="v in vendors"
|
||||
v-for="v in filteredVendors"
|
||||
:key="v.vendorid"
|
||||
:value="v.vendorid"
|
||||
>
|
||||
@@ -168,7 +168,7 @@
|
||||
>
|
||||
<option value="">Select vendor...</option>
|
||||
<option
|
||||
v-for="v in vendors"
|
||||
v-for="v in filteredControllerVendors"
|
||||
:key="v.vendorid"
|
||||
:value="v.vendorid"
|
||||
>
|
||||
@@ -446,16 +446,67 @@ const existingRelationshipId = ref(null)
|
||||
const loadedControllingPcId = ref(null)
|
||||
const currentMachine = ref(null)
|
||||
|
||||
// Filter models by selected machine vendor
|
||||
// Ensure a saved model id is present in models.value even when the filtered
|
||||
// list (category Equipment) 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 machine vendor.
|
||||
// only in the controller dropdown, so keep them out of the machine list. Keep
|
||||
// the record's own saved model even if it is untyped or a different vendor, so
|
||||
// editing a machine never renders a blank select that clears the field on save.
|
||||
const filteredModels = computed(() => {
|
||||
if (!form.value.vendorid) return models.value
|
||||
return models.value.filter(m => m.vendorid === form.value.vendorid)
|
||||
const savedId = form.value.modelnumberid
|
||||
const machineModels = models.value.filter(
|
||||
m => m.modeltype !== 'Controller' || m.modelnumberid === savedId)
|
||||
if (!form.value.vendorid) return machineModels
|
||||
return machineModels.filter(
|
||||
m => m.vendorid === form.value.vendorid || m.modelnumberid === savedId)
|
||||
})
|
||||
|
||||
// Filter models by selected controller vendor
|
||||
// Filter models by selected controller vendor. Only Controller-type models are
|
||||
// valid here. Keep the record's own saved controller model even if it is untyped
|
||||
// or a different vendor, for the same reason as filteredModels above.
|
||||
const filteredControllerModels = computed(() => {
|
||||
if (!form.value.controllervendorid) return models.value
|
||||
return models.value.filter(m => m.vendorid === form.value.controllervendorid)
|
||||
const savedId = form.value.controllermodelid
|
||||
const controllerModels = models.value.filter(
|
||||
m => m.modeltype === 'Controller' || m.modelnumberid === savedId)
|
||||
if (!form.value.controllervendorid) return controllerModels
|
||||
return controllerModels.filter(
|
||||
m => m.vendorid === form.value.controllervendorid || m.modelnumberid === savedId)
|
||||
})
|
||||
|
||||
// Only offer vendors that have machine (non-controller Equipment) models. Keep
|
||||
// the record's own vendor even if it falls outside that set, so editing an
|
||||
// existing machine never silently drops its saved vendor.
|
||||
const filteredVendors = computed(() => {
|
||||
const machineVendorIds = new Set(
|
||||
models.value.filter(m => m.modeltype !== 'Controller').map(m => m.vendorid)
|
||||
)
|
||||
return vendors.value.filter(
|
||||
v => machineVendorIds.has(v.vendorid) || v.vendorid === form.value.vendorid
|
||||
)
|
||||
})
|
||||
|
||||
// Only offer controller vendors that have Controller-type models. Keep the
|
||||
// record's own controller vendor even if it falls outside that set.
|
||||
const filteredControllerVendors = computed(() => {
|
||||
const controllerVendorIds = new Set(
|
||||
models.value.filter(m => m.modeltype === 'Controller').map(m => m.vendorid)
|
||||
)
|
||||
return vendors.value.filter(
|
||||
v => controllerVendorIds.has(v.vendorid) || v.vendorid === form.value.controllervendorid
|
||||
)
|
||||
})
|
||||
|
||||
// Clear model selection when vendor changes
|
||||
@@ -485,8 +536,8 @@ onMounted(async () => {
|
||||
machinesApi.types.list(),
|
||||
assetsApi.statuses.list(),
|
||||
vendorsApi.list({ perpage: 500 }),
|
||||
locationsApi.list({ perpage: 500 }),
|
||||
modelsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
locationsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
modelsApi.listAll({ category: 'Equipment' }), // backend caps perpage at 100; page through all
|
||||
businessunitsApi.list({ perpage: 500 }),
|
||||
computersApi.listAll(), // backend caps perpage at 100; page through all
|
||||
assetsApi.types.list() // Used for relationship types, will fix below
|
||||
@@ -495,7 +546,7 @@ onMounted(async () => {
|
||||
machineTypes.value = typesRes.data.data || []
|
||||
statuses.value = statusRes.data.data || []
|
||||
vendors.value = vendorRes.data.data || []
|
||||
locations.value = locRes.data.data || []
|
||||
locations.value = locRes || []
|
||||
models.value = allModels
|
||||
businessunits.value = buRes.data.data || []
|
||||
// listAll resolves to the ARRAY, not a response: fetchAllPages already
|
||||
@@ -568,6 +619,14 @@ onMounted(async () => {
|
||||
levelid: data.levelid ?? null,
|
||||
}
|
||||
|
||||
// The model list is loaded filtered to category Equipment, which the
|
||||
// backend inner-joins against ModelType - so a model with no modeltype is
|
||||
// absent from models.value. If this machine's saved model or controller
|
||||
// model is one of those untyped 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)
|
||||
await ensureModelLoaded(form.value.controllermodelid)
|
||||
|
||||
// Load existing relationships to find controlling PC
|
||||
if (data.assetid) {
|
||||
try {
|
||||
|
||||
@@ -181,13 +181,13 @@ onMounted(async () => {
|
||||
const [typesResponse, statusResponse, locResponse, buResponse, assetTypesResponse] = await Promise.all([
|
||||
measuringtoolsApi.types.list({ perpage: 200 }),
|
||||
assetsApi.statuses.list(),
|
||||
locationsApi.list({ perpage: 500 }),
|
||||
locationsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
businessunitsApi.list({ perpage: 500 }),
|
||||
assetsApi.types.list(),
|
||||
])
|
||||
types.value = typesResponse.data.data || []
|
||||
statuses.value = statusResponse.data.data || []
|
||||
locations.value = locResponse.data.data || []
|
||||
locations.value = locResponse || []
|
||||
businessunits.value = buResponse.data.data || []
|
||||
|
||||
const assetType = (assetTypesResponse.data.data || []).find(a => a.assettype === 'measuring_tool')
|
||||
|
||||
@@ -460,8 +460,8 @@ async function loadVendors() {
|
||||
|
||||
async function loadLocations() {
|
||||
try {
|
||||
const response = await locationsApi.list({ perpage: 100 })
|
||||
locations.value = response.data.data || []
|
||||
const items = await locationsApi.listAll() // backend caps perpage at 100; page through all
|
||||
locations.value = items || []
|
||||
} catch (err) {
|
||||
console.error('Error loading locations:', err)
|
||||
}
|
||||
|
||||
@@ -197,8 +197,8 @@ async function loadVendors() {
|
||||
|
||||
async function loadLocations() {
|
||||
try {
|
||||
const response = await locationsApi.list({ perpage: 100 })
|
||||
locations.value = response.data.data || []
|
||||
const items = await locationsApi.listAll() // backend caps perpage at 100; page through all
|
||||
locations.value = items || []
|
||||
} catch (error) {
|
||||
console.error('Error loading locations:', error)
|
||||
}
|
||||
|
||||
@@ -352,8 +352,8 @@ async function loadVLANs() {
|
||||
|
||||
async function loadLocations() {
|
||||
try {
|
||||
const response = await locationsApi.list({ perpage: 100 })
|
||||
locations.value = response.data.data || []
|
||||
const items = await locationsApi.listAll() // backend caps perpage at 100; page through all
|
||||
locations.value = items || []
|
||||
} catch (err) {
|
||||
console.error('Error loading locations:', err)
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
>
|
||||
<option value="">Select vendor...</option>
|
||||
<option
|
||||
v-for="v in vendors"
|
||||
v-for="v in filteredVendors"
|
||||
:key="v.vendorid"
|
||||
:value="v.vendorid"
|
||||
>
|
||||
@@ -370,9 +370,28 @@ const vendors = ref([])
|
||||
const models = ref([])
|
||||
const locations = ref([])
|
||||
|
||||
// Ensure a saved model id is present in models.value even when the filtered
|
||||
// list (category Printer) 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 printer type
|
||||
const filteredModels = computed(() => {
|
||||
const savedId = form.value.modelnumberid
|
||||
return models.value.filter(m => {
|
||||
// Always keep the record's own saved model, so editing a printer never
|
||||
// renders a blank select that clears the field on save.
|
||||
if (m.modelnumberid === savedId) return true
|
||||
// Filter by vendor if selected
|
||||
if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
|
||||
return false
|
||||
@@ -387,6 +406,17 @@ const filteredModels = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// Only offer vendors that actually have Printer-category models. models is
|
||||
// already scoped to category Printer at load, so its vendorids are the printer
|
||||
// vendor set. Keep the record's own vendor even if it falls outside that set,
|
||||
// so editing an existing printer never silently drops its saved vendor.
|
||||
const filteredVendors = computed(() => {
|
||||
const printerVendorIds = new Set(models.value.map(m => m.vendorid))
|
||||
return vendors.value.filter(
|
||||
v => printerVendorIds.has(v.vendorid) || v.vendorid === form.value.vendorid
|
||||
)
|
||||
})
|
||||
|
||||
// Get short description from model number for naming
|
||||
function getModelShortDesc(modelNumber) {
|
||||
if (!modelNumber) return ''
|
||||
@@ -493,15 +523,15 @@ onMounted(async () => {
|
||||
printersApi.types.list({ perpage: 100 }),
|
||||
assetsApi.statuses.list(),
|
||||
vendorsApi.list({ perpage: 100 }),
|
||||
modelsApi.listAll(),
|
||||
locationsApi.list({ perpage: 100 })
|
||||
modelsApi.listAll({ category: 'Printer' }),
|
||||
locationsApi.listAll() // backend caps perpage at 100; page through all
|
||||
])
|
||||
|
||||
printerTypes.value = mtRes.data.data || []
|
||||
statuses.value = statusRes.data.data || []
|
||||
vendors.value = vendorRes.data.data || []
|
||||
models.value = allModels
|
||||
locations.value = locRes.data.data || []
|
||||
locations.value = locRes || []
|
||||
|
||||
// Load printer if editing
|
||||
if (isEdit.value) {
|
||||
@@ -539,6 +569,12 @@ onMounted(async () => {
|
||||
pin: ext.pin || ''
|
||||
}
|
||||
|
||||
// The model list is loaded filtered to category Printer, which the backend
|
||||
// inner-joins against ModelType - so an untyped model is absent. If this
|
||||
// printer'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)
|
||||
|
||||
// Don't auto-generate for existing printers
|
||||
manualWindowsName.value = true
|
||||
manualHostname.value = true
|
||||
|
||||
239
scripts/dev/seed_endpoints_sample.py
Normal file
239
scripts/dev/seed_endpoints_sample.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""Seed sample endpoint telemetry for the Fleet module (DEV ONLY).
|
||||
|
||||
Attaches EndpointState snapshots plus a handful of curated EndpointEvents to
|
||||
existing Computer assets so the fleet list, the PC-detail asset panels
|
||||
(status / services / recent events), and the dashboard quiet + stability cards
|
||||
have something to render before a real agent exists.
|
||||
|
||||
Sample rows are marked with a sentinel agentversion ("0.0.0-sample") so they can
|
||||
be listed and cleared without touching real agent data.
|
||||
|
||||
DEV ONLY: this file lives under scripts/dev/ and must never ship in a
|
||||
production image (see devnotes/live-container-and-testing-workflow.md). Do not
|
||||
import it from application code.
|
||||
|
||||
Usage (in the API container):
|
||||
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_endpoints_sample.py
|
||||
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_endpoints_sample.py --clear
|
||||
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_endpoints_sample.py --limit 8
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# Repo root on sys.path so `shopdb` and `plugins` import when run as a script.
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')))
|
||||
|
||||
from shopdb import create_app # noqa: E402
|
||||
from shopdb.api import db # noqa: E402
|
||||
|
||||
SAMPLE_MARKER = '0.0.0-sample'
|
||||
|
||||
|
||||
def _naive_utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _services(*running_then_stopped):
|
||||
"""Build a services snapshot. Positional pairs are (name, state)."""
|
||||
return [{'name': name, 'state': state} for name, state in running_then_stopped]
|
||||
|
||||
|
||||
def _ports(*pairs):
|
||||
return [{'port': port, 'process': process} for port, process in pairs]
|
||||
|
||||
|
||||
def _interfaces(ip, mac):
|
||||
return [{'name': 'Ethernet', 'ip': ip, 'mac': mac}]
|
||||
|
||||
|
||||
# Per-machine telemetry shapes. minutesago drives whether a machine reads as
|
||||
# fresh, stale, or quiet; a couple are pushed past a plausible quiet window so
|
||||
# the dashboard quiet card has rows. Everything is offline-plausible because
|
||||
# there is no live agent.
|
||||
SAMPLE_PROFILES = [
|
||||
{
|
||||
'loggedinuser': 'CORP\\jmartin',
|
||||
'agentversion': SAMPLE_MARKER,
|
||||
'minutesago': 3,
|
||||
'uptimeseconds': 4 * 86400 + 6 * 3600,
|
||||
'idleseconds': 12 * 60,
|
||||
'cpupercent': 14.0,
|
||||
'memorypercent': 46.0,
|
||||
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
|
||||
('WinRM', 'Running'), ('MSSQLSERVER', 'Running')),
|
||||
'ports': _ports((3389, 'svchost'), (5985, 'winrm'), (445, 'System')),
|
||||
'interfaces': _interfaces('10.20.4.11', '00:1A:2B:3C:4D:01'),
|
||||
'events': [(4624, -8), (4672, -8), (4698, -120)],
|
||||
},
|
||||
{
|
||||
'loggedinuser': 'CORP\\operator2',
|
||||
'agentversion': SAMPLE_MARKER,
|
||||
'minutesago': 9,
|
||||
'uptimeseconds': 2 * 3600 + 40 * 60,
|
||||
'idleseconds': 3 * 60,
|
||||
'cpupercent': 61.0,
|
||||
'memorypercent': 72.0,
|
||||
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
|
||||
('WinRM', 'Stopped')),
|
||||
'ports': _ports((3389, 'svchost'), (445, 'System')),
|
||||
'interfaces': _interfaces('10.20.4.27', '00:1A:2B:3C:4D:02'),
|
||||
# Recent unexpected shutdown -> shows on the stability card.
|
||||
'events': [(41, -50), (1001, -30), (4625, -12)],
|
||||
},
|
||||
{
|
||||
'loggedinuser': None,
|
||||
'agentversion': SAMPLE_MARKER,
|
||||
'minutesago': 22,
|
||||
'uptimeseconds': 11 * 86400,
|
||||
'idleseconds': 9 * 3600,
|
||||
'cpupercent': 3.0,
|
||||
'memorypercent': 38.0,
|
||||
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
|
||||
('WinRM', 'Running')),
|
||||
'ports': _ports((3389, 'svchost'), (5985, 'winrm')),
|
||||
'interfaces': _interfaces('10.20.4.44', '00:1A:2B:3C:4D:03'),
|
||||
'events': [(4624, -600), (7011, -180)],
|
||||
},
|
||||
{
|
||||
'loggedinuser': 'CORP\\nightshift',
|
||||
'agentversion': SAMPLE_MARKER,
|
||||
# Quiet: no report in over a day.
|
||||
'minutesago': 1500,
|
||||
'uptimeseconds': 30 * 60,
|
||||
'idleseconds': 30 * 60,
|
||||
'cpupercent': 1.0,
|
||||
'memorypercent': 29.0,
|
||||
'services': _services(('Spooler', 'Stopped'), ('W32Time', 'Running')),
|
||||
'ports': _ports((445, 'System'),),
|
||||
'interfaces': _interfaces('10.20.4.58', '00:1A:2B:3C:4D:04'),
|
||||
'events': [(55, -1600), (41, -1580)],
|
||||
},
|
||||
{
|
||||
'loggedinuser': 'CORP\\qa_kiosk',
|
||||
'agentversion': SAMPLE_MARKER,
|
||||
'minutesago': 6,
|
||||
'uptimeseconds': 6 * 3600,
|
||||
'idleseconds': 45 * 60,
|
||||
'cpupercent': 22.0,
|
||||
'memorypercent': 55.0,
|
||||
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
|
||||
('WinRM', 'Running'), ('BITS', 'Running')),
|
||||
'ports': _ports((3389, 'svchost'), (5985, 'winrm'), (135, 'rpc')),
|
||||
'interfaces': _interfaces('10.20.4.63', '00:1A:2B:3C:4D:05'),
|
||||
'events': [(4624, -20), (10016, -90), (4697, -240)],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _clear(EndpointState, EndpointEvent):
|
||||
sample_states = EndpointState.query.filter_by(agentversion=SAMPLE_MARKER).all()
|
||||
asset_ids = [s.assetid for s in sample_states]
|
||||
events_removed = 0
|
||||
if asset_ids:
|
||||
events_removed = (EndpointEvent.query
|
||||
.filter(EndpointEvent.assetid.in_(asset_ids))
|
||||
.delete(synchronize_session=False))
|
||||
states_removed = (EndpointState.query
|
||||
.filter_by(agentversion=SAMPLE_MARKER)
|
||||
.delete(synchronize_session=False))
|
||||
db.session.commit()
|
||||
print('Cleared {} sample state(s) and {} sample event(s).'
|
||||
.format(states_removed, events_removed))
|
||||
|
||||
|
||||
def _seed(limit):
|
||||
from plugins.computers.models import Computer
|
||||
from plugins.endpoints.models import EndpointState, EndpointEvent
|
||||
from plugins.endpoints.eventcatalog import EVENT_CATALOG
|
||||
|
||||
computers = (Computer.query
|
||||
.filter(Computer.assetid.isnot(None))
|
||||
.order_by(Computer.computerid.asc())
|
||||
.limit(limit)
|
||||
.all())
|
||||
if not computers:
|
||||
print('No Computer assets found to attach sample endpoints to. '
|
||||
'Seed the computers plugin first.')
|
||||
return
|
||||
|
||||
now = _naive_utcnow()
|
||||
created, updated, events_added = 0, 0, 0
|
||||
|
||||
for index, computer in enumerate(computers):
|
||||
profile = SAMPLE_PROFILES[index % len(SAMPLE_PROFILES)]
|
||||
reported = now - timedelta(minutes=profile['minutesago'])
|
||||
boot = reported - timedelta(seconds=profile['uptimeseconds'])
|
||||
|
||||
state = EndpointState.query.filter_by(assetid=computer.assetid).first()
|
||||
if state and state.agentversion not in (None, SAMPLE_MARKER):
|
||||
# Never stomp a real agent's snapshot.
|
||||
continue
|
||||
if not state:
|
||||
state = EndpointState(assetid=computer.assetid, createdat=now)
|
||||
db.session.add(state)
|
||||
created += 1
|
||||
else:
|
||||
updated += 1
|
||||
|
||||
state.hostname = computer.hostname or 'HOST-{}'.format(computer.computerid)
|
||||
state.loggedinuser = profile['loggedinuser']
|
||||
state.agentversion = SAMPLE_MARKER
|
||||
state.lastboottime = boot
|
||||
state.uptimeseconds = profile['uptimeseconds']
|
||||
state.idleseconds = profile['idleseconds']
|
||||
state.cpupercent = profile['cpupercent']
|
||||
state.memorypercent = profile['memorypercent']
|
||||
state.servicesjson = json.dumps(profile['services'])
|
||||
state.portsjson = json.dumps(profile['ports'])
|
||||
state.interfacesjson = json.dumps(profile['interfaces'])
|
||||
state.scheduledtasksjson = json.dumps([])
|
||||
state.lastreportedat = reported
|
||||
|
||||
# Refresh this asset's sample events so re-running stays idempotent.
|
||||
EndpointEvent.query.filter_by(assetid=computer.assetid).delete(
|
||||
synchronize_session=False)
|
||||
for eventid, offset_minutes in profile['events']:
|
||||
title, severity, channel = EVENT_CATALOG.get(
|
||||
eventid, ('Event {}'.format(eventid), 'info', 'System'))
|
||||
event = EndpointEvent(
|
||||
assetid=computer.assetid,
|
||||
eventid=eventid,
|
||||
eventchannel=channel,
|
||||
eventsource='sample-seed',
|
||||
eventtime=reported + timedelta(minutes=offset_minutes),
|
||||
eventmessage='{} (sample)'.format(title),
|
||||
payloadjson=json.dumps({'sample': True, 'severity': severity}),
|
||||
recordedat=now,
|
||||
)
|
||||
db.session.add(event)
|
||||
events_added += 1
|
||||
|
||||
db.session.commit()
|
||||
print('Seeded sample endpoints: {} created, {} updated, {} event(s) added '
|
||||
'across {} computer(s).'
|
||||
.format(created, updated, events_added, len(computers)))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--clear', action='store_true',
|
||||
help='Remove sample-marked endpoint states and their events.')
|
||||
parser.add_argument('--limit', type=int, default=5,
|
||||
help='Max number of computers to attach sample endpoints to.')
|
||||
args = parser.parse_args()
|
||||
|
||||
app = create_app(os.environ.get('FLASK_ENV', 'production'))
|
||||
with app.app_context():
|
||||
from plugins.endpoints.models import EndpointState, EndpointEvent
|
||||
if args.clear:
|
||||
_clear(EndpointState, EndpointEvent)
|
||||
else:
|
||||
_seed(args.limit)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
102
scripts/dev/seed_shared_applications.py
Normal file
102
scripts/dev/seed_shared_applications.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Seed the org's shared shopfloor applications (DEV ONLY).
|
||||
|
||||
Inserts the applications that ship as common across the organization so the
|
||||
Configuration Policy builder has real apps to scope against and the software
|
||||
inventory has canonical rows to match collected installs to. Each app records
|
||||
its executable and (where relevant) its Windows service name in
|
||||
applicationnotes, since the Application model has no dedicated process/service
|
||||
column - these identifiers are what a generic service_*/process_* policy action
|
||||
targets.
|
||||
|
||||
Shipped set:
|
||||
* UDC - UDC.exe
|
||||
* MTConnect - agent.exe process, "MTConnect UDC Agent" service
|
||||
* eDNC - DncMain.exe process
|
||||
|
||||
Idempotent: matches on appname and updates in place, so re-running does not
|
||||
duplicate. Marked installable so they appear in installable-app pickers.
|
||||
|
||||
DEV ONLY: lives under scripts/dev/ and must never be imported by application
|
||||
code. Run inside the API container.
|
||||
|
||||
Usage (in the API container):
|
||||
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_shared_applications.py
|
||||
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_shared_applications.py --clear
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Repo root on sys.path so `shopdb` imports when run as a script.
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')))
|
||||
|
||||
from shopdb import create_app # noqa: E402
|
||||
from shopdb.api import db # noqa: E402
|
||||
|
||||
# appname -> seed fields. 'notes' captures the process image and service name so
|
||||
# a generic policy action (params.target) has a canonical identifier to bind to.
|
||||
SHARED_APPS = [
|
||||
{
|
||||
'appname': 'UDC',
|
||||
'appdescription': 'Universal Data Collector',
|
||||
'notes': 'process=UDC.exe',
|
||||
},
|
||||
{
|
||||
'appname': 'MTConnect',
|
||||
'appdescription': 'MTConnect UDC agent',
|
||||
'notes': 'process=agent.exe; service=MTConnect UDC Agent',
|
||||
},
|
||||
{
|
||||
'appname': 'eDNC',
|
||||
'appdescription': 'eDNC distributed NC file transfer',
|
||||
'notes': 'process=DncMain.exe',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _seed(Application):
|
||||
created = 0
|
||||
updated = 0
|
||||
for spec in SHARED_APPS:
|
||||
app_row = Application.query.filter_by(appname=spec['appname']).first()
|
||||
if app_row is None:
|
||||
app_row = Application(appname=spec['appname'])
|
||||
db.session.add(app_row)
|
||||
created += 1
|
||||
else:
|
||||
updated += 1
|
||||
app_row.appdescription = spec['appdescription']
|
||||
app_row.applicationnotes = spec['notes']
|
||||
app_row.isinstallable = True
|
||||
db.session.commit()
|
||||
print('Seeded shared applications: {} created, {} updated.'
|
||||
.format(created, updated))
|
||||
|
||||
|
||||
def _clear(Application):
|
||||
names = [spec['appname'] for spec in SHARED_APPS]
|
||||
removed = (Application.query
|
||||
.filter(Application.appname.in_(names))
|
||||
.delete(synchronize_session=False))
|
||||
db.session.commit()
|
||||
print('Removed {} shared application row(s).'.format(removed))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--clear', action='store_true',
|
||||
help='Remove the seeded shared applications.')
|
||||
args = parser.parse_args()
|
||||
|
||||
app = create_app(os.environ.get('FLASK_ENV', 'production'))
|
||||
with app.app_context():
|
||||
from shopdb.api import Application
|
||||
if args.clear:
|
||||
_clear(Application)
|
||||
else:
|
||||
_seed(Application)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
156
scripts/reconcile_locations.py
Normal file
156
scripts/reconcile_locations.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Generate reconciliation SQL for the locations table.
|
||||
|
||||
Reads a TSV export (locationid, locationname, description, locationtypeid,
|
||||
parentlocationid) and emits a single transaction-wrapped SQL script that:
|
||||
- Treats bare grid coordinates ([A-V][0-9]{1,2}W? and LD[0-9]+W?) as the
|
||||
canonical identity for a cell.
|
||||
- Folds coordinate-plus-suffix rows into the canonical coordinate, moving the
|
||||
suffix text into the canonical row's description (never the name).
|
||||
- Folds named-area suffix variants ("NDE - 6409") into the parent named area
|
||||
("NDE"); named areas are kept as-is and never forced into coordinates.
|
||||
- Repoints assets.locationid, subnets.locationid and child
|
||||
locations.parentlocationid onto the canonical row, then deletes duplicates.
|
||||
|
||||
Bare coordinates with no variants are left untouched. The trailing W (west
|
||||
column, e.g. G2W, K3W) is part of the coordinate and is never stripped, so
|
||||
G2W is kept distinct from a hypothetical G2.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
|
||||
COORD = re.compile(r'^(LD|[ABCDEFGHJKLMNPQRSTUV])(\d{1,2})(W)?(?=$|[ \-])')
|
||||
|
||||
|
||||
def esc(s):
|
||||
return s.replace("'", "''")
|
||||
|
||||
|
||||
def load(path):
|
||||
rows = []
|
||||
with open(path, encoding='utf-8-sig') as fh:
|
||||
for line in fh:
|
||||
line = line.rstrip('\n').rstrip('\r')
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split('\t')
|
||||
if len(parts) < 5:
|
||||
parts += [''] * (5 - len(parts))
|
||||
lid, name, desc, typeid, parentid = parts[:5]
|
||||
rows.append({
|
||||
'id': int(lid),
|
||||
'name': name,
|
||||
'desc': desc,
|
||||
'typeid': typeid,
|
||||
'parentid': parentid,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def classify(name):
|
||||
"""Return (canonical, suffix). canonical is the bare coordinate or named
|
||||
parent; suffix is the trailing detail (may be '')."""
|
||||
m = COORD.match(name)
|
||||
if m:
|
||||
prefix, digits, west = m.group(1), m.group(2), m.group(3) or ''
|
||||
canonical = f"{prefix}{digits}{west}"
|
||||
suffix = name[m.end():].strip()
|
||||
suffix = re.sub(r'^[\-\s]+', '', suffix).strip()
|
||||
return canonical, suffix, 'coord'
|
||||
# named area: fold on ' - ' separator only
|
||||
if ' - ' in name:
|
||||
base, _, rest = name.partition(' - ')
|
||||
return base.strip(), rest.strip(), 'named'
|
||||
return name.strip(), '', 'named'
|
||||
|
||||
|
||||
def main(tsv, out):
|
||||
rows = load(tsv)
|
||||
|
||||
# Build canonical groups. Key is casefolded canonical for grouping, but we
|
||||
# preserve a display canonical (prefer an existing exact bare/standalone).
|
||||
groups = {}
|
||||
for r in rows:
|
||||
canonical, suffix, kind = classify(r['name'])
|
||||
key = (kind, canonical.casefold())
|
||||
g = groups.setdefault(key, {
|
||||
'canonical': canonical,
|
||||
'kind': kind,
|
||||
'members': [],
|
||||
'exact_id': None,
|
||||
})
|
||||
r['_suffix'] = suffix
|
||||
r['_canonical'] = canonical
|
||||
g['members'].append(r)
|
||||
# exact row: name equals canonical (no suffix)
|
||||
if suffix == '' and r['name'].strip() == canonical:
|
||||
g['exact_id'] = r['id']
|
||||
g['canonical'] = r['name'].strip()
|
||||
|
||||
lines = []
|
||||
lines.append('-- Auto-generated locations reconciliation. Review before running.')
|
||||
lines.append('START TRANSACTION;')
|
||||
|
||||
touched = 0
|
||||
for key, g in sorted(groups.items(), key=lambda kv: kv[0][1]):
|
||||
members = sorted(g['members'], key=lambda r: r['id'])
|
||||
# nothing to do: single member that is already the exact canonical
|
||||
if len(members) == 1 and g['exact_id'] == members[0]['id']:
|
||||
continue
|
||||
touched += 1
|
||||
canonical = g['canonical']
|
||||
|
||||
# choose target id: prefer exact bare row, else promote smallest id
|
||||
if g['exact_id'] is not None:
|
||||
target = g['exact_id']
|
||||
promote = None
|
||||
else:
|
||||
target = members[0]['id']
|
||||
promote = members[0]
|
||||
|
||||
# accumulate suffix/description text from all members (dedup, ordered)
|
||||
details = []
|
||||
for m in members:
|
||||
for chunk in (m['_suffix'], m['desc']):
|
||||
chunk = (chunk or '').strip()
|
||||
if chunk and chunk not in details:
|
||||
details.append(chunk)
|
||||
desc_val = '; '.join(details)
|
||||
|
||||
lines.append('')
|
||||
lines.append(f"-- {canonical} (target locationid {target}) <= "
|
||||
+ ', '.join(str(m['id']) for m in members))
|
||||
if promote is not None:
|
||||
lines.append(
|
||||
f"UPDATE locations SET locationname='{esc(canonical)}' "
|
||||
f"WHERE locationid={target};")
|
||||
if desc_val:
|
||||
lines.append(
|
||||
f"UPDATE locations SET description='{esc(desc_val)}' "
|
||||
f"WHERE locationid={target};")
|
||||
|
||||
others = [m['id'] for m in members if m['id'] != target]
|
||||
if others:
|
||||
ids = ','.join(str(i) for i in others)
|
||||
lines.append(
|
||||
f"UPDATE assets SET locationid={target} "
|
||||
f"WHERE locationid IN ({ids});")
|
||||
lines.append(
|
||||
f"UPDATE subnets SET locationid={target} "
|
||||
f"WHERE locationid IN ({ids});")
|
||||
lines.append(
|
||||
f"UPDATE locations SET parentlocationid={target} "
|
||||
f"WHERE parentlocationid IN ({ids});")
|
||||
lines.append(
|
||||
f"DELETE FROM locations WHERE locationid IN ({ids});")
|
||||
|
||||
lines.append('')
|
||||
lines.append('COMMIT;')
|
||||
lines.append(f'-- groups touched: {touched}')
|
||||
|
||||
with open(out, 'w', encoding='utf-8') as fh:
|
||||
fh.write('\n'.join(lines) + '\n')
|
||||
print(f'wrote {out}: {touched} groups touched, {len(rows)} source rows')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main(sys.argv[1], sys.argv[2])
|
||||
594
scripts/site_imports/assetcollector/Collect-ShopDB.ps1
Normal file
594
scripts/site_imports/assetcollector/Collect-ShopDB.ps1
Normal file
@@ -0,0 +1,594 @@
|
||||
#Requires -Version 5.1
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# Where CSVs are collected/accumulated. Overrides the in-script CONFIG block
|
||||
# below. See the CONFIG block for the easy place to set this permanently.
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$OutputRoot,
|
||||
|
||||
# Optional network share to mirror the store into after a run. Overrides the
|
||||
# in-script CONFIG block below.
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$PublishRoot,
|
||||
|
||||
# Business identifier for this PC (workstation number). Defaults to hostname.
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$AssetNumber,
|
||||
|
||||
# Value written into assets.assettypeid for the PC (resolved by name).
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$AssetType = 'Computer',
|
||||
|
||||
# Value written into computers.computertypeid (resolved by name).
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$ComputerType,
|
||||
|
||||
# Value written into machines.machinetypeid (resolved by name).
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$MachineType,
|
||||
|
||||
# Explicit eDNC/UDC source overrides. When omitted, standard on-asset
|
||||
# locations are searched.
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$RegPath,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$JsonPath,
|
||||
|
||||
# Skip switches for partial runs.
|
||||
[switch]$SkipMachine,
|
||||
[switch]$SkipApps
|
||||
)
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Unified ShopDB site-adoption collector. Runs on any PC, collects the PC and any
|
||||
attached eDNC/DNC machine, and UPSERTS the results into a shared set of
|
||||
importable CSVs so a site can build its inventory PC by PC.
|
||||
|
||||
.DESCRIPTION
|
||||
Accumulating store under OutputRoot:
|
||||
vendors.csv, models.csv, operatingsystems.csv, assets.csv,
|
||||
computers.csv, machines.csv
|
||||
Rows are merged field-by-field on each table's natural key (a later run fills
|
||||
gaps and refreshes values without wiping data an earlier run captured).
|
||||
|
||||
Phase-2 data that CSV cannot carry is written per entity under OutputRoot\sidecars:
|
||||
computer_<assetnumber>.json (interfaces, installed apps)
|
||||
machine_<machinenumber>.json (PC<->Machine link, connection detail)
|
||||
|
||||
Read-only against the machine. Local reads may run elevated; publishing to a
|
||||
share runs in the current user context only.
|
||||
|
||||
.NOTES
|
||||
PS 5.1 compatible. Headers match the ShopDB importer (see CSV-SCHEMA.md).
|
||||
#>
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# =============================================================================
|
||||
# CONFIG - SET YOUR CSV DESTINATIONS HERE
|
||||
# -----------------------------------------------------------------------------
|
||||
# Two-stage flow so you can collect locally (possibly elevated) and then append
|
||||
# the captured rows to a CSV store that lives on a network share:
|
||||
#
|
||||
# 1) LocalStageRoot Fast, always-writable local folder where CSVs are built
|
||||
# and merged. Collection can run elevated here.
|
||||
# 2) NetworkStoreRoot The shared CSV store the whole site accumulates into and
|
||||
# that you later point 'flask csv import --dir' at. Leave
|
||||
# blank ('') to keep everything local. When set, the merged
|
||||
# store is copied here at the end of the run. Network
|
||||
# writes must happen as a STANDARD user - do NOT run the
|
||||
# publish step elevated.
|
||||
#
|
||||
# Example network path: '\\fileserver\shopdb$\intake'
|
||||
#
|
||||
# Command-line -OutputRoot / -PublishRoot still override these values.
|
||||
# =============================================================================
|
||||
|
||||
$LocalStageRoot = Join-Path $env:ProgramData 'ShopDB\intake'
|
||||
# Ship blank so a site that has not configured its share keeps everything local
|
||||
# instead of publishing to another site's path. Set this to your own share (or
|
||||
# pass -PublishRoot), e.g. '\\fileserver\shopdb$\intake'. The download endpoint
|
||||
# fills this in from asset_import_csv_location when the site has set it.
|
||||
$NetworkStoreRoot = ''
|
||||
|
||||
# Resolve effective destinations (command-line params win over CONFIG above).
|
||||
if (-not $OutputRoot) { $OutputRoot = $LocalStageRoot }
|
||||
if (-not $PublishRoot) { $PublishRoot = $NetworkStoreRoot }
|
||||
|
||||
# --- Store definitions (header order + natural key per importable table) -------
|
||||
|
||||
$script:Stores = [ordered]@{
|
||||
'vendors.csv' = @{ key = 'vendor'; header = @('vendor') }
|
||||
'models.csv' = @{ key = 'modelnumber'; header = @('modelnumber','vendorid','modeltypeid') }
|
||||
'operatingsystems.csv' = @{ key = 'osname'; header = @('osname') }
|
||||
'assets.csv' = @{ key = 'assetnumber'; header = @('assetnumber','assettypeid','name','statusid','serialnumber','gaugelabreference','maintenancereference','locationid','businessunitid','notes') }
|
||||
'computers.csv' = @{ key = 'assetid'; header = @('assetid','hostname','computertypeid','osid','vendorid','modelnumberid','loggedinuser','lastreporteddate','lastboottime') }
|
||||
'machines.csv' = @{ key = 'assetid'; header = @('assetid','machinetypeid','vendorid','modelnumberid','controllervendorid','controllermodelid','requiresmanualconfig','islocationonly','notes') }
|
||||
}
|
||||
|
||||
# --- Shared helpers -----------------------------------------------------------
|
||||
|
||||
function ConvertTo-CsvField {
|
||||
param([AllowNull()]$Value)
|
||||
if ($null -eq $Value) { return '' }
|
||||
$text = [string]$Value
|
||||
if ($text -match '[",\r\n]') { return '"' + ($text -replace '"', '""') + '"' }
|
||||
return $text
|
||||
}
|
||||
|
||||
function Get-Prop {
|
||||
param([AllowNull()]$Object, [Parameter(Mandatory = $true)][string]$Name)
|
||||
if ($null -eq $Object) { return $null }
|
||||
$prop = $Object.PSObject.Properties[$Name]
|
||||
if ($null -eq $prop) { return $null }
|
||||
return $prop.Value
|
||||
}
|
||||
|
||||
function Get-NonEmpty {
|
||||
param([AllowNull()]$Value)
|
||||
if ($null -ne $Value -and -not [string]::IsNullOrWhiteSpace([string]$Value)) {
|
||||
return ([string]$Value).Trim()
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-FirstNonEmpty {
|
||||
param([object[]]$Values)
|
||||
foreach ($v in $Values) { $n = Get-NonEmpty $v; if ($n) { return $n } }
|
||||
return $null
|
||||
}
|
||||
|
||||
function Format-ShopDbDate {
|
||||
param([AllowNull()]$Value)
|
||||
if ($null -eq $Value) { return $null }
|
||||
try { return ([datetime]$Value).ToString('yyyy-MM-dd HH:mm:ss') } catch { return $null }
|
||||
}
|
||||
|
||||
function Write-Utf8NoBom {
|
||||
param([string]$Path, [string]$Content)
|
||||
$encoding = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($Path, $Content, $encoding)
|
||||
}
|
||||
|
||||
# Field-level upsert into a persistent CSV store, keyed by natural key. Existing
|
||||
# values are kept when the incoming cell is empty, so a partial run never blanks
|
||||
# out data an earlier run captured.
|
||||
function Merge-CsvStore {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[Parameter(Mandatory = $true)][string[]]$Header,
|
||||
[Parameter(Mandatory = $true)][string]$Key,
|
||||
[Parameter(Mandatory = $true)][AllowNull()][object[]]$IncomingRows
|
||||
)
|
||||
|
||||
$index = [ordered]@{}
|
||||
if (Test-Path -LiteralPath $Path) {
|
||||
foreach ($existing in @(Import-Csv -LiteralPath $Path)) {
|
||||
$keyVal = Get-NonEmpty (Get-Prop $existing $Key)
|
||||
if (-not $keyVal) { continue }
|
||||
$index[$keyVal.ToLowerInvariant()] = $existing
|
||||
}
|
||||
}
|
||||
|
||||
$changed = 0
|
||||
foreach ($row in $IncomingRows) {
|
||||
if ($null -eq $row) { continue }
|
||||
$keyVal = Get-NonEmpty (Get-Prop $row $Key)
|
||||
if (-not $keyVal) { continue }
|
||||
$lc = $keyVal.ToLowerInvariant()
|
||||
if ($index.Contains($lc)) {
|
||||
$target = $index[$lc]
|
||||
foreach ($col in $Header) {
|
||||
$incoming = Get-NonEmpty (Get-Prop $row $col)
|
||||
if ($incoming) {
|
||||
if ($target.PSObject.Properties[$col]) { $target.$col = $incoming }
|
||||
else { $target | Add-Member -NotePropertyName $col -NotePropertyValue $incoming -Force }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$fresh = [ordered]@{}
|
||||
foreach ($col in $Header) { $fresh[$col] = Get-NonEmpty (Get-Prop $row $col) }
|
||||
$index[$lc] = [pscustomobject]$fresh
|
||||
}
|
||||
$changed++
|
||||
}
|
||||
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
$lines.Add(($Header -join ','))
|
||||
foreach ($stored in $index.Values) {
|
||||
$cells = foreach ($col in $Header) { ConvertTo-CsvField (Get-Prop $stored $col) }
|
||||
$lines.Add(($cells -join ','))
|
||||
}
|
||||
Write-Utf8NoBom -Path $Path -Content (($lines -join "`r`n") + "`r`n")
|
||||
return $changed
|
||||
}
|
||||
|
||||
# --- eDNC/UDC parsing (registry, .reg export, UDC settings json) --------------
|
||||
|
||||
function Resolve-ControllerVendor {
|
||||
param([AllowNull()][string]$ControlType, [AllowNull()][string]$NcIF)
|
||||
$ct = ('' + $ControlType).Trim().ToUpperInvariant()
|
||||
$nc = ('' + $NcIF).Trim().ToUpperInvariant()
|
||||
if ($nc -eq 'EFOCAS' -or $ct -eq 'EFOCAS') { return 'FANUC' }
|
||||
if ($ct -eq 'SIEMENS') { return 'Siemens' }
|
||||
if ($ct -eq 'OKUMA') { return 'Okuma' }
|
||||
return $null
|
||||
}
|
||||
|
||||
function Read-RegExport {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
$lines = @(Get-Content -LiteralPath $Path -Encoding Unicode -ErrorAction Stop)
|
||||
$firstMeaningful = ($lines | Where-Object { $_ -match '\S' } | Select-Object -First 1)
|
||||
if (-not ($firstMeaningful -match '^(REGEDIT4|Windows Registry Editor Version)')) {
|
||||
$lines = @(Get-Content -LiteralPath $Path -Encoding Default -ErrorAction Stop)
|
||||
}
|
||||
$data = @{}
|
||||
$currentKey = $null
|
||||
foreach ($raw in $lines) {
|
||||
$line = $raw.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($line)) { continue }
|
||||
if ($line -match '^(REGEDIT4|Windows Registry Editor Version)') { continue }
|
||||
if ($line -match '^\s*;') { continue }
|
||||
if ($line -match '^\[(?<key>[^\]]+)\]$') {
|
||||
$currentKey = $Matches['key']
|
||||
if (-not $data.ContainsKey($currentKey)) { $data[$currentKey] = @{} }
|
||||
continue
|
||||
}
|
||||
if (-not $currentKey) { continue }
|
||||
if ($line -match '^"(?<name>[^"]+)"=(?<rhs>.+)$') {
|
||||
$name = $Matches['name']
|
||||
$rhs = $Matches['rhs'].Trim()
|
||||
if ($rhs -match '^"(?<s>.*)"$') { $val = $Matches['s'] }
|
||||
elseif ($rhs -match '^dword:(?<hex>[0-9a-fA-F]{8})$') { $val = [Convert]::ToInt32($Matches['hex'], 16) }
|
||||
else { $val = $rhs }
|
||||
$data[$currentKey][$name] = $val
|
||||
}
|
||||
}
|
||||
return $data
|
||||
}
|
||||
|
||||
function Get-RegExportValue {
|
||||
param([hashtable]$Reg, [string]$KeySuffix, [string]$Name)
|
||||
foreach ($key in $Reg.Keys) {
|
||||
if ($key -match ('\\' + [regex]::Escape($KeySuffix) + '$')) {
|
||||
if ($Reg[$key].ContainsKey($Name)) { return $Reg[$key][$Name] }
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function ConvertFrom-RegExport {
|
||||
param([hashtable]$Reg)
|
||||
[ordered]@{
|
||||
source = 'reg'
|
||||
machinenumber = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'MachineNo')
|
||||
site = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'Site')
|
||||
controltype = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'Cnc')
|
||||
ncif = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'NcIF')
|
||||
dnctype = 'eDNC'
|
||||
tcpaddress = Get-NonEmpty (Get-RegExportValue $Reg 'eFocas' 'IpAddr')
|
||||
tcpport = Get-NonEmpty (Get-RegExportValue $Reg 'eFocas' 'SocketNo')
|
||||
comport = Get-NonEmpty (Get-RegExportValue $Reg 'PPDCS' 'Port Id')
|
||||
baudrate = Get-NonEmpty (Get-RegExportValue $Reg 'PPDCS' 'Baud')
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RegistryValue {
|
||||
param([string]$Path, [string]$Name)
|
||||
if (-not (Test-Path -LiteralPath $Path)) { return $null }
|
||||
return Get-Prop (Get-ItemProperty -LiteralPath $Path -ErrorAction SilentlyContinue) $Name
|
||||
}
|
||||
|
||||
function ConvertFrom-LiveRegistry {
|
||||
param([string]$Base)
|
||||
if (-not (Test-Path -LiteralPath $Base)) { return $null }
|
||||
$general = Join-Path $Base 'General'
|
||||
$efocas = Join-Path $Base 'eFocas'
|
||||
$ppdcs = Join-Path $Base 'PPDCS'
|
||||
[ordered]@{
|
||||
source = 'registry'
|
||||
machinenumber = Get-NonEmpty (Get-RegistryValue $general 'MachineNo')
|
||||
site = Get-NonEmpty (Get-RegistryValue $general 'Site')
|
||||
controltype = Get-NonEmpty (Get-RegistryValue $general 'Cnc')
|
||||
ncif = Get-NonEmpty (Get-RegistryValue $general 'NcIF')
|
||||
dnctype = 'eDNC'
|
||||
tcpaddress = Get-NonEmpty (Get-RegistryValue $efocas 'IpAddr')
|
||||
tcpport = Get-NonEmpty (Get-RegistryValue $efocas 'SocketNo')
|
||||
comport = Get-NonEmpty (Get-RegistryValue $ppdcs 'Port Id')
|
||||
baudrate = Get-NonEmpty (Get-RegistryValue $ppdcs 'Baud')
|
||||
}
|
||||
}
|
||||
|
||||
function ConvertFrom-UdcJson {
|
||||
param([string]$Path)
|
||||
$obj = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json
|
||||
$general = Get-Prop $obj 'GeneralSettings'
|
||||
$ethernet = Get-Prop $obj 'EthernetSettings'
|
||||
$serial = Get-Prop $obj 'SerialSettings'
|
||||
[ordered]@{
|
||||
source = 'json'
|
||||
machinenumber = Get-NonEmpty (Get-Prop $general 'MachineNumber')
|
||||
site = Get-NonEmpty (Get-Prop $general 'Site')
|
||||
controltype = Get-NonEmpty (Get-Prop $general 'ControlType')
|
||||
ncif = $null
|
||||
dnctype = Get-NonEmpty (Get-Prop $general 'DncType')
|
||||
tcpaddress = Get-NonEmpty (Get-Prop $ethernet 'TCPAddress')
|
||||
tcpport = Get-NonEmpty (Get-Prop $ethernet 'TCPPort')
|
||||
comport = Get-NonEmpty (Get-Prop $serial 'ComPort')
|
||||
baudrate = Get-NonEmpty (Get-Prop $serial 'BaudRate')
|
||||
}
|
||||
}
|
||||
|
||||
function Find-FirstFile {
|
||||
param([string[]]$Roots, [string]$Filter)
|
||||
foreach ($root in $Roots) {
|
||||
if (-not $root -or -not (Test-Path -LiteralPath $root)) { continue }
|
||||
$hit = Get-ChildItem -LiteralPath $root -Filter $Filter -File -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
if ($hit) { return $hit.FullName }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Resolve-DncSource {
|
||||
param([string]$RegPath, [string]$JsonPath)
|
||||
if ($JsonPath -and (Test-Path -LiteralPath $JsonPath)) {
|
||||
Write-Host (' machine source: JSON (explicit) ' + $JsonPath) -ForegroundColor DarkGray
|
||||
return ConvertFrom-UdcJson -Path $JsonPath
|
||||
}
|
||||
if ($RegPath -and (Test-Path -LiteralPath $RegPath)) {
|
||||
Write-Host (' machine source: .reg (explicit) ' + $RegPath) -ForegroundColor DarkGray
|
||||
return ConvertFrom-RegExport -Reg (Read-RegExport -Path $RegPath)
|
||||
}
|
||||
foreach ($base in @(
|
||||
'HKLM:\SOFTWARE\GE Aircraft Engines\DNC',
|
||||
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC')) {
|
||||
$fromReg = ConvertFrom-LiveRegistry -Base $base
|
||||
if ($fromReg -and $fromReg.machinenumber) {
|
||||
Write-Host (' machine source: live registry ' + $base) -ForegroundColor DarkGray
|
||||
return $fromReg
|
||||
}
|
||||
}
|
||||
$localReg = Find-FirstFile -Roots @('C:\Program Files (x86)\Dnc', (Join-Path $env:SystemDrive 'GE Aerospace')) -Filter '*.reg'
|
||||
if ($localReg) {
|
||||
Write-Host (' machine source: local .reg ' + $localReg) -ForegroundColor DarkGray
|
||||
return ConvertFrom-RegExport -Reg (Read-RegExport -Path $localReg)
|
||||
}
|
||||
$localJson = Find-FirstFile -Roots @((Join-Path $env:SystemDrive 'GE Aerospace'), 'C:\Program Files (x86)\UDC', 'C:\Program Files (x86)\Dnc') -Filter 'udc_settings_*.json'
|
||||
if ($localJson) {
|
||||
Write-Host (' machine source: local UDC json ' + $localJson) -ForegroundColor DarkGray
|
||||
return ConvertFrom-UdcJson -Path $localJson
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# --- PC collection (read-only) ------------------------------------------------
|
||||
|
||||
function Get-PcFacts {
|
||||
$computerSystem = Get-CimInstance Win32_ComputerSystem -ErrorAction SilentlyContinue
|
||||
$operatingSys = Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue
|
||||
$bios = Get-CimInstance Win32_BIOS -ErrorAction SilentlyContinue
|
||||
|
||||
$interfaces = @()
|
||||
try {
|
||||
foreach ($cfg in @(Get-NetIPConfiguration -ErrorAction SilentlyContinue)) {
|
||||
$adapter = $null
|
||||
try { $adapter = Get-NetAdapter -InterfaceAlias $cfg.InterfaceAlias -ErrorAction SilentlyContinue } catch {}
|
||||
if (-not $adapter -or (Get-Prop $adapter 'HardwareInterface') -ne $true) { continue }
|
||||
$ipv4 = @(Get-Prop $cfg 'IPv4Address') | Select-Object -First 1
|
||||
$interfaces += [ordered]@{
|
||||
interfacealias = [string]$cfg.InterfaceAlias
|
||||
ipaddress = if ($ipv4) { [string](Get-Prop $ipv4 'IPAddress') } else { $null }
|
||||
macaddress = [string](Get-Prop $adapter 'MacAddress')
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
$installedApps = @()
|
||||
if (-not $SkipApps) {
|
||||
foreach ($keyPath in @(
|
||||
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*')) {
|
||||
foreach ($entry in @(Get-ItemProperty -Path $keyPath -ErrorAction SilentlyContinue)) {
|
||||
$displayName = Get-NonEmpty (Get-Prop $entry 'DisplayName')
|
||||
if (-not $displayName) { continue }
|
||||
if ((Get-Prop $entry 'SystemComponent') -eq 1) { continue }
|
||||
$installedApps += [ordered]@{
|
||||
appname = $displayName
|
||||
installedversion = Get-NonEmpty (Get-Prop $entry 'DisplayVersion')
|
||||
publisher = Get-NonEmpty (Get-Prop $entry 'Publisher')
|
||||
}
|
||||
}
|
||||
}
|
||||
$installedApps = @($installedApps | Sort-Object { $_.appname } -Unique)
|
||||
}
|
||||
|
||||
[ordered]@{
|
||||
hostname = Get-FirstNonEmpty @($env:COMPUTERNAME, (Get-Prop $computerSystem 'Name'))
|
||||
vendor = Get-NonEmpty (Get-Prop $computerSystem 'Manufacturer')
|
||||
model = Get-NonEmpty (Get-Prop $computerSystem 'Model')
|
||||
osname = Get-NonEmpty (Get-Prop $operatingSys 'Caption')
|
||||
serial = Get-FirstNonEmpty @((Get-Prop $bios 'SerialNumber'), (Get-Prop $computerSystem 'SerialNumber'))
|
||||
lastboot = Format-ShopDbDate (Get-Prop $operatingSys 'LastBootUpTime')
|
||||
loggeduser = Get-NonEmpty (Get-Prop $computerSystem 'UserName')
|
||||
interfaces = $interfaces
|
||||
installedapps = $installedApps
|
||||
}
|
||||
}
|
||||
|
||||
# --- Main ---------------------------------------------------------------------
|
||||
|
||||
Write-Host ''
|
||||
Write-Host ' ShopDB unified collector' -ForegroundColor Cyan
|
||||
Write-Host (' ' + ('-' * 56)) -ForegroundColor DarkGray
|
||||
|
||||
if (-not (Test-Path -LiteralPath $OutputRoot)) { New-Item -ItemType Directory -Path $OutputRoot -Force | Out-Null }
|
||||
$sidecarDir = Join-Path $OutputRoot 'sidecars'
|
||||
if (-not (Test-Path -LiteralPath $sidecarDir)) { New-Item -ItemType Directory -Path $sidecarDir -Force | Out-Null }
|
||||
|
||||
$collectedAt = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
|
||||
|
||||
$pc = Get-PcFacts
|
||||
if (-not $AssetNumber) { $AssetNumber = $pc.hostname }
|
||||
|
||||
# Row sets keyed to the shared stores.
|
||||
$vendorRows = @()
|
||||
$modelRows = @()
|
||||
$osRows = @()
|
||||
$assetRows = @()
|
||||
$computerRows = @()
|
||||
$machineRows = @()
|
||||
|
||||
if ($pc.vendor) { $vendorRows += [pscustomobject]@{ vendor = $pc.vendor } }
|
||||
if ($pc.model) { $modelRows += [pscustomobject]@{ modelnumber = $pc.model; vendorid = $pc.vendor; modeltypeid = $null } }
|
||||
if ($pc.osname) { $osRows += [pscustomobject]@{ osname = $pc.osname } }
|
||||
|
||||
$assetRows += [pscustomobject]@{
|
||||
assetnumber = $AssetNumber; assettypeid = $AssetType; name = $pc.hostname; statusid = $null
|
||||
serialnumber = $pc.serial; gaugelabreference = $null; maintenancereference = $null
|
||||
locationid = $null; businessunitid = $null
|
||||
notes = 'PC collected ' + $collectedAt
|
||||
}
|
||||
$computerRows += [pscustomobject]@{
|
||||
assetid = $AssetNumber; hostname = $pc.hostname; computertypeid = $ComputerType; osid = $pc.osname
|
||||
vendorid = $pc.vendor; modelnumberid = $pc.model; loggedinuser = $pc.loggeduser
|
||||
lastreporteddate = $collectedAt; lastboottime = $pc.lastboot
|
||||
}
|
||||
|
||||
# Machine (best-effort).
|
||||
$machine = $null
|
||||
if (-not $SkipMachine) {
|
||||
try { $machine = Resolve-DncSource -RegPath $RegPath -JsonPath $JsonPath } catch { $machine = $null }
|
||||
}
|
||||
if ($machine -and $machine.machinenumber) {
|
||||
$controllerVendor = Resolve-ControllerVendor -ControlType $machine.controltype -NcIF $machine.ncif
|
||||
$transport = if ($machine.tcpaddress) { 'ethernet' } elseif ($machine.comport) { 'serial' } else { 'unknown' }
|
||||
$noteBits = @("DNC=$($machine.dnctype)")
|
||||
if ($machine.controltype) { $noteBits += "control=$($machine.controltype)" }
|
||||
$noteBits += "transport=$transport"
|
||||
$machineNote = 'eDNC collected ' + $collectedAt + '; ' + ($noteBits -join '; ')
|
||||
|
||||
if ($controllerVendor) { $vendorRows += [pscustomobject]@{ vendor = $controllerVendor } }
|
||||
$assetRows += [pscustomobject]@{
|
||||
assetnumber = $machine.machinenumber; assettypeid = 'Machine'; name = $machine.machinenumber; statusid = $null
|
||||
serialnumber = $null; gaugelabreference = $null; maintenancereference = $null
|
||||
locationid = $null; businessunitid = $null; notes = $machineNote
|
||||
}
|
||||
$machineRows += [pscustomobject]@{
|
||||
assetid = $machine.machinenumber; machinetypeid = $MachineType; vendorid = $null; modelnumberid = $null
|
||||
controllervendorid = $controllerVendor; controllermodelid = $null
|
||||
requiresmanualconfig = $null; islocationonly = $null; notes = $machineNote
|
||||
}
|
||||
|
||||
$link = [ordered]@{
|
||||
pcassetnumber = $AssetNumber; machineassetnumber = $machine.machinenumber; relationship = 'controls'
|
||||
dnctype = $machine.dnctype; controltype = $machine.controltype; controllervendor = $controllerVendor
|
||||
site = $machine.site; source = $machine.source; collectedat = $collectedAt
|
||||
connection = [ordered]@{
|
||||
transport = $transport; tcpaddress = $machine.tcpaddress; tcpport = $machine.tcpport
|
||||
comport = $machine.comport; baudrate = $machine.baudrate
|
||||
}
|
||||
}
|
||||
Write-Utf8NoBom -Path (Join-Path $sidecarDir ('machine_' + $machine.machinenumber + '.json')) -Content ($link | ConvertTo-Json -Depth 6)
|
||||
Write-Host (' linked machine ' + $machine.machinenumber + ' (' + $controllerVendor + ', ' + $transport + ')') -ForegroundColor DarkGray
|
||||
} elseif (-not $SkipMachine) {
|
||||
Write-Host ' no eDNC/DNC/UDC machine config found on this PC' -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# --- Upsert into the shared store --------------------------------------------
|
||||
|
||||
$rowsByStore = @{
|
||||
'vendors.csv' = $vendorRows
|
||||
'models.csv' = $modelRows
|
||||
'operatingsystems.csv' = $osRows
|
||||
'assets.csv' = $assetRows
|
||||
'computers.csv' = $computerRows
|
||||
'machines.csv' = $machineRows
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host (' Store: ' + $OutputRoot) -ForegroundColor Green
|
||||
foreach ($fileName in $script:Stores.Keys) {
|
||||
$spec = $script:Stores[$fileName]
|
||||
$rows = @($rowsByStore[$fileName])
|
||||
if ($rows.Count -eq 0) { continue }
|
||||
$path = Join-Path $OutputRoot $fileName
|
||||
$n = Merge-CsvStore -Path $path -Header $spec.header -Key $spec.key -IncomingRows $rows
|
||||
Write-Host (' merged ' + $n + ' row(s) into ' + $fileName) -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# PC phase-2 sidecar.
|
||||
$pcSidecar = [ordered]@{
|
||||
assetnumber = $AssetNumber; hostname = $pc.hostname; collectedat = $collectedAt
|
||||
interfaces = $pc.interfaces; installedapps = $pc.installedapps
|
||||
}
|
||||
Write-Utf8NoBom -Path (Join-Path $sidecarDir ('computer_' + $AssetNumber + '.json')) -Content ($pcSidecar | ConvertTo-Json -Depth 6)
|
||||
|
||||
# --- Publish (standard-user network mirror, optional) -------------------------
|
||||
|
||||
if ($PublishRoot) {
|
||||
if (([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
|
||||
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
Write-Host ''
|
||||
Write-Host ' WARNING: publishing while elevated. Network writes should run as the standard user.' -ForegroundColor Yellow
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $PublishRoot)) { New-Item -ItemType Directory -Path $PublishRoot -Force | Out-Null }
|
||||
|
||||
# Merge THIS PC's rows into the shared store rather than overwriting it. A
|
||||
# blind copy would erase every other PC's rows (issue: the store is meant to
|
||||
# accumulate site-wide). Merge-CsvStore upserts field-by-field, so a second
|
||||
# PC's run adds its rows and refreshes shared ones without dropping the rest.
|
||||
# A lock file serializes concurrent publishers so two PCs cannot read-modify-
|
||||
# write the same store CSV at once and lose each other's rows.
|
||||
$lockPath = Join-Path $PublishRoot '.publish.lock'
|
||||
$lockStream = $null
|
||||
for ($attempt = 0; $attempt -lt 60; $attempt++) {
|
||||
try {
|
||||
$lockStream = [System.IO.File]::Open(
|
||||
$lockPath, [System.IO.FileMode]::OpenOrCreate,
|
||||
[System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
|
||||
break
|
||||
} catch {
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
}
|
||||
if ($null -eq $lockStream) {
|
||||
Write-Host ''
|
||||
Write-Host (' WARNING: could not acquire publish lock at ' + $lockPath + '; skipping network merge.') -ForegroundColor Yellow
|
||||
} else {
|
||||
try {
|
||||
foreach ($fileName in $script:Stores.Keys) {
|
||||
$spec = $script:Stores[$fileName]
|
||||
$localPath = Join-Path $OutputRoot $fileName
|
||||
if (-not (Test-Path -LiteralPath $localPath)) { continue }
|
||||
$localRows = @(Import-Csv -LiteralPath $localPath)
|
||||
if ($localRows.Count -eq 0) { continue }
|
||||
$sharePath = Join-Path $PublishRoot $fileName
|
||||
$n = Merge-CsvStore -Path $sharePath -Header $spec.header -Key $spec.key -IncomingRows $localRows
|
||||
Write-Host (' merged ' + $n + ' row(s) into ' + $fileName + ' on share') -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# Sidecars are uniquely named per asset, so a straight copy is safe and
|
||||
# nothing is overwritten across PCs.
|
||||
$localSidecars = Join-Path $OutputRoot 'sidecars'
|
||||
if (Test-Path -LiteralPath $localSidecars) {
|
||||
$shareSidecars = Join-Path $PublishRoot 'sidecars'
|
||||
if (-not (Test-Path -LiteralPath $shareSidecars)) { New-Item -ItemType Directory -Path $shareSidecars -Force | Out-Null }
|
||||
Copy-Item -Path (Join-Path $localSidecars '*') -Destination $shareSidecars -Recurse -Force
|
||||
}
|
||||
} finally {
|
||||
$lockStream.Close()
|
||||
$lockStream.Dispose()
|
||||
Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Host ''
|
||||
Write-Host (' Published store to: ' + $PublishRoot) -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host ' Done. Dry-run validate the accumulated store with:' -ForegroundColor Cyan
|
||||
Write-Host (' flask csv import --dir "' + $OutputRoot + '"') -ForegroundColor White
|
||||
Write-Host ''
|
||||
91
scripts/site_imports/assetcollector/overview.md
Normal file
91
scripts/site_imports/assetcollector/overview.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Collect-ShopDB.ps1 Overview
|
||||
|
||||
## Overview
|
||||
|
||||
- **Purpose**: A unified, read-only "site adoption" collector that runs on any
|
||||
Windows PC, gathers facts about that PC plus any attached eDNC/DNC/UDC machine,
|
||||
and merges them into a shared set of importable CSVs so a site can build its
|
||||
ShopDB inventory one PC at a time.
|
||||
- **Compatibility**: PowerShell 5.1+; CSV headers are aligned to the ShopDB
|
||||
importer (`flask csv import --dir`).
|
||||
|
||||
## Configuration and destinations
|
||||
|
||||
- **Two-stage flow**: a fast local stage folder (`%ProgramData%\ShopDB\intake`)
|
||||
where CSVs are built/merged, and an optional network store (a file share) that
|
||||
the whole site accumulates into.
|
||||
- Command-line params `-OutputRoot` and `-PublishRoot` override the in-script
|
||||
CONFIG block.
|
||||
- Other params: `-AssetNumber` (defaults to hostname),
|
||||
`-AssetType`/`-ComputerType`/`-MachineType` (resolved by name), explicit
|
||||
`-RegPath`/`-JsonPath` machine source overrides, and `-SkipMachine`/`-SkipApps`
|
||||
switches for partial runs.
|
||||
|
||||
## Data model (the stores)
|
||||
|
||||
- Six importable CSVs, each with a defined header order and natural key:
|
||||
`vendors.csv`, `models.csv`, `operatingsystems.csv`, `assets.csv`,
|
||||
`computers.csv`, `machines.csv`.
|
||||
- Column names follow ShopDB conventions (e.g., `assetid`, `modelnumberid`,
|
||||
`controllervendorid`, `lastreporteddate`).
|
||||
|
||||
## PC collection (`Get-PcFacts`, read-only)
|
||||
|
||||
- Uses CIM/WMI (`Win32_ComputerSystem`, `Win32_OperatingSystem`, `Win32_BIOS`)
|
||||
to gather hostname, vendor/manufacturer, model, OS caption, serial number,
|
||||
last boot time, and logged-in user.
|
||||
- Enumerates **network interfaces** (hardware adapters only: alias, IPv4, MAC).
|
||||
- Enumerates **installed applications** from the registry Uninstall keys (name,
|
||||
version, publisher), skipping system components - unless `-SkipApps` is set.
|
||||
|
||||
## Machine (eDNC/DNC/UDC) collection
|
||||
|
||||
- `Resolve-DncSource` searches in priority order: explicit JSON path -> explicit
|
||||
.reg path -> live registry (`HKLM\...\GE Aircraft Engines\DNC`) -> local `.reg`
|
||||
export files -> local `udc_settings_*.json` files.
|
||||
- Three parsers normalize the different source formats into one shape:
|
||||
`ConvertFrom-RegExport` (parses exported `.reg` files including dword decoding),
|
||||
`ConvertFrom-LiveRegistry`, and `ConvertFrom-UdcJson`.
|
||||
- Extracts machine number, site, control type, DNC type, and connection detail
|
||||
(TCP address/port for ethernet, COM port/baud for serial).
|
||||
- `Resolve-ControllerVendor` maps control type / NcIF to a controller vendor
|
||||
(e.g., eFOCAS -> FANUC, Siemens, Okuma).
|
||||
|
||||
## Merge / upsert logic (`Merge-CsvStore`)
|
||||
|
||||
- Field-level UPSERT keyed on each table's natural key: existing rows are matched
|
||||
case-insensitively, and empty incoming cells never overwrite previously captured
|
||||
values - so partial or repeated runs fill gaps without wiping earlier data.
|
||||
- CSV writing is UTF-8 without BOM, with proper quoting/escaping.
|
||||
|
||||
## Sidecar files (phase-2 data CSV can't carry)
|
||||
|
||||
- Per-PC `sidecars\computer_<assetnumber>.json`: interfaces + installed apps.
|
||||
- Per-machine `sidecars\machine_<machinenumber>.json`: PC-to-machine link
|
||||
(relationship "controls"), DNC/control details, and connection transport info.
|
||||
|
||||
## Publishing and output
|
||||
|
||||
- Optionally mirrors the whole local store to the network share
|
||||
(`Copy-Item -Recurse -Force`), with a warning if run elevated (network writes
|
||||
should run as standard user).
|
||||
- Prints a summary and the follow-up dry-run command:
|
||||
`flask csv import --dir "<OutputRoot>"`.
|
||||
|
||||
---
|
||||
|
||||
## Relevance to an "Asset Collector" package
|
||||
|
||||
This script is a solid template for one collector in a broader package. Notable
|
||||
patterns worth generalizing across other collectors (existing databases,
|
||||
Intune/Azure exports, etc.):
|
||||
|
||||
- **Normalized intermediate schema** (the six CSV stores + JSON sidecars) that
|
||||
all sources converge on, decoupling collection from import.
|
||||
- **Idempotent field-level merge** so multiple heterogeneous sources can
|
||||
contribute to the same asset without clobbering each other.
|
||||
- **Source-priority resolution** and per-format parsers - the same shape could
|
||||
wrap an Intune/Azure `Get-MgDevice` export or a SQL query.
|
||||
- **Machines, PCs, Applications** are already modeled here; an Intune/Azure
|
||||
source would map naturally onto `computers.csv` + the installed-apps sidecar,
|
||||
while a MES/CNC database could feed `machines.csv`.
|
||||
5
scripts/site_imports/assetcollector/sample/assets.csv
Normal file
5
scripts/site_imports/assetcollector/sample/assets.csv
Normal file
@@ -0,0 +1,5 @@
|
||||
assetnumber,name,serialnumber,assettypeid,statusid,locationid,businessunitid,notes
|
||||
SAMPLE-PC-01,Shopfloor PC 01,DELLSN0001,computer,In Use,A1,Gage Lab,Sample collected device
|
||||
SAMPLE-PC-02,CMM PC 02,DELLSN0002,computer,In Use,A1,Gage Lab,Sample collected device
|
||||
SAMPLE-MC-01,Zeiss CMM 01,ZEISSSN0001,machine,In Use,A1,Gage Lab,Sample collected device attached to SAMPLE-PC-02
|
||||
SAMPLE-MC-02,Haas VF-2SS 02,HAASSN0002,machine,In Use,A1,Gage Lab,Sample collected standalone CNC
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user