From 27b2817fd7209b405d7e45d5c6c647b19e8d85be Mon Sep 17 00:00:00 2001 From: cproudlock Date: Wed, 2 Sep 2026 10:05:44 -0400 Subject: [PATCH] Import the wilm endpoint-monitor and channel-distribution work Everything on origin/wilm that is not on the published main, applied as one change because the published history has rewritten hashes: per-commit cherry-pick conflicts against our main even though the squashed diff applies clean. Contents: endpoint monitor catalog + policy attachment + warn-only drift reconciler, operator issue reports from the tray, agent launch-path allowlist, and the GHCR channel-image distribution path for dev workstations. NOT READY FOR main. Open items: - 10 new routes are absent from docs/api-inventory.json, so test_api_inventory_parity fails. That gate SKIPS on the published mirror (the test ships, its data file is scrubbed), which is why wilm never saw it. - dev-bootstrap.ps1 and Start-ShopDBDev.ps1 hardcode the ge-aero GitHub org. - plugins/endpoints/plugin.py carries a whitespace-only re-indent. Scope: product --- .github/workflows/publish-images.yml | 81 ++++ .gitignore | 4 + Dockerfile | 38 +- agent/ShopDbEndpointAgent.Core/AgentConfig.cs | 58 ++- .../AgentFaultMonitor.cs | 10 +- .../CollectorClient.cs | 83 ++++ .../ShopDbEndpointAgent.Core/CommandClient.cs | 16 +- .../CommandExecutor.cs | 68 ++- agent/ShopDbEndpointAgent.Core/Contracts.cs | 35 ++ .../LaunchPathPolicy.cs | 123 ++++++ .../OperatorReportPayload.cs | 52 +++ .../TelemetryWorker.cs | 15 + agent/ShopDbEndpointAgent.Tray/Program.cs | 32 +- .../ShopDbEndpointAgent.Tray/ScreenCapture.cs | 34 ++ agent/ShopDbEndpointAgent.Tray/TicketForm.cs | 50 +-- agent/ShopDbEndpointAgent.Tray/TrayContext.cs | 136 +++++- agent/ShopDbEndpointAgent.Tray/TrayIcon.cs | 16 + agent/ShopDbEndpointAgent/Program.cs | 62 +++ .../ShopDbEndpointAgent.csproj | 35 +- agent/ShopDbEndpointAgent/appsettings.json | 16 +- docker-compose.airgap.yml | 4 + docker-compose.channel.yml | 27 ++ docker-compose.pull.yml | 92 ++++ docker-compose.yml | 5 + docs/DEV-BUILD-TEST.md | 45 ++ docs/ShopDB-Planner-Tasks-v2.csv | 0 frontend/src/api/index.js | 19 + frontend/src/assets/style.css | 27 +- frontend/src/components/PluginAssetPanels.vue | 275 +++++++++++- frontend/src/components/pluginAssetPanels.js | 2 + frontend/src/utils/siteSettings.js | 8 + frontend/src/views/AppLayout.vue | 22 +- plugins/computers/api/policy_routes.py | 38 +- plugins/computers/frontend/routes.js | 4 +- .../computers/frontend/views/ActionPicker.vue | 265 ++++++++++++ .../frontend/views/BusinessPoliciesList.vue | 12 +- .../views/ConfigurationPoliciesList.vue | 137 +++++- plugins/computers/models/__init__.py | 2 + plugins/computers/models/policy.py | 6 + plugins/computers/plugin.py | 6 +- plugins/computers/services/policyresolver.py | 58 +++ plugins/endpoints/api/__init__.py | 2 + plugins/endpoints/api/monitors.py | 204 +++++++++ plugins/endpoints/api/routes.py | 408 +++++++++++++++++- plugins/endpoints/api/siteagent.py | 140 +++++- plugins/endpoints/commands.py | 68 +++ plugins/endpoints/frontend/routes.js | 6 + .../frontend/views/AgentKeySettings.vue | 21 +- .../frontend/views/MonitorsSettings.vue | 232 ++++++++++ .../versions/0006_endpoint_monitors.py | 151 +++++++ .../versions/0007_shipped_monitor_policy.py | 234 ++++++++++ .../versions/0008_endpoint_drift.py | 51 +++ .../versions/0009_operator_reports.py | 63 +++ plugins/endpoints/models/__init__.py | 3 + plugins/endpoints/models/endpoint.py | 7 + plugins/endpoints/models/monitor.py | 87 ++++ plugins/endpoints/models/operatorreport.py | 82 ++++ plugins/endpoints/plugin.py | 63 ++- plugins/endpoints/services/__init__.py | 0 plugins/endpoints/services/reconciler.py | 153 +++++++ scripts/Start-ShopDBDev.ps1 | 151 +++++++ scripts/build-channel-installer.ps1 | 346 +++++++++++++++ scripts/check-naming-and-style.sh | 2 + scripts/dev-bootstrap.ps1 | 344 +++++++++++++++ scripts/sim_policy_monitor_e2e.py | 335 ++++++++++++++ scripts/uninstall-shopdbagent.ps1 | 9 + shopdb/core/api/settings.py | 48 ++- shopdb/plugins/alembic_template.py | 2 +- tests/test_core/test_authz.py | 10 +- tests/test_core/test_public_read_surface.py | 8 + tests/test_plugin_migrations.py | 9 +- .../test_endpoints_operator_reports.py | 237 ++++++++++ tests/test_plugins/test_endpoints_plugin.py | 31 +- .../test_endpoints_policy_monitors.py | 199 +++++++++ .../test_plugins/test_endpoints_reconciler.py | 238 ++++++++++ .../test_endpoints_shipped_monitor_policy.py | 141 ++++++ 76 files changed, 5930 insertions(+), 173 deletions(-) create mode 100644 .github/workflows/publish-images.yml create mode 100644 agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs create mode 100644 agent/ShopDbEndpointAgent.Core/OperatorReportPayload.cs create mode 100644 agent/ShopDbEndpointAgent.Tray/ScreenCapture.cs create mode 100644 docker-compose.channel.yml create mode 100644 docker-compose.pull.yml create mode 100644 docs/DEV-BUILD-TEST.md create mode 100644 docs/ShopDB-Planner-Tasks-v2.csv create mode 100644 plugins/computers/frontend/views/ActionPicker.vue create mode 100644 plugins/endpoints/api/monitors.py create mode 100644 plugins/endpoints/frontend/views/MonitorsSettings.vue create mode 100644 plugins/endpoints/migrations/versions/0006_endpoint_monitors.py create mode 100644 plugins/endpoints/migrations/versions/0007_shipped_monitor_policy.py create mode 100644 plugins/endpoints/migrations/versions/0008_endpoint_drift.py create mode 100644 plugins/endpoints/migrations/versions/0009_operator_reports.py create mode 100644 plugins/endpoints/models/monitor.py create mode 100644 plugins/endpoints/models/operatorreport.py create mode 100644 plugins/endpoints/services/__init__.py create mode 100644 plugins/endpoints/services/reconciler.py create mode 100644 scripts/Start-ShopDBDev.ps1 create mode 100644 scripts/build-channel-installer.ps1 create mode 100644 scripts/dev-bootstrap.ps1 create mode 100644 scripts/sim_policy_monitor_e2e.py create mode 100644 tests/test_plugins/test_endpoints_operator_reports.py create mode 100644 tests/test_plugins/test_endpoints_policy_monitors.py create mode 100644 tests/test_plugins/test_endpoints_reconciler.py create mode 100644 tests/test_plugins/test_endpoints_shipped_monitor_policy.py diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml new file mode 100644 index 00000000..16bea88a --- /dev/null +++ b/.github/workflows/publish-images.yml @@ -0,0 +1,81 @@ +# Build and publish per-channel container images to GHCR. +# +# WHY this exists: pilot dev workstations sit behind an inspecting proxy +# (Zscaler) that resets npm's many small fetches mid-install, so building the +# image FROM SOURCE on a pilot box is unreliable. These runners +# (arc-runner-set) install npm cleanly - the CI `frontend` job already proves +# it - so we build the image ONCE here and pilots pull the finished image. +# A single image pull is one resumable HTTPS stream, far more robust through +# the proxy than hundreds of npm requests. +# +# Channels map to branches: +# main -> ghcr.io//shopdb-flask:stable (badge off) +# dev-be -> ghcr.io//shopdb-flask:edge (SHOPDB_ENV_LABEL=DEV badge) +# +# Each push to those branches rebuilds and republishes its channel tag, so +# `edge` stays genuinely bleeding edge. The image is also tagged with the short +# commit SHA for traceability and rollback. + +name: publish-images + +on: + push: + branches: + - main + - dev-be + # Allow a manual rebuild/publish of the current branch's channel image. + workflow_dispatch: + +# One in-flight publish per branch; a newer push supersedes an older one. +concurrency: + group: publish-images-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-push: + runs-on: arc-runner-set + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v5 + + # Lowercase the owner: GHCR repository paths must be lowercase, but + # github.repository_owner can carry uppercase (e.g. ge-aero is fine, but + # this keeps the workflow correct for any owner casing). + - name: Resolve image name and channel tag + id: meta + run: | + owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" + image="ghcr.io/${owner}/shopdb-flask" + if [ "${{ github.ref_name }}" = "main" ]; then + channel="stable" + else + channel="edge" + fi + echo "image=${image}" >> "$GITHUB_OUTPUT" + echo "channel=${channel}" >> "$GITHUB_OUTPUT" + echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push channel image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.channel }} + ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.channel }}-${{ steps.meta.outputs.sha }} + # Reuse the channel tag as a cache source/target so subsequent builds + # only redo layers that actually changed. + cache-from: type=registry,ref=${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.channel }} + cache-to: type=inline diff --git a/.gitignore b/.gitignore index f2e77ab6..31591d72 100644 --- a/.gitignore +++ b/.gitignore @@ -132,9 +132,13 @@ docker_build.txt docker_up.txt gates_out.txt pytest_targeted.txt +pt*.txt alandry.py kendry.py +# Visual Studio local editor state (not product source). +.vs/ + # Scratch tooling under scripts/ (catalog diff/merge helpers, docx generator). # Not product source; developer-local utilities. scripts/.temp/ diff --git a/Dockerfile b/Dockerfile index df0156d7..91904fdb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,11 +38,40 @@ RUN if [ -f /tmp/corpca/corp-root-ca.pem ]; then \ 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 +# Trust the corp CA for npm's TLS only when it was actually installed above. The +# guard keeps NODE_EXTRA_CA_CERTS unset on a clean checkout (no cert), so Node +# does not warn about a missing cert path; a proxy-inspected build still gets +# the cert because the file is present. +# Install the frontend dependencies. vite and the rest of the build toolchain +# live under devDependencies, so include them explicitly (--include=dev). +# Behind an inspecting proxy (Zscaler) each package fetch is slow (40-50s) and +# npm's default concurrency piles up parallel sockets that the proxy resets +# mid-stream, making npm abort with "Exit handler never called" and leaving an +# incomplete node_modules with no vite. Serializing to a single socket +# (maxsockets 1) lets every fetch complete; the retry loop and raised timeouts +# ride out any remaining transient reset, and the final test hard-verifies the +# toolchain landed so a truncated install fails loudly here rather than as a +# confusing "vite: not found" three steps later. +RUN if [ -f /usr/local/share/ca-certificates/corp-root-ca.crt ]; then \ + export NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/corp-root-ca.crt; \ + fi; \ + cd frontend && \ + npm config set fetch-retries 5 && \ + npm config set fetch-retry-factor 2 && \ + npm config set fetch-retry-mintimeout 20000 && \ + npm config set fetch-retry-maxtimeout 120000 && \ + npm config set fetch-timeout 600000 && \ + npm config set maxsockets 1 && \ + ( npm ci --include=dev --no-audit --no-fund \ + || { echo "npm ci attempt 1 failed, retrying..."; sleep 5; npm ci --include=dev --no-audit --no-fund; } \ + || { echo "npm ci attempt 2 failed, retrying..."; sleep 10; npm ci --include=dev --no-audit --no-fund; } ) && \ + test -x node_modules/.bin/vite || { \ + echo "ERROR: node_modules/.bin/vite missing after npm ci (proxy likely truncated the install)."; \ + exit 1; \ + } # The frontend keeps its repo-relative layout here, because npm prebuild runs # `node ../scripts/stage-frontend.mjs`, which stages each plugin's frontend/ into @@ -54,7 +83,10 @@ COPY scripts/stage-frontend.mjs ./scripts/ COPY plugins/ ./plugins/ COPY frontend/ ./frontend/ -RUN cd frontend && npm run build +RUN if [ -f /usr/local/share/ca-certificates/corp-root-ca.crt ]; then \ + export NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/corp-root-ca.crt; \ + fi; \ + cd frontend && npm run build # Output lands in /build/frontend/dist (Vite default), copied into the final # stage below. diff --git a/agent/ShopDbEndpointAgent.Core/AgentConfig.cs b/agent/ShopDbEndpointAgent.Core/AgentConfig.cs index 86672779..991d857e 100644 --- a/agent/ShopDbEndpointAgent.Core/AgentConfig.cs +++ b/agent/ShopDbEndpointAgent.Core/AgentConfig.cs @@ -34,14 +34,23 @@ public sealed class AgentConfig [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 + // historically never carried a path; now a server-provisioned monitor MAY + // carry one, but the agent only accepts it after LaunchPathPolicy validates + // it against ApprovedLaunchRoots (see below). Start otherwise 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 LaunchPaths { get; set; } = new(); + // Approved root directories a server-provisioned launch path must sit under + // before the agent will honor it. Empty = fall back to LaunchPathPolicy's + // shipped org defaults (Program Files roots). A site (or the future Site + // Agent Configuration tool) can widen or narrow this without a rebuild. + [JsonPropertyName("approvedlaunchroots")] + public List ApprovedLaunchRoots { 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 @@ -162,6 +171,42 @@ public sealed class MonitorConfig [JsonPropertyName("services")] public MonitorTarget[] Services { get; set; } = Array.Empty(); + + // Union the local (appsettings.json) watch list with the server-resolved one, + // keyed case-insensitively by wire name so a target watched both locally and + // by policy is not enumerated twice. Server entries win on the label (App) so + // a policy display name overrides a stale local one, but a local-only target + // is preserved - the server list augments, it does not replace. Nulls on + // either side degrade to the other, which is what keeps monitoring alive when + // a fetch fails. + public static MonitorConfig Merge(MonitorConfig? local, ResolvedMonitors? server) + { + return new MonitorConfig + { + Processes = MergeTargets(local?.Processes, server?.Processes), + Services = MergeTargets(local?.Services, server?.Services), + }; + } + + private static MonitorTarget[] MergeTargets( + MonitorTarget[]? local, MonitorTarget[]? server) + { + var byName = new Dictionary( + StringComparer.OrdinalIgnoreCase); + foreach (var target in local ?? Array.Empty()) + { + if (string.IsNullOrWhiteSpace(target?.Name)) + continue; + byName[target.Name.Trim()] = target; + } + foreach (var target in server ?? Array.Empty()) + { + if (string.IsNullOrWhiteSpace(target?.Name)) + continue; + byName[target.Name.Trim()] = target; + } + return byName.Values.ToArray(); + } } public sealed class MonitorTarget @@ -172,4 +217,13 @@ public sealed class MonitorTarget // Human-facing application label, optional. Falls back to Name when empty. [JsonPropertyName("app")] public string App { get; set; } = ""; + + // Optional server-provisioned launch path for a process monitor. Only set on + // process targets that a site leader configured a path for. The agent NEVER + // launches this blindly: LaunchPathPolicy validates it against an approved- + // root allowlist first, and a local appsettings.json launchpaths entry for + // the same image still wins. Null/empty means "no server path" - the agent + // falls back to its local launch map, and process_start fails closed when + // neither yields a validated path. + [JsonPropertyName("launchpath")] public string? LaunchPath { get; set; } } diff --git a/agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs b/agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs index a46e98df..9db8776a 100644 --- a/agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs +++ b/agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs @@ -34,7 +34,15 @@ public sealed class AgentFaultMonitor } public void ReportFatal(string context, Exception error) - => Report(EventLogEntryType.Error, 1000, $"{context}: {error}"); + { + // File log first, always: the Event Log sink below is admin-gated (the + // source cannot be created without elevation), so an unelevated run such + // as the tray-launched --once/--report path would otherwise fail with no + // trace anywhere. The rolling file log is the one sink guaranteed to work + // in every context. + logger.LogError(error, "Fatal in {Context}", context); + Report(EventLogEntryType.Error, 1000, $"{context}: {error}"); + } private void OnUnhandledException(object sender, UnhandledExceptionEventArgs args) { diff --git a/agent/ShopDbEndpointAgent.Core/CollectorClient.cs b/agent/ShopDbEndpointAgent.Core/CollectorClient.cs index 4c4d30d9..6f119139 100644 --- a/agent/ShopDbEndpointAgent.Core/CollectorClient.cs +++ b/agent/ShopDbEndpointAgent.Core/CollectorClient.cs @@ -89,6 +89,42 @@ public sealed class CollectorClient : IDisposable return envelope.Data; } + // Fetch the server-resolved watch list (policy-attached processes/services) + // for this hostname, GET /api/endpoints/monitors/resolved. Shares the same + // X-API-Key credential as telemetry. Best-effort: returns null on any + // failure so the caller falls back to the last-known/local list rather than + // blanking monitoring on a transient blip. A single attempt, no retry - the + // next telemetry cycle retries naturally, and a stale-but-usable watch list + // beats an empty one. + public async Task FetchResolvedMonitorsAsync( + string hostname, CancellationToken token) + { + var url = "api/endpoints/monitors/resolved?hostname=" + + Uri.EscapeDataString(hostname); + try + { + using var response = await http.GetAsync(url, token); + var body = await response.Content.ReadAsStringAsync(token); + if (!response.IsSuccessStatusCode) + { + logger.LogWarning( + "Resolved-monitors fetch returned HTTP {Status}: {Body}", + (int)response.StatusCode, body); + return null; + } + var envelope = JsonSerializer.Deserialize( + body, JsonOptions); + return envelope?.Data; + } + catch (Exception error) when ( + error is HttpRequestException or TaskCanceledException or JsonException + && !token.IsCancellationRequested) + { + logger.LogWarning(error, "Resolved-monitors fetch failed; keeping current watch list"); + return null; + } + } + private TimeSpan BackoffDelay(int attempt) { // 2^(attempt-1) seconds, capped, plus up to 1s jitter. @@ -96,6 +132,53 @@ public sealed class CollectorClient : IDisposable return TimeSpan.FromSeconds(seconds + jitter.NextDouble()); } + // Uploads one operator issue report (free-text reason/details plus an + // optional PNG screenshot) to POST /api/endpoints/operatorreports as + // multipart/form-data, authorized by the same X-API-Key collector key as + // telemetry. The server resolves this hostname to its asset. A single + // attempt: the tray shows the operator the outcome and can retry by hand, + // and a stuck retry loop holding a screenshot in memory is worse than a + // clear failure. Returns the parsed upload response (new row id) or throws. + public async Task UploadOperatorReportAsync( + string reason, string details, string? screenshotPath, string hostname, + CancellationToken token) + { + using var form = new MultipartFormDataContent(); + form.Add(new StringContent(reason ?? string.Empty), "reason"); + form.Add(new StringContent(details ?? string.Empty), "details"); + form.Add(new StringContent(hostname ?? string.Empty), "hostname"); + + StreamContent? imageContent = null; + FileStream? imageStream = null; + try + { + if (!string.IsNullOrWhiteSpace(screenshotPath) && File.Exists(screenshotPath)) + { + imageStream = File.OpenRead(screenshotPath); + imageContent = new StreamContent(imageStream); + imageContent.Headers.ContentType = + new System.Net.Http.Headers.MediaTypeHeaderValue("image/png"); + form.Add(imageContent, "screenshot", + Path.GetFileName(screenshotPath)); + } + + using var response = await http.PostAsync( + "api/endpoints/operatorreports", form, token); + var body = await response.Content.ReadAsStringAsync(token); + if (!response.IsSuccessStatusCode) + throw new CollectorException((int)response.StatusCode, body); + + var envelope = JsonSerializer.Deserialize( + body, JsonOptions); + return envelope?.Data; + } + finally + { + imageContent?.Dispose(); + imageStream?.Dispose(); + } + } + public void Dispose() => http.Dispose(); } diff --git a/agent/ShopDbEndpointAgent.Core/CommandClient.cs b/agent/ShopDbEndpointAgent.Core/CommandClient.cs index 0dcc395c..f1ad6d6e 100644 --- a/agent/ShopDbEndpointAgent.Core/CommandClient.cs +++ b/agent/ShopDbEndpointAgent.Core/CommandClient.cs @@ -22,12 +22,26 @@ public sealed class CommandClient : IDisposable }; private readonly HttpClient http; + private readonly SocketsHttpHandler handler; private readonly ILogger logger; public CommandClient(AgentConfig config, ILogger logger) { this.logger = logger; - http = new HttpClient + // The command channel long-polls: the server holds each claim open for a + // hold window and then, between windows, the pooled TLS connection sits + // idle. Gunicorn's keep-alive closes an idle socket after a couple of + // seconds, so a connection reused after that yields "unexpected EOF or 0 + // bytes from the transport stream" on the next TLS handshake. Retire + // pooled connections faster than the server's keep-alive so a socket the + // server has already dropped is never handed back out; the lifetime cap + // is a hard backstop so no connection lingers indefinitely. + handler = new SocketsHttpHandler + { + PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1), + PooledConnectionLifetime = TimeSpan.FromMinutes(2), + }; + http = new HttpClient(handler, disposeHandler: true) { BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"), // Longer than the collector client: the claim request is held open diff --git a/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs b/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs index 9ffae27c..7db7a198 100644 --- a/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs +++ b/agent/ShopDbEndpointAgent.Core/CommandExecutor.cs @@ -56,9 +56,14 @@ public sealed class CommandExecutor // 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. + // map - and without a validated server-provisioned path - cannot be started, + // so process_start fails closed. private readonly IReadOnlyDictionary launchPaths; + // Validates server-provisioned launch paths (from policy-attached monitors) + // before they are honored. Local launchPaths always win over a server path. + private readonly LaunchPathPolicy launchPathPolicy; + // Held for update_app: the agent joins InstallPath to BaseUrl and downloads // the pinned installer with its own collector credential (ApiKey), so the // installer body never rides the command wire. @@ -71,6 +76,7 @@ public sealed class CommandExecutor this.startTimeout = startTimeout ?? TimeSpan.FromSeconds(30); this.config = config ?? new AgentConfig(); this.launchPaths = this.config.ResolveLaunchPaths(); + this.launchPathPolicy = new LaunchPathPolicy(this.config.ApprovedLaunchRoots); } public async Task ExecuteAsync( @@ -366,8 +372,8 @@ public sealed class CommandExecutor if (bare.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) bare = bare[..^4]; - if (!launchPaths.TryGetValue(bare, out var launchPath) - || string.IsNullOrWhiteSpace(launchPath)) + var launchPath = ResolveLaunchPath(bare, image); + if (string.IsNullOrWhiteSpace(launchPath)) { logger.LogWarning( "Rejected process_start for {Image}: no launchable path configured", @@ -452,6 +458,62 @@ public sealed class CommandExecutor } } + // Resolves the launchable path for a process image. Precedence: + // 1. Local launch map (shipped defaults + appsettings.json). A site pin + // always wins so a server value never silently overrides it. + // 2. A server-provisioned path on the matching process monitor, but only + // after LaunchPathPolicy validates it (approved root, exists, launchable + // extension). A rejected server path is logged and dropped - the caller + // then fails closed exactly as before. + // Returns null when neither source yields a usable path. + private string? ResolveLaunchPath(string bareImage, string image) + { + if (launchPaths.TryGetValue(bareImage, out var localPath) + && !string.IsNullOrWhiteSpace(localPath)) + { + return localPath; + } + + var serverPath = ServerLaunchPathFor(image, bareImage); + if (string.IsNullOrWhiteSpace(serverPath)) + return null; + + if (launchPathPolicy.TryValidate(serverPath, out var validated, out var reason)) + return validated; + + logger.LogWarning( + "Rejected server-provisioned launch path for {Image} ({Path}): {Reason}", + image, serverPath, reason); + return null; + } + + // Finds the server-provisioned launch path carried on the process monitor + // whose wire name matches the image (case-insensitive, .exe optional). + private string? ServerLaunchPathFor(string image, string bareImage) + { + var processes = config.Monitors?.Processes; + if (processes is null) + return null; + + foreach (var target in processes) + { + if (string.IsNullOrWhiteSpace(target?.Name) + || string.IsNullOrWhiteSpace(target.LaunchPath)) + continue; + + var name = target.Name.Trim(); + var bareName = name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) + ? name[..^4] + : name; + if (string.Equals(name, image, StringComparison.OrdinalIgnoreCase) + || string.Equals(bareName, bareImage, StringComparison.OrdinalIgnoreCase)) + { + return target.LaunchPath; + } + } + return null; + } + private bool IsProcessRunning(string image) { var processes = FindProcessesByImage(image); diff --git a/agent/ShopDbEndpointAgent.Core/Contracts.cs b/agent/ShopDbEndpointAgent.Core/Contracts.cs index d4c92a0a..37a7f000 100644 --- a/agent/ShopDbEndpointAgent.Core/Contracts.cs +++ b/agent/ShopDbEndpointAgent.Core/Contracts.cs @@ -72,6 +72,20 @@ public sealed class CollectorEnvelope [JsonPropertyName("data")] public CollectorResponse? Data { get; set; } } +// Server response to an operator issue report upload. Carries the new row id so +// the agent can log it for correlation. +public sealed class OperatorReportUploadResponse +{ + [JsonPropertyName("operatorreportid")] public int? OperatorReportId { get; set; } + [JsonPropertyName("assetid")] public int? AssetId { get; set; } +} + +public sealed class OperatorReportUploadEnvelope +{ + [JsonPropertyName("status")] public string Status { get; set; } = ""; + [JsonPropertyName("data")] public OperatorReportUploadResponse? 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 - @@ -147,3 +161,24 @@ public sealed class CommandResult [JsonPropertyName("mtconnectrunning")] public bool? MtConnectRunning { get; set; } [JsonPropertyName("detail")] public Dictionary? Detail { get; set; } } + +// The server-resolved watch list for this endpoint (processes and services the +// site's policy attaches to the box's PC type), fetched each telemetry cycle so +// a policy-attached monitor is enumerated without editing appsettings.json on +// the box. Same {name, app} shape as the local MonitorConfig, so the merge is a +// direct list union. Empty lists mean "watch nothing extra beyond local". +public sealed class ResolvedMonitors +{ + [JsonPropertyName("processes")] public MonitorTarget[] Processes { get; set; } + = Array.Empty(); + + [JsonPropertyName("services")] public MonitorTarget[] Services { get; set; } + = Array.Empty(); +} + +// Envelope for the resolved-monitors route: { status, data: { processes, services } }. +public sealed class ResolvedMonitorsEnvelope +{ + [JsonPropertyName("status")] public string Status { get; set; } = ""; + [JsonPropertyName("data")] public ResolvedMonitors? Data { get; set; } +} diff --git a/agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs b/agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs new file mode 100644 index 00000000..4976dc38 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/LaunchPathPolicy.cs @@ -0,0 +1,123 @@ +using System.Text.Json.Serialization; + +namespace ShopDbEndpointAgent.Core; + +// Guards server-provisioned launch paths. Historically the command wire never +// carried a launch path at all - the agent mapped an image name to a known-good +// local path and anything else failed closed. Server-provisioned monitors now +// MAY carry a path a site leader set in the WebUI, which is a deliberate +// relaxation of that invariant. This policy is the replacement safeguard: a +// server path is only honored when it sits under an approved root, actually +// exists, and is an approved executable type. Everything else is rejected and +// the agent falls back to its local launch map (still fail closed). +// +// The approved-root list is org-opinionated with shipped defaults (the Windows +// Program Files roots), overridable per site via AgentConfig.ApprovedLaunchRoots +// so the future Site Agent Configuration tool can widen or narrow it without a +// rebuild. A local appsettings.json launchpaths entry still wins over any +// server path, so a site pin is never silently overridden. +public sealed class LaunchPathPolicy +{ + // Only these extensions are launchable. .bat is included because some shop- + // floor apps ship a launcher script (see the eDNC/UDC run.bat pattern). + private static readonly string[] ApprovedExtensions = + { ".exe", ".bat", ".cmd" }; + + // Shipped org defaults: the standard 64- and 32-bit install roots. Resolved + // from the environment so a non-C: system drive still matches. + public static IReadOnlyList DefaultApprovedRoots { get; } = + BuildDefaultRoots(); + + private readonly IReadOnlyList approvedRoots; + + public LaunchPathPolicy(IEnumerable? approvedRootsOverride = null) + { + var roots = (approvedRootsOverride ?? Enumerable.Empty()) + .Where(root => !string.IsNullOrWhiteSpace(root)) + .Select(NormalizeRoot) + .ToList(); + approvedRoots = roots.Count > 0 ? roots : DefaultApprovedRoots; + } + + // Result of validating a candidate path: the normalized full path when it is + // acceptable, or a human-readable reason when it is rejected. + public bool TryValidate(string? candidate, out string fullPath, out string reason) + { + fullPath = ""; + reason = ""; + + if (string.IsNullOrWhiteSpace(candidate)) + { + reason = "empty path"; + return false; + } + + string resolved; + try + { + resolved = Path.GetFullPath(candidate.Trim()); + } + catch (Exception ex) + { + reason = $"not a valid path: {ex.Message}"; + return false; + } + + var extension = Path.GetExtension(resolved); + if (!ApprovedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + { + reason = $"extension '{extension}' is not launchable"; + return false; + } + + if (!IsUnderApprovedRoot(resolved)) + { + reason = "path is outside every approved launch root"; + return false; + } + + if (!File.Exists(resolved)) + { + reason = "file does not exist"; + return false; + } + + fullPath = resolved; + return true; + } + + private bool IsUnderApprovedRoot(string resolved) + { + foreach (var root in approvedRoots) + { + // Compare against root + separator so "C:\Program Files" does not + // also match "C:\Program FilesEvil\...". Case-insensitive on Windows. + var prefix = root.EndsWith(Path.DirectorySeparatorChar) + ? root + : root + Path.DirectorySeparatorChar; + if (resolved.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + + private static string NormalizeRoot(string root) + { + var full = Path.GetFullPath(root.Trim()); + return full.TrimEnd(Path.DirectorySeparatorChar); + } + + private static IReadOnlyList BuildDefaultRoots() + { + var candidates = new[] + { + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + }; + return candidates + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(NormalizeRoot) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } +} diff --git a/agent/ShopDbEndpointAgent.Core/OperatorReportPayload.cs b/agent/ShopDbEndpointAgent.Core/OperatorReportPayload.cs new file mode 100644 index 00000000..0402a1b3 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/OperatorReportPayload.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ShopDbEndpointAgent.Core; + +// The on-disk hand-off between the interactive tray (which captures the +// operator's screen and notes) and the headless service binary (which holds the +// collector key and performs the upload). The tray writes this JSON sidecar to a +// per-run temp folder and invokes `shopdbagent.exe --report `; the agent +// reads it, uploads, and deletes the folder. Keeping the secret in the service +// preserves the "tray never holds a credential" rule already used by --once. +public sealed class OperatorReportPayload +{ + [JsonPropertyName("reason")] + public string Reason { get; set; } = "Other (free text)"; + + [JsonPropertyName("details")] + public string Details { get; set; } = string.Empty; + + // Absolute path to the captured PNG, or null when the operator declined a + // screenshot. Written by the tray into the interactive operator's + // %USERPROFILE%\ShopDBScreenshots folder and passed through as an absolute + // path; the agent reads it verbatim and never re-derives the location (as + // SYSTEM its own profile would resolve elsewhere). + [JsonPropertyName("screenshotpath")] + public string? ScreenshotPath { get; set; } + + private static readonly JsonSerializerOptions Options = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, + }; + + public static void Write(string filePath, string reason, string details, + string? screenshotPath) + { + var payload = new OperatorReportPayload + { + Reason = reason, + Details = details, + ScreenshotPath = screenshotPath, + }; + File.WriteAllText(filePath, JsonSerializer.Serialize(payload, Options)); + } + + public static OperatorReportPayload Read(string filePath) + { + var json = File.ReadAllText(filePath); + return JsonSerializer.Deserialize(json, Options) + ?? throw new InvalidDataException("empty or invalid report payload"); + } +} diff --git a/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs b/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs index 1fcd3412..2efa847d 100644 --- a/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs +++ b/agent/ShopDbEndpointAgent.Core/TelemetryWorker.cs @@ -96,6 +96,21 @@ public sealed class TelemetryWorker : BackgroundService // exact production collection path. public async Task RunOnceAsync(bool adopt, CancellationToken token) { + // Refresh the watch list from server policy BEFORE collecting so a + // policy-attached monitor (added in the WebUI, never in appsettings.json) + // is enumerated and reported this cycle. Server entries merge over the + // local list; a failed fetch returns null and Merge keeps the local list, + // so monitoring never blanks on a transient blip. + var resolved = await client.FetchResolvedMonitorsAsync( + Environment.MachineName, token); + if (resolved is not null) + { + config.Monitors = MonitorConfig.Merge(config.Monitors, resolved); + logger.LogDebug( + "Watch list refreshed from policy: {Processes} process(es), {Services} service(s)", + config.Monitors.Processes.Length, config.Monitors.Services.Length); + } + var report = collector.Collect(agentVersion, adopt); if (utilities is not null) diff --git a/agent/ShopDbEndpointAgent.Tray/Program.cs b/agent/ShopDbEndpointAgent.Tray/Program.cs index 80e0f0cd..4e8cdc0a 100644 --- a/agent/ShopDbEndpointAgent.Tray/Program.cs +++ b/agent/ShopDbEndpointAgent.Tray/Program.cs @@ -1,6 +1,8 @@ +using System.Threading; + namespace ShopDbEndpointAgent.Tray; -// Interactive user-session tray app (SCAFFOLD). Separate process from the +// Interactive user-session tray app (SCAFFOLD). // 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 @@ -8,9 +10,35 @@ namespace ShopDbEndpointAgent.Tray; // is deferred until that decision is made server-side. internal static class Program { + // Machine-wide single-instance guard. The installer starts the tray from an + // HKLM Run value for every user at logon, and an operator can also launch it + // from the Start Menu shortcut, so a second copy is easy to spawn. A named + // mutex in the Global namespace keeps exactly one tray per session: a later + // launch that cannot own the mutex exits immediately instead of stacking a + // duplicate icon. Global\ scopes across sessions on the same machine. + private const string SingleInstanceMutexName = @"Global\ShopDbEndpointAgentTray"; + [STAThread] - private static void Main() + private static void Main(string[] args) { + // Headless icon export for the installer: `--export-icon ` writes + // the branded .ico and exits without starting the tray, so install.ps1 + // can point the Start Menu shortcut at a real brand icon. Kept in this + // exe so the icon has a single source (TrayIcon) and no binary is + // checked into the repo. + if (args.Length >= 2 && + string.Equals(args[0], "--export-icon", StringComparison.OrdinalIgnoreCase)) + { + TrayIcon.Export(args[1]); + return; + } + + using var singleInstance = new Mutex(true, SingleInstanceMutexName, out bool owned); + if (!owned) + { + return; + } + ApplicationConfiguration.Initialize(); Application.Run(new TrayContext()); } diff --git a/agent/ShopDbEndpointAgent.Tray/ScreenCapture.cs b/agent/ShopDbEndpointAgent.Tray/ScreenCapture.cs new file mode 100644 index 00000000..675b628e --- /dev/null +++ b/agent/ShopDbEndpointAgent.Tray/ScreenCapture.cs @@ -0,0 +1,34 @@ +using System.Drawing.Imaging; + +namespace ShopDbEndpointAgent.Tray; + +// Captures the full virtual desktop (all monitors) to a PNG on disk, porting +// the operator PowerShell proof of concept: +// CopyFromScreen over SystemInformation.VirtualScreen, then Bitmap.Save as +// PNG. Kept in the tray project because only the interactive tray session can +// see the operator's desktop; the headless service runs in session 0 and +// would capture a black frame. +internal static class ScreenCapture +{ + // Grab every monitor in one shot. VirtualScreen spans the whole desktop so + // a multi-head shop-floor PC is captured intact rather than just the + // primary display. Returns the saved file path. + public static string CaptureToPng(string filePath) + { + var bounds = SystemInformation.VirtualScreen; + using var bitmap = new Bitmap(bounds.Width, bounds.Height, + PixelFormat.Format32bppArgb); + using (var graphics = Graphics.FromImage(bitmap)) + { + graphics.CopyFromScreen(bounds.Left, bounds.Top, 0, 0, + bounds.Size, CopyPixelOperation.SourceCopy); + } + + var directory = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + bitmap.Save(filePath, ImageFormat.Png); + return filePath; + } +} diff --git a/agent/ShopDbEndpointAgent.Tray/TicketForm.cs b/agent/ShopDbEndpointAgent.Tray/TicketForm.cs index 392418e2..f953dcc8 100644 --- a/agent/ShopDbEndpointAgent.Tray/TicketForm.cs +++ b/agent/ShopDbEndpointAgent.Tray/TicketForm.cs @@ -1,22 +1,20 @@ 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. +// The operator issue reporter. Collects a free-text description and an optional +// screenshot request, then hands the result back to TrayContext which performs +// the capture (after this dialog closes, so it never appears in the shot) and +// the upload via the service binary. +// +// Only "Other (free text)" is offered today: the fixed-reason routing from the +// original design is not wired to a ticketing backend, so a single free-text +// path is the one flow that works end to end. The dropdown is kept (disabled, +// preselected) so the richer reason list can return without reworking the form. internal sealed class TicketForm : Form { - // Fixed dropdown reasons from the source conversation (section 6). + // Only the free-text reason is functional. Kept as a one-item list so the + // control and its layout stay in place for the future reason routing. 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)", }; @@ -24,6 +22,11 @@ internal sealed class TicketForm : Form private readonly TextBox detailBox; private readonly CheckBox screenshotBox; + // Collected result, read by TrayContext after DialogResult.OK. + public string Reason => reasonBox.SelectedItem?.ToString() ?? "Other (free text)"; + public string Details => detailBox.Text?.Trim() ?? string.Empty; + public bool IncludeScreenshot => screenshotBox.Checked; + public TicketForm() { Text = "Report an issue"; @@ -46,13 +49,16 @@ internal sealed class TicketForm : Form Top = 40, Width = 388, DropDownStyle = ComboBoxStyle.DropDownList, + // Disabled until the fixed-reason routing exists; free text is the + // only functional path, so the selection is fixed on it. + Enabled = false, }; reasonBox.Items.AddRange(Reasons); reasonBox.SelectedIndex = 0; var detailLabel = new Label { - Text = "Details (optional)", + Text = "Details", Left = 16, Top = 76, Width = 388, @@ -72,6 +78,7 @@ internal sealed class TicketForm : Form Left = 16, Top = 188, Width = 388, + Checked = true, }; var submit = new Button @@ -82,7 +89,6 @@ internal sealed class TicketForm : Form Width = 76, DialogResult = DialogResult.OK, }; - submit.Click += OnSubmit; var cancel = new Button { @@ -101,18 +107,4 @@ internal sealed class TicketForm : Form 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); - } } diff --git a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs index 93827706..f6b80d72 100644 --- a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs +++ b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs @@ -1,4 +1,7 @@ using System.Diagnostics; +using System.Reflection; + +using ShopDbEndpointAgent.Core; namespace ShopDbEndpointAgent.Tray; @@ -44,10 +47,102 @@ internal sealed class TrayContext : ApplicationContext trayIcon.DoubleClick += OnSendStatus; } - private void OnReportIssue(object? sender, EventArgs e) + private async void OnReportIssue(object? sender, EventArgs e) { - using var dialog = new TicketForm(); - dialog.ShowDialog(); + string reason; + string details; + bool includeScreenshot; + using (var dialog = new TicketForm()) + { + if (dialog.ShowDialog() != DialogResult.OK) + return; + reason = dialog.Reason; + details = dialog.Details; + includeScreenshot = dialog.IncludeScreenshot; + } + + var agentExe = ResolveAgentExe(); + if (agentExe is null) + { + ShowBalloon("Report an issue", "Could not find " + AgentExeName + + ". Is the ShopDB Endpoint Agent installed?", ToolTipIcon.Error); + return; + } + + // Capture AFTER the dialog closes so the report form is never in the + // shot. Stage into the interactive operator's own profile + // (%USERPROFILE%\ShopDBScreenshots), NOT %TEMP%: on this locked-down + // image the tray process's TEMP resolves to a different account's + // AppData that ShopFloor cannot write, and AppData/Temp are aggressively + // ACL'd/redirected. The profile root is owned by the operator and, by + // inherited ACL, readable by SYSTEM (the agent service) so the handoff + // works. Files are kept for the operator to show a DT/specialist later; + // pruning is the agent's future server-driven Capture Cleanup job. + var storeDir = OperatorReportStoreDir(); + var stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss"); + string? payloadPath = null; + try + { + Directory.CreateDirectory(storeDir); + string? screenshotPath = null; + if (includeScreenshot) + { + screenshotPath = Path.Combine(storeDir, "screenshot-" + stamp + ".png"); + ScreenCapture.CaptureToPng(screenshotPath); + } + + payloadPath = Path.Combine(storeDir, "report-" + stamp + ".json"); + OperatorReportPayload.Write(payloadPath, reason, details, screenshotPath); + + ShowBalloon("Report an issue", "Sending your report to ShopDB...", + ToolTipIcon.Info); + + int exitCode = await RunAgentAsync(agentExe, "--report \"" + payloadPath + "\""); + if (exitCode == 0) + ShowBalloon("Report an issue", "Your report was sent to ShopDB.", + ToolTipIcon.Info); + else + ShowBalloon("Report an issue", + "Report send failed (exit " + exitCode + + "). See the agent log under ProgramData\\ShopDB.", + ToolTipIcon.Warning); + } + catch (Exception error) + { + ShowBalloon("Report an issue", "Could not send the report: " + + error.Message, ToolTipIcon.Error); + } + finally + { + // Only the transient JSON sidecar is removed; the screenshot stays + // for the operator. The agent also deletes the sidecar on success, + // so this just covers the failure path. + if (payloadPath is not null) + TryDeleteFile(payloadPath); + } + } + + // The operator-visible folder screenshots and the report sidecar are staged + // into, under the interactive user's profile. Resolved in the tray (running + // as that user) and passed to the agent as an absolute path; the agent must + // never re-derive it, since as SYSTEM its profile would resolve elsewhere. + private static string OperatorReportStoreDir() + => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "ShopDBScreenshots"); + + private static void TryDeleteFile(string filePath) + { + try + { + if (File.Exists(filePath)) + File.Delete(filePath); + } + catch + { + // Best-effort; a leftover sidecar is harmless and the agent also + // removes it on a successful upload. + } } // Runs shopdbagent.exe --once out of process so the operator can push a @@ -74,7 +169,7 @@ internal sealed class TrayContext : ApplicationContext try { - int exitCode = await RunAgentOnceAsync(agentExe); + int exitCode = await RunAgentAsync(agentExe, "--once"); if (exitCode == 0) ShowBalloon("Send Status", "Status sent to ShopDB.", ToolTipIcon.Info); @@ -96,12 +191,12 @@ internal sealed class TrayContext : ApplicationContext } } - private static Task RunAgentOnceAsync(string agentExe) + private static Task RunAgentAsync(string agentExe, string arguments) { var startInfo = new ProcessStartInfo { FileName = agentExe, - Arguments = "--once", + Arguments = arguments, WorkingDirectory = Path.GetDirectoryName(agentExe) ?? Environment.CurrentDirectory, UseShellExecute = false, CreateNoWindow = true, @@ -181,9 +276,36 @@ internal sealed class TrayContext : ApplicationContext => 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.", + "Send Status now reports this PC to ShopDB on demand.\n\n" + + "Tray version: " + TrayVersion() + "\n" + + "Agent version: " + AgentVersion(), "About", MessageBoxButtons.OK, MessageBoxIcon.Information); + // The tray's own assembly version, the same value stamped from the csproj + // at build time. + private static string TrayVersion() + => Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; + + // The installed agent service exe's file version, read from wherever the + // tray resolves it (beside the tray, then the default install dir). The + // service and tray can legitimately be different versions, so this is shown + // as its own line rather than assumed equal to the tray. Degrades to a + // clear note when the agent exe is not found or its version is unreadable. + private static string AgentVersion() + { + var agentExe = ResolveAgentExe(); + if (agentExe is null) + return "not found"; + try + { + return FileVersionInfo.GetVersionInfo(agentExe).FileVersion ?? "unknown"; + } + catch (FileNotFoundException) + { + return "not found"; + } + } + private void OnExit(object? sender, EventArgs e) { trayIcon.Visible = false; diff --git a/agent/ShopDbEndpointAgent.Tray/TrayIcon.cs b/agent/ShopDbEndpointAgent.Tray/TrayIcon.cs index dbc0957b..b00e8bb9 100644 --- a/agent/ShopDbEndpointAgent.Tray/TrayIcon.cs +++ b/agent/ShopDbEndpointAgent.Tray/TrayIcon.cs @@ -62,6 +62,22 @@ internal static class TrayIcon } } + // Writes the branded icon to an .ico file so a Start Menu / desktop shortcut + // can show the brand instead of the generic exe icon. Reuses the same GDI+ + // drawing as the tray so there is still one source of brand truth and no + // checked-in binary. 64px suits shortcut/large-icon views; the OS downscales. + public static void Export(string path, int size = 64) + { + using var icon = Create(size); + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + using var stream = new FileStream(path, FileMode.Create, FileAccess.Write); + icon.Save(stream); + } + private static GraphicsPath RoundedRect(RectangleF rect, float radius) { float diameter = radius * 2; diff --git a/agent/ShopDbEndpointAgent/Program.cs b/agent/ShopDbEndpointAgent/Program.cs index aa75f556..b003d9f6 100644 --- a/agent/ShopDbEndpointAgent/Program.cs +++ b/agent/ShopDbEndpointAgent/Program.cs @@ -10,6 +10,9 @@ using ShopDbEndpointAgent.Core; // (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) +// --report upload one operator issue report (JSON sidecar written by +// the tray), then exit. Keeps the collector key in the +// service so the tray never holds a secret. // // Secrets are never read here: AgentConfigProvider layers the registry // (HKLM\SOFTWARE\GE\ShopDB: CollectorKey, BaseUrl) over the shipped, non-secret @@ -19,6 +22,7 @@ using ShopDbEndpointAgent.Core; var runOnce = args.Any(a => string.Equals(a, "--once", StringComparison.OrdinalIgnoreCase)); var forceConsole = args.Any(a => string.Equals(a, "--console", StringComparison.OrdinalIgnoreCase)); +var reportPayloadPath = ArgValue(args, "--report"); var agentVersion = Assembly.GetExecutingAssembly() .GetName().Version?.ToString() ?? "0.0.0"; @@ -58,6 +62,9 @@ try if (runOnce) return await RunOnceAsync(config, agentVersion, logDirectory, faultMonitor); + if (!string.IsNullOrWhiteSpace(reportPayloadPath)) + return await RunReportAsync(config, reportPayloadPath, logDirectory, faultMonitor); + await RunHostAsync(config, agentVersion, logDirectory, forceConsole); return 0; } @@ -103,6 +110,61 @@ static async Task RunOnceAsync(AgentConfig config, string agentVersion, } } +// Uploads one operator issue report described by a tray-written JSON sidecar, +// then deletes the payload folder. Runs out of process from the tray so the +// collector key stays in the service binary. The exit code is the tray's +// contract: 0 = uploaded, non-zero = failed (detail in the agent log). +static async Task RunReportAsync(AgentConfig config, string payloadPath, + string logDirectory, AgentFaultMonitor faultMonitor) +{ + using var services = BuildOnceServices(config, "0.0.0", logDirectory); + var client = services.GetRequiredService(); + var logger = services.GetRequiredService>(); + try + { + var payload = OperatorReportPayload.Read(payloadPath); + var response = await client.UploadOperatorReportAsync( + payload.Reason, payload.Details, payload.ScreenshotPath, + Environment.MachineName, CancellationToken.None); + logger.LogInformation("Operator report uploaded: id={ReportId}", + response?.OperatorReportId); + return 0; + } + catch (Exception error) + { + faultMonitor.ReportFatal("--report", error); + return 1; + } + finally + { + // Remove only the transient JSON sidecar. The screenshot lives in the + // operator's %USERPROFILE%\ShopDBScreenshots folder and is deliberately + // KEPT so the operator can show it to a DT/specialist later; pruning it + // is the future server-driven Capture Cleanup job, not this path. (The + // sidecar previously sat in a throwaway temp folder we deleted whole; + // now it shares the operator's folder, so a recursive delete here would + // wipe saved screenshots.) + try + { + if (File.Exists(payloadPath)) + File.Delete(payloadPath); + } + catch { /* best-effort; the tray also removes the sidecar */ } + } +} + +// Read the value following a flag (e.g. --report C:\path\report.json). Returns +// null when the flag is absent or has no following token. +static string? ArgValue(string[] args, string flag) +{ + for (int i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], flag, StringComparison.OrdinalIgnoreCase)) + return args[i + 1]; + } + return null; +} + static ServiceProvider BuildOnceServices(AgentConfig config, string agentVersion, string logDirectory) { diff --git a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj index 9c7d55bf..63e33106 100644 --- a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj +++ b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj @@ -7,7 +7,38 @@ enable ShopDbEndpointAgent shopdbagent - - 1.4.1 + 1.6.3 true win-x64 true diff --git a/agent/ShopDbEndpointAgent/appsettings.json b/agent/ShopDbEndpointAgent/appsettings.json index 162913e3..6db6ca67 100644 --- a/agent/ShopDbEndpointAgent/appsettings.json +++ b/agent/ShopDbEndpointAgent/appsettings.json @@ -7,20 +7,10 @@ "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" } - ] + "processes": [], + "services": [] }, - "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" - } + "launchpaths": {} } diff --git a/docker-compose.airgap.yml b/docker-compose.airgap.yml index 7354ccb9..edaba728 100644 --- a/docker-compose.airgap.yml +++ b/docker-compose.airgap.yml @@ -32,6 +32,10 @@ x-app-env: &app-env LOG_LEVEL: ${LOG_LEVEL:-INFO} ZABBIX_URL: ${ZABBIX_URL:-} ZABBIX_TOKEN: ${ZABBIX_TOKEN:-} + # Channel label for a distributable installer. Empty on the stable (main) + # bundle, 'DEV' on the edge (dev-be) bundle, which lights up the sidebar + # badge. Sourced from .env, which build-channel-installer.ps1 pre-fills. + SHOPDB_ENV_LABEL: ${SHOPDB_ENV_LABEL:-} services: db: diff --git a/docker-compose.channel.yml b/docker-compose.channel.yml new file mode 100644 index 00000000..2f16b22e --- /dev/null +++ b/docker-compose.channel.yml @@ -0,0 +1,27 @@ +# Channel overlay for side-by-side stable / bleeding-edge stacks. +# +# The base docker-compose.yml is channel-agnostic. This overlay adds only the +# pieces that differ between a stable (main) and an edge (dev-be) stack so the +# two can run at once on one dev workstation without colliding: +# +# - SHOPDB_ENV_LABEL is passed into the api container. When non-empty the +# public settings endpoint surfaces it as env_label and the sidebar renders +# a red DEV badge. Stable leaves it unset, so nothing renders. +# - Ports fall back to the SAME defaults as the base file, so running this +# overlay alone changes nothing; the bootstrap script overrides API_PORT / +# MYSQL_PORT for the edge stack so both channels bind distinct host ports. +# +# The stacks are isolated by compose project name (-p shopdb-stable / +# -p shopdb-edge), which namespaces containers, networks, and the named +# volumes, so each channel keeps its own database and instance state. +# +# Usage (driven by scripts/dev-bootstrap.ps1, shown here for reference): +# docker compose -p shopdb-edge \ +# -f docker-compose.yml -f docker-compose.channel.yml up -d + +services: + api: + environment: + # Empty on stable. The edge stack sets SHOPDB_ENV_LABEL=DEV in its .env + # (written by the bootstrap script) to light up the badge. + SHOPDB_ENV_LABEL: ${SHOPDB_ENV_LABEL:-} diff --git a/docker-compose.pull.yml b/docker-compose.pull.yml new file mode 100644 index 00000000..1d04ec1b --- /dev/null +++ b/docker-compose.pull.yml @@ -0,0 +1,92 @@ +# shopdb-flask PULL stack for dev workstations (stable / edge channels). +# +# WHY this exists: pilots behind an inspecting proxy cannot reliably BUILD the +# image from source (npm's many small fetches get reset mid-install). Instead +# CI (.github/workflows/publish-images.yml) builds the image on clean runners +# and publishes it to GHCR; this file just PULLS and RUNS it. One resumable +# image pull survives the proxy where hundreds of npm requests do not. +# +# Nothing is built here (no `build:`), mirroring docker-compose.airgap.yml: +# - api/migrate use `image:` (pulled from GHCR), never `build: .`. +# - NO ./plugins bind mount; the image carries every plugin baked in. +# - a one-shot `migrate` runs db upgrade + plugin upgrade-all + seed before +# api starts, so `up -d` alone brings up a working site. +# +# Driven by scripts/dev-bootstrap.ps1, which sets SHOPDB_IMAGE (the channel +# image ref), SHOPDB_ENV_LABEL, API_PORT and MYSQL_PORT per channel and picks a +# compose project name (-p shopdb-stable / -p shopdb-edge) so the two channels +# run side by side without colliding. + +x-app-env: &app-env + FLASK_APP: wsgi.py + FLASK_ENV: production + DATABASE_URL: mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask?charset=utf8mb4 + SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set} + JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY must be set} + CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS must be set} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + # Empty on stable; 'DEV' on edge, which lights the sidebar badge. + SHOPDB_ENV_LABEL: ${SHOPDB_ENV_LABEL:-} + +services: + db: + image: mysql:8.4 + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set} + MYSQL_DATABASE: shopdb_flask + MYSQL_USER: shopdb + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set} + volumes: + - db_data:/var/lib/mysql + ports: + - "127.0.0.1:${MYSQL_PORT:-3306}:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 + + # One-shot schema + seed. Runs to completion and exits; api waits for it. + # Every step is idempotent, so it is safe to run on every `up`. + migrate: + image: ${SHOPDB_IMAGE:?SHOPDB_IMAGE must be set to the channel image ref} + pull_policy: always + restart: "no" + depends_on: + db: + condition: service_healthy + environment: + <<: *app-env + command: + - sh + - -c + - > + flask db upgrade && + flask plugin upgrade-all && + flask seed permissions && + flask seed settings && + flask seed reference-data + volumes: + - instance_data:/app/instance + + api: + image: ${SHOPDB_IMAGE:?SHOPDB_IMAGE must be set to the channel image ref} + pull_policy: always + restart: unless-stopped + depends_on: + db: + condition: service_healthy + migrate: + condition: service_completed_successfully + environment: + <<: *app-env + ports: + - "${API_PORT:-5001}:5001" + volumes: + - instance_data:/app/instance + +volumes: + db_data: + instance_data: diff --git a/docker-compose.yml b/docker-compose.yml index dc2e63e3..3d28a3ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -99,6 +99,11 @@ services: retries: 3 volumes: - ./plugins:/app/plugins:ro + # Standalone operator scripts shipped inside generated bundles (the + # endpoint-agent uninstaller). Read-only, same pattern as plugins: the + # site-agent packaging route reads uninstall-shopdbagent.ps1 from here at + # download time, so it must be visible in the container. + - ./scripts:/app/scripts:ro # Self-signed TLS material for the dev/test https listener. Read-only; # generated on the host under ./certs and never baked into the image. - ./certs:/app/certs:ro diff --git a/docs/DEV-BUILD-TEST.md b/docs/DEV-BUILD-TEST.md new file mode 100644 index 00000000..ac357d1b --- /dev/null +++ b/docs/DEV-BUILD-TEST.md @@ -0,0 +1,45 @@ +# ShopDB dev build - quick test + +Two dev builds you can stand up on a Windows box with Docker Desktop. You run +one PowerShell script: it checks your dependencies (offers to winget-install +anything missing), then PULLS a prebuilt image from GHCR and starts the stack in +Docker. Nothing is compiled on your machine - no Node, no npm, no source build - +so it works on a box that has only Docker installed. + +## What you need + +- Windows with Docker Desktop running. +- Git for Windows (`winget install Git.Git`). +- A GitHub login for this private repo/registry already stored on the box. If + you have cloned this repo before, or signed in to GitHub in Visual Studio or + VS Code, you already have it - the script uses that stored credential + automatically. If Docker cannot pull the image, sign in once with + `docker login ghcr.io` (use a GitHub username and a personal access token that + has `read:packages`), then re-run the launcher. + +## Get the launcher + +You will be sent `Start-ShopDBDev.ps1` directly. Save it anywhere (for example +your Downloads folder) and open PowerShell in that folder. + +## Bleeding edge (dev-be) + + powershell -ExecutionPolicy Bypass -File .\Start-ShopDBDev.ps1 -Channel edge + +Comes up at https://localhost:5011. Red DEV badge in the sidebar. Pulls the +`edge` image. + +## Stable (main) + + powershell -ExecutionPolicy Bypass -File .\Start-ShopDBDev.ps1 -Channel stable + +Comes up at https://localhost:5001. No DEV badge. Pulls the `stable` image. + +## Notes + +- Both stacks can run at the same time - they use different ports and separate + Docker volumes, so they do not collide and each keeps its own database. +- Each launch pulls the latest image for the channel, so re-running the launcher + is how you pick up a new build. No local rebuild step. +- First page load shows a self-signed cert warning (expected for a dev box). +- Stop a stack: `docker compose -p shopdb-stable down` (or `shopdb-edge`). diff --git a/docs/ShopDB-Planner-Tasks-v2.csv b/docs/ShopDB-Planner-Tasks-v2.csv new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 6bc0f1b3..57fcc970 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -178,6 +178,25 @@ export const endpointsApi = { }, telemetryMeta(assetId) { return api.get(`/endpoints/asset/${assetId}/telemetrymeta`) + }, + // Monitor catalog: reusable process/service definitions. Shipped rows are + // product-owned and read-only; local rows are user-built on the Settings page. + monitors: { + list() { + return api.get('/endpoints/monitors') + }, + create(data) { + return api.post('/endpoints/monitors', data) + }, + update(monitorid, data) { + return api.put(`/endpoints/monitors/${monitorid}`, data) + }, + remove(monitorid) { + return api.delete(`/endpoints/monitors/${monitorid}`) + }, + resolve(pctype) { + return api.get('/endpoints/monitors/resolve', { params: { pctype } }) + } } } diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css index 7cf95e64..18bd2c44 100644 --- a/frontend/src/assets/style.css +++ b/frontend/src/assets/style.css @@ -1068,17 +1068,28 @@ td.actions .btn + .btn { } /* Content Grid */ -/* Balanced two-column card flow. Uses CSS multicol (not a hand-assigned - grid) so cards distribute by height and the columns stay even no matter - how many cards land on either side. display:contents flattens the two - .content-column wrappers so their cards flow directly into the columns, - which keeps the existing markup unchanged. */ +/* Two-column card layout on an explicit CSS grid rather than multicol. + Multicol was abandoned because a child using column-span: all (the full + width Network Interfaces panel) overlaps the shorter column above it when + the two columns have unequal heights - a known multicol spanning bug. Grid + has no such flaw: short fact cards sit two-up and a full-width panel spans + both tracks cleanly via .pap-span. display:contents flattens the two + .content-column wrappers so their cards become direct grid items, which + keeps the existing markup unchanged. */ .content-grid { - column-count: 2; - column-gap: 25px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 25px; + align-items: start; margin-bottom: 25px; } +/* Grid supplies the gap; the per-card margin was a multicol-only crutch and + would otherwise double the vertical spacing. */ +.content-grid > .section-card { + margin-bottom: 0; +} + .content-column { display: contents; } @@ -1522,7 +1533,7 @@ td.actions .btn + .btn { } .content-grid { - column-count: 1; + grid-template-columns: 1fr; } .audit-footer { diff --git a/frontend/src/components/PluginAssetPanels.vue b/frontend/src/components/PluginAssetPanels.vue index 9782f2af..335fd2ed 100644 --- a/frontend/src/components/PluginAssetPanels.vue +++ b/frontend/src/components/PluginAssetPanels.vue @@ -143,21 +143,170 @@ {{ b.label }} + + +
+ +
+ {{ panel.empty || 'No operator reports.' }} +
+
+ + + diff --git a/plugins/computers/frontend/views/BusinessPoliciesList.vue b/plugins/computers/frontend/views/BusinessPoliciesList.vue index 4a6b20ae..e2f3b971 100644 --- a/plugins/computers/frontend/views/BusinessPoliciesList.vue +++ b/plugins/computers/frontend/views/BusinessPoliciesList.vue @@ -1,8 +1,8 @@