add operator issue reports for endpoint agent

Operators had no way to flag a problem PC from the tray. This adds a report path: the tray captures a reason, free-text note, and optional screenshot and the agent uploads it on the collector token to a new operatorreports table linked to the asset. Ships the endpoints 0009 migration so the table exists before the code queries it; the running DB had drifted behind the code and returned HTTP 500 on upload.
This commit is contained in:
benjamin.baker
2026-09-01 19:39:32 -04:00
parent 8cc8b0c9b2
commit ab4b9d7ec9
24 changed files with 1596 additions and 51 deletions

View File

@@ -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)
{

View File

@@ -132,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<OperatorReportUploadResponse?> 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<OperatorReportUploadEnvelope>(
body, JsonOptions);
return envelope?.Data;
}
finally
{
imageContent?.Dispose();
imageStream?.Dispose();
}
}
public void Dispose() => http.Dispose();
}

View File

@@ -22,12 +22,26 @@ public sealed class CommandClient : IDisposable
};
private readonly HttpClient http;
private readonly SocketsHttpHandler handler;
private readonly ILogger<CommandClient> logger;
public CommandClient(AgentConfig config, ILogger<CommandClient> 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

View File

@@ -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 -

View File

@@ -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 <path>`; 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<OperatorReportPayload>(json, Options)
?? throw new InvalidDataException("empty or invalid report payload");
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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<int> RunAgentOnceAsync(string agentExe)
private static Task<int> 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
// <Version> 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;

View File

@@ -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 <path> 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<int> 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<int> RunReportAsync(AgentConfig config, string payloadPath,
string logDirectory, AgentFaultMonitor faultMonitor)
{
using var services = BuildOnceServices(config, "0.0.0", logDirectory);
var client = services.GetRequiredService<CollectorClient>();
var logger = services.GetRequiredService<ILogger<CollectorClient>>();
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)
{

View File

@@ -7,7 +7,23 @@
<Nullable>enable</Nullable>
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
<AssemblyName>shopdbagent</AssemblyName>
<!-- 1.6.1: republished against the current source so both site packages
<!-- 1.6.3: operator issue reports stage under the interactive operator's
%USERPROFILE%\ShopDBScreenshots (resolved in the ShopFloor tray session)
instead of %TEMP%, which on locked-down endpoints redirected to another
user's AppData\Local\Temp and left the SYSTEM-context report upload
reading an inaccessible path. Screenshots use flat timestamped names and
are KEPT after a successful upload for operator review; only the transient
JSON sidecar is pruned, so the report path no longer recursively deletes
the shared folder. AgentFaultMonitor.ReportFatal now mirrors fatal report
failures to the rolling file log so unelevated tray-launched runs surface
a cause instead of exiting 1 silently.
1.6.2: command channel retires pooled TLS connections faster than the
server keep-alive (SocketsHttpHandler PooledConnectionIdleTimeout 1s,
PooledConnectionLifetime 2m). Fixes intermittent "unexpected EOF or 0
bytes from the transport stream" on the long-poll claim when the pool
reused a socket the server had already closed between hold windows.
Telemetry was unaffected; this only touched CommandClient.
1.6.1: republished against the current source so both site packages
(Wilmington and West Jefferson) carry the same vetted build. No agent
behavior change; the server now computes warn-only drift from the
service/process state the agent already reports each cycle.
@@ -34,7 +50,7 @@
(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.6.1</Version>
<Version>1.6.3</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>

View File

View File

@@ -143,21 +143,170 @@
<span v-for="(b, bi) in badges(panel)" :key="bi" class="status-badge"
:style="colorStyle(b.color)">{{ b.label }}</span>
</div>
<!-- operatorreports: a thumbnail grid of operator-submitted reports; a
card expands to a lightbox with the full screenshot and note, and
left/right arrows cycle through the reports. -->
<div v-else-if="panel.render === 'operatorreports'" class="pap-reports">
<template v-if="rows(panel).length">
<div class="pap-report-grid">
<button
v-for="(report, ri) in rows(panel)"
:key="report.operatorreportid"
type="button"
class="pap-report-card"
@click="openReport(panel, ri)"
>
<div class="pap-report-thumb">
<img
v-if="report.hasimage"
:src="reportImageUrl(panel, report)"
:alt="'Screenshot for report ' + report.operatorreportid"
loading="lazy"
/>
<span v-else class="pap-report-noimage">No screenshot</span>
</div>
<div class="pap-report-preview">
<span class="pap-report-text">{{ report.details || '(no note)' }}</span>
<span class="pap-report-when">{{ formatDate(report.createdat) }}</span>
</div>
</button>
</div>
</template>
<div v-else class="pap-empty">
<span class="muted">{{ panel.empty || 'No operator reports.' }}</span>
</div>
</div>
</div>
</div>
</template>
<!-- Lightbox: full screenshot and note for the open report, with left/right
cycling. Rendered once outside the panel loop so the overlay covers the
page rather than a single card. -->
<div
v-if="lightbox.panel"
class="pap-lightbox"
role="dialog"
aria-modal="true"
tabindex="0"
@click.self="closeReport"
@keydown.esc="closeReport"
@keydown.left="cycleReport(-1)"
@keydown.right="cycleReport(1)"
>
<button type="button" class="pap-lb-close" aria-label="Close" @click="closeReport">&times;</button>
<button
type="button"
class="pap-lb-nav pap-lb-prev"
aria-label="Previous report"
:disabled="rows(lightbox.panel).length < 2"
@click.stop="cycleReport(-1)"
>&#8249;</button>
<div class="pap-lb-body">
<div class="pap-lb-image">
<img
v-if="openReportRow && openReportRow.hasimage"
:src="reportImageUrl(lightbox.panel, openReportRow)"
:alt="'Screenshot for report ' + openReportRow.operatorreportid"
/>
<span v-else class="pap-report-noimage">No screenshot</span>
</div>
<div v-if="openReportRow" class="pap-lb-details">
<div class="pap-lb-meta">
<span>{{ openReportRow.reason }}</span>
<span>{{ formatDate(openReportRow.createdat) }}</span>
<span v-if="openReportRow.reportedhostname" class="mono">{{ openReportRow.reportedhostname }}</span>
</div>
<p class="pap-lb-note">{{ openReportRow.details || '(no note)' }}</p>
<div class="pap-lb-count">
{{ lightbox.index + 1 }} / {{ rows(lightbox.panel).length }}
</div>
</div>
</div>
<button
type="button"
class="pap-lb-nav pap-lb-next"
aria-label="Next report"
:disabled="rows(lightbox.panel).length < 2"
@click.stop="cycleReport(1)"
>&#8250;</button>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, onUnmounted } from 'vue'
import api from '../api'
import { colorStyle } from '@/utils/colorStyle'
import {
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
rowAction, facets, facetedRows,
rowAction, facets, facetedRows, formatValue,
} from './pluginAssetPanels'
// Operator report screenshots are served by a JWT-gated route, so a bare
// <img src> (which sends no Authorization header) would 401. Fetch each image
// as a blob through the api instance and hand the <img> an object URL instead.
// Cache by report id so cycling the lightbox does not refetch, and revoke on
// unmount so the blobs are not leaked.
const reportImageUrls = ref({})
function formatDate(value) {
return formatValue(value, { format: 'date' })
}
function reportImageUrl(panel, report) {
const key = report.operatorreportid
if (reportImageUrls.value[key]) return reportImageUrls.value[key]
const base = panel.imagebase || '/api/endpoints/operatorreports'
const path = toApiPath(base + '/' + key + '/image', props.assetid)
api.get(path, { responseType: 'blob' })
.then((response) => {
reportImageUrls.value = {
...reportImageUrls.value,
[key]: URL.createObjectURL(response.data),
}
})
.catch(() => {})
return ''
}
// Release every cached object URL. Called on reload and unmount so a long-lived
// detail page does not leak blob handles as the operator revisits PCs.
function revokeReportImageUrls() {
Object.values(reportImageUrls.value).forEach((url) => {
try { URL.revokeObjectURL(url) } catch { /* already revoked */ }
})
reportImageUrls.value = {}
}
onUnmounted(revokeReportImageUrls)
// The open report lightbox: which panel and which row index. A null panel
// means closed.
const lightbox = ref({ panel: null, index: 0 })
const openReportRow = computed(() => {
if (!lightbox.value.panel) return null
return rows(lightbox.value.panel)[lightbox.value.index] || null
})
function openReport(panel, index) {
lightbox.value = { panel, index }
}
function closeReport() {
lightbox.value = { panel: null, index: 0 }
}
function cycleReport(delta) {
if (!lightbox.value.panel) return
const total = rows(lightbox.value.panel).length
if (total < 2) return
const next = (lightbox.value.index + delta + total) % total
lightbox.value = { ...lightbox.value, index: next }
}
// Which filter chips are active, per panel id. Deliberately NOT reset when the
// data refreshes: a viewer who filtered to criticals and then hit refresh means
// to still be looking at criticals.
@@ -339,6 +488,8 @@ async function waitForFreshTelemetry(baseline, { attempts = 15, delayMs = 1000 }
// Fetch the declared panels for this asset, then each panel's data endpoint.
async function load() {
panels.value = []
revokeReportImageUrls()
closeReport()
if (!props.assetid) return
let declared
try {
@@ -476,4 +627,116 @@ defineExpose({ refresh: load, populatedGroups })
}
.mono { font-family: monospace; }
.muted { color: var(--text-light); }
/* Operator reports: a responsive thumbnail grid, each card a screenshot over a
one-line note preview. Clicking a card opens the lightbox. */
.pap-report-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
gap: 0.75rem;
}
.pap-report-card {
display: flex;
flex-direction: column;
padding: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
cursor: pointer;
overflow: hidden;
text-align: left;
}
.pap-report-card:hover { border-color: var(--primary); }
.pap-report-thumb {
aspect-ratio: 16 / 10;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-alt, #1b1b1b);
overflow: hidden;
}
.pap-report-thumb img { width: 100%; height: 100%; object-fit: cover; }
.pap-report-noimage { font-size: 0.78rem; color: var(--text-light); }
.pap-report-preview {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.45rem 0.55rem;
}
.pap-report-text {
font-size: 0.82rem;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pap-report-when { font-size: 0.72rem; color: var(--text-light); }
/* Lightbox: a page-covering overlay with the full screenshot, the note, and
left/right cycling controls. */
.pap-lightbox {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem;
background: rgba(0, 0, 0, 0.8);
}
.pap-lb-body {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 90vw;
max-height: 90vh;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
overflow: auto;
}
.pap-lb-image {
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
}
.pap-lb-image img { max-width: 100%; max-height: 70vh; object-fit: contain; }
.pap-lb-details { display: flex; flex-direction: column; gap: 0.4rem; }
.pap-lb-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.8rem;
color: var(--text-light);
}
.pap-lb-note { margin: 0; color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; }
.pap-lb-count { font-size: 0.78rem; color: var(--text-light); font-variant-numeric: tabular-nums; }
.pap-lb-close {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
color: #fff;
font-size: 1.8rem;
line-height: 1;
cursor: pointer;
}
.pap-lb-nav {
flex: 0 0 auto;
background: rgba(255, 255, 255, 0.1);
border: none;
color: #fff;
font-size: 2rem;
line-height: 1;
padding: 0.5rem 0.9rem;
border-radius: 6px;
cursor: pointer;
}
.pap-lb-nav:hover:not(:disabled) { background: rgba(255, 255, 255, 0.25); }
.pap-lb-nav:disabled { opacity: 0.3; cursor: default; }
</style>

View File

@@ -33,6 +33,7 @@ export function rows(panel) {
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 if (data && Array.isArray(data.reports)) list = data.reports
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
@@ -258,5 +259,6 @@ export function panelVisible(panel) {
if (panel.render === 'keyvalue') return keyvalueFields(panel).length > 0
if (panel.render === 'table') return rows(panel).length > 0 || !!panel.empty
if (panel.render === 'badge') return badges(panel).length > 0
if (panel.render === 'operatorreports') return rows(panel).length > 0 || !!panel.empty
return false
}

View File

@@ -25,7 +25,10 @@ from shopdb.api import (
Application, AppVersion, Script,
)
from ..models import EndpointState, EndpointEvent, EndpointCommand, EndpointApplication
from ..models import (
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
OperatorReport,
)
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
@@ -1766,3 +1769,174 @@ def download_script_body(script_id: int):
return send_upload(script_dir, script.filename, as_attachment=True)
# Operator issue reports (tray screenshot + free-text notes)
# ---------------------------------------------------------------------------
# Retention groundwork: how many days an operator report (and its screenshot
# file) is kept before the purge sweep removes it. Read from the Setting store
# so Agent Configuration can expose a toggle later without a code change; the
# default is 7 days. A value of 0 or less means "keep forever" (purge disabled).
OPERATOR_REPORT_RETENTION_SETTING = 'endpoints.operatorreport.retentiondays'
OPERATOR_REPORT_RETENTION_DEFAULT_DAYS = 7
# Screenshots are small PNGs from a virtual-screen grab. Cap the upload so a
# malformed or hostile agent cannot fill the disk with one request.
OPERATOR_REPORT_MAX_IMAGE_BYTES = 10 * 1024 * 1024
def _operator_report_dir():
return os.path.join(current_app.instance_path, 'operatorreports')
def _operator_report_retention_days():
"""Configured retention window in whole days (default 7, <=0 disables)."""
raw = Setting.get(OPERATOR_REPORT_RETENTION_SETTING)
if raw is None:
return OPERATOR_REPORT_RETENTION_DEFAULT_DAYS
try:
return int(raw)
except (TypeError, ValueError):
return OPERATOR_REPORT_RETENTION_DEFAULT_DAYS
def _purge_expired_operator_reports():
"""Delete operator reports older than the retention window, files and all.
Best-effort and idempotent: called opportunistically on upload so the feed
stays bounded without a scheduler. A retention of <=0 disables the sweep.
"""
days = _operator_report_retention_days()
if days <= 0:
return
cutoff = _naive_utcnow() - timedelta(days=days)
stale = OperatorReport.query.filter(OperatorReport.createdat < cutoff).all()
if not stale:
return
report_dir = _operator_report_dir()
for report in stale:
if report.imagefilename:
path = os.path.join(report_dir, report.imagefilename)
try:
os.remove(path)
except OSError:
pass
db.session.delete(report)
db.session.commit()
@endpoints_bp.route('/operatorreports', methods=['POST'])
def upload_operator_report():
"""Agent uploads one operator issue report (collector-token auth).
The tray captures the operator's free-text note and an optional screenshot,
then hands them to the service, which posts here as multipart/form-data on
the same endpoints collector token used for telemetry. The reported hostname
resolves to an asset; an unresolved hostname is a validation error. The
screenshot bytes are written to the operator-reports uploads dir and the row
holds only metadata plus the stored filename. One retention sweep runs
opportunistically so the feed stays bounded.
"""
from werkzeug.utils import secure_filename
import uuid
error = _collector_auth_or_error()
if error is not None:
return error
hostname = (request.form.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.VALIDATION_ERROR,
'could not resolve hostname to a computer: {!r}'.format(hostname))
reason = (request.form.get('reason') or 'Other (free text)').strip()[:128]
details = (request.form.get('details') or '').strip() or None
imagefilename = None
imagemimetype = None
imagebytesize = None
upload = request.files.get('screenshot')
if upload is not None and upload.filename:
data = upload.read()
if len(data) > OPERATOR_REPORT_MAX_IMAGE_BYTES:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'screenshot exceeds the {} byte limit'.format(
OPERATOR_REPORT_MAX_IMAGE_BYTES))
if data:
ext = os.path.splitext(secure_filename(upload.filename))[1].lower()
if ext != '.png':
ext = '.png'
imagefilename = 'report-{}{}'.format(uuid.uuid4().hex, ext)
imagemimetype = 'image/png'
imagebytesize = len(data)
report_dir = _operator_report_dir()
os.makedirs(report_dir, exist_ok=True)
with open(os.path.join(report_dir, imagefilename), 'wb') as handle:
handle.write(data)
report = OperatorReport(
assetid=asset_id,
reason=reason,
details=details,
imagefilename=imagefilename,
imagemimetype=imagemimetype,
imagebytesize=imagebytesize,
reportedhostname=hostname,
)
db.session.add(report)
db.session.commit()
# Keep the feed bounded without a scheduler. Best-effort: a purge failure
# must not fail the upload the operator already completed.
try:
_purge_expired_operator_reports()
except Exception:
db.session.rollback()
return success_response(report.to_dict(),
message='Operator report received', http_code=201)
@endpoints_bp.route('/panel/<int:asset_id>/operatorreports', methods=['GET'])
@require_permission('endpoints.view')
def panel_operator_reports(asset_id: int):
"""Operator Reports panel: this PC's recent operator issue reports.
Newest first. Each row carries a hasimage flag so the viewer knows whether
to request the screenshot from the serve route below. Runs a retention
sweep opportunistically so a viewed PC does not accumulate stale rows.
"""
try:
_purge_expired_operator_reports()
except Exception:
db.session.rollback()
reports = (OperatorReport.query
.filter_by(assetid=asset_id)
.order_by(OperatorReport.createdat.desc())
.all())
return success_response({'reports': [r.to_dict() for r in reports]})
@endpoints_bp.route('/operatorreports/<int:operatorreportid>/image',
methods=['GET'])
@require_permission('endpoints.view')
def serve_operator_report_image(operatorreportid: int):
"""Serve one operator report's screenshot (user JWT + endpoints.view)."""
from shopdb.api import send_upload
report = db.session.get(OperatorReport, operatorreportid)
if report is None or not report.imagefilename:
return error_response(ErrorCodes.NOT_FOUND,
'No screenshot for this report', http_code=404)
return send_upload(_operator_report_dir(), report.imagefilename)

View File

@@ -0,0 +1,63 @@
"""Operator issue reports (tray screenshot + free-text notes).
Adds operatorreports: one row per operator-submitted issue report for a PC
asset. The screenshot bytes live on disk under the instance uploads dir; this
table holds only metadata plus the stored file name so the image can be served
back and purged on retention. Reports are transient - a retention window
(default 7 days) purges old rows and their files - so this is a rolling
operator-signal feed, not a permanent record.
Idempotent: creation checks the live schema first so a db.create_all() site
(which already built the table) re-runs clean on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0009opreports'
down_revision = 'endpoints0008drift'
branch_labels = None
depends_on = None
def _has_table(table_name):
from sqlalchemy import inspect
return inspect(op.get_bind()).has_table(table_name)
def upgrade():
if _has_table('operatorreports'):
return
op.create_table(
'operatorreports',
sa.Column('operatorreportid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('reason', sa.String(length=128), nullable=False,
server_default='Other (free text)'),
sa.Column('details', sa.Text(), nullable=True),
sa.Column('imagefilename', sa.String(length=255), nullable=True),
sa.Column('imagemimetype', sa.String(length=64), nullable=True),
sa.Column('imagebytesize', sa.Integer(), nullable=True),
sa.Column('reportedhostname', sa.String(length=255), nullable=True),
sa.Column('createdat', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('operatorreportid'),
sa.ForeignKeyConstraint(
['assetid'], ['assets.assetid'],
name='fk_operatorreports_asset', ondelete='CASCADE'),
)
op.create_index('idx_operatorreports_asset', 'operatorreports', ['assetid'])
op.create_index('idx_operatorreports_createdat', 'operatorreports',
['createdat'])
def downgrade():
if not _has_table('operatorreports'):
return
op.drop_index('idx_operatorreports_createdat', table_name='operatorreports')
op.drop_index('idx_operatorreports_asset', table_name='operatorreports')
op.drop_table('operatorreports')

View File

@@ -4,8 +4,9 @@ from .endpoint import (
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
)
from .monitor import EndpointMonitor
from .operatorreport import OperatorReport
__all__ = [
'EndpointState', 'EndpointEvent', 'EndpointCommand', 'EndpointApplication',
'EndpointMonitor',
'EndpointMonitor', 'OperatorReport',
]

View File

@@ -0,0 +1,82 @@
"""Operator issue reports (screenshot + free-text notes from the tray).
An operator on a shop-floor PC opens the tray "Report an issue" dialog, writes a
free-text description, and optionally attaches a screenshot of their screen. The
agent uploads that to ShopDB, where it becomes one OperatorReport row linked to
the reporting PC's asset. The screenshot bytes live on disk under the instance
uploads dir (like employee photos); this row holds only metadata plus the
relative file path so the image can be served back and purged on retention.
Reports are transient by design: a retention window (default 7 days) purges old
rows and their image files, so this is a rolling operator-signal feed, not a
permanent record.
"""
from datetime import datetime
from shopdb.api import db
def _utciso(value):
"""Naive-UTC datetime -> ISO string marked UTC, or None."""
return value.isoformat() + 'Z' if value else None
class OperatorReport(db.Model):
"""One operator-submitted issue report for a PC asset."""
__tablename__ = 'operatorreports'
operatorreportid = db.Column(db.Integer, primary_key=True)
# The PC this report is about. ON DELETE CASCADE: when the asset goes, its
# operator reports go with it.
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
)
# The dropdown reason. Only 'Other (free text)' is functional today, but the
# column is kept so the richer reason routing can return without a migration.
reason = db.Column(db.String(128), nullable=False, default='Other (free text)')
# The operator's free-text description. Nullable: a screenshot alone is a
# valid report.
details = db.Column(db.Text, nullable=True)
# Screenshot metadata. imagefilename is the stored file name under the
# operator-reports uploads dir; null when the operator declined a screenshot.
imagefilename = db.Column(db.String(255), nullable=True)
imagemimetype = db.Column(db.String(64), nullable=True)
imagebytesize = db.Column(db.Integer, nullable=True)
# The hostname the agent reported at upload time, kept for traceability even
# if the asset link is later re-pointed.
reportedhostname = db.Column(db.String(255), nullable=True)
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
__table_args__ = (
db.Index('idx_operatorreports_asset', 'assetid'),
db.Index('idx_operatorreports_createdat', 'createdat'),
)
def __repr__(self):
return f"<OperatorReport {self.operatorreportid} asset={self.assetid}>"
def has_image(self):
return bool(self.imagefilename)
def to_dict(self):
return {
'operatorreportid': self.operatorreportid,
'assetid': self.assetid,
'reason': self.reason,
'details': self.details,
'hasimage': self.has_image(),
'imagemimetype': self.imagemimetype,
'imagebytesize': self.imagebytesize,
'reportedhostname': self.reportedhostname,
'createdat': _utciso(self.createdat),
}

View File

@@ -486,6 +486,23 @@ class EndpointsPlugin(BasePlugin):
# the installed-software inventory
'group': 'software',
},
{
# Operator-submitted issue reports: a free-text note and an
# optional screenshot the operator captured from the tray. Sits
# in the Monitors tab beside the other watch-and-observe panels.
# A custom render carries the thumbnail grid and the expanding
# lightbox with left/right cycling the generic modes cannot.
'id': 'endpoints-operator-reports',
'title': 'Operator Reports',
'assettypes': ['computer'],
'endpoint': '/api/endpoints/panel/{assetid}/operatorreports',
'render': 'operatorreports',
'imagebase': '/api/endpoints/operatorreports',
'collapsible': True,
'empty': 'No operator reports for this endpoint.',
'position': 18,
'group': 'monitors',
},
]
def get_dashboard_widgets(self) -> List[Dict]:

View File

@@ -0,0 +1,335 @@
"""Live end-to-end simulation for the policy-driven monitor workflow.
Drives the REAL HTTP routes through the Flask app against the LIVE database
(the same one the container serves), exercising the exact flow a user performs
in the browser:
1. Log in as an admin (mint a real JWT).
2. Confirm the monitor catalog lists the four shipped monitors.
3. Create a local process monitor (msedge.exe) via POST /api/endpoints/monitors.
4. Create a Configuration Policy scoped to a PC type, carrying monitor:<id>.
5. Wrap it in an active Business Policy.
6. Create a Computer of that PC type.
7. GET the services panel and assert the monitor surfaces as an Unknown row.
8. Negative + validation probes.
9. Clean up everything created (leaves the DB as found).
Run inside the container:
docker compose exec -T api python scripts/sim_policy_monitor_e2e.py
Exit code 0 = all simulations passed. Non-zero = a failure (printed).
"""
import sys
import uuid
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(Path(__file__).resolve().parent.parent / '.env')
from shopdb import create_app
from shopdb.extensions import db
from shopdb.core.models import User, Role, Asset, AssetType, Setting
from flask_jwt_extended import create_access_token
PASS = "PASS"
FAIL = "FAIL"
class Sim:
def __init__(self):
self.results = []
self.created = {
"monitor_ids": [],
"configpolicy_ids": [],
"businesspolicy_ids": [],
"asset_ids": [],
"computer_ids": [],
"computertype_ids": [],
"setting_created": False,
}
def check(self, name, condition, detail=""):
status = PASS if condition else FAIL
self.results.append((status, name, detail))
marker = "[ OK ]" if condition else "[FAIL]"
line = f"{marker} {name}"
if detail:
line += f" -- {detail}"
print(line, flush=True)
return condition
def summary(self):
passed = sum(1 for r in self.results if r[0] == PASS)
failed = sum(1 for r in self.results if r[0] == FAIL)
print("\n" + "=" * 60, flush=True)
print(f"SIMULATION SUMMARY: {passed} passed, {failed} failed", flush=True)
print("=" * 60, flush=True)
return failed == 0
def admin_headers(app):
"""Mint a real admin JWT bound to an existing admin user, or a temp one."""
with app.app_context():
user = (
User.query.join(User.roles)
.filter(Role.rolename == "admin")
.first()
)
if user is None:
role = Role.query.filter_by(rolename="admin").first()
if role is None:
role = Role(rolename="admin", description="Administrator")
db.session.add(role)
db.session.flush()
user = User(
username=f"sim_admin_{uuid.uuid4().hex[:8]}",
email=f"sim_{uuid.uuid4().hex[:8]}@test.local",
isactive=True,
)
if hasattr(user, "set_password"):
user.set_password("simpass123!")
user.roles.append(role)
db.session.add(user)
db.session.commit()
token = create_access_token(
identity=str(user.userid),
additional_claims={
"username": user.username,
"roles": [r.rolename for r in user.roles],
},
)
return {"Authorization": f"Bearer {token}"}
def jdata(resp):
body = resp.get_json()
return (body or {}).get("data")
def run():
app = create_app()
headers = admin_headers(app)
sim = Sim()
client = app.test_client()
tag = uuid.uuid4().hex[:8]
pctype_name = f"SimPCType_{tag}"
# --- 1. Monitor catalog lists the shipped four ---------------------------
resp = client.get("/api/endpoints/monitors", headers=headers)
sim.check("GET /monitors returns 200", resp.status_code == 200,
f"status={resp.status_code}")
catalog = jdata(resp) or []
shipped = [m for m in catalog if m.get("source") == "shipped"]
shipped_names = sorted({m["displayname"] for m in shipped})
sim.check("Shipped monitors seeded (eDNC, UDC, MTConnect x2)",
len(shipped) >= 4,
f"shipped={len(shipped)} names={shipped_names}")
# --- 2. Create a local process monitor (msedge.exe) ----------------------
display = f"sim_msedge_{tag}.exe"
resp = client.post("/api/endpoints/monitors", headers=headers, json={
"kind": "process",
"displayname": display,
"processname": display,
"isactive": True,
})
sim.check("POST /monitors (process) returns 201",
resp.status_code == 201, f"status={resp.status_code}")
monitor = jdata(resp) or {}
monitor_id = monitor.get("endpointmonitorid")
if monitor_id:
sim.created["monitor_ids"].append(monitor_id)
sim.check("Created monitor has an id", bool(monitor_id),
f"id={monitor_id}")
# --- 2b. Validation probe: missing name is rejected ----------------------
resp = client.post("/api/endpoints/monitors", headers=headers, json={
"kind": "process", "displayname": "", "processname": "",
})
sim.check("POST /monitors with blank name rejected (400)",
resp.status_code == 400, f"status={resp.status_code}")
# --- 3. Create a PC type to scope to -------------------------------------
resp = client.post("/api/computers/types", headers=headers,
json={"computertype": pctype_name})
sim.check("POST /computers/types returns 2xx",
resp.status_code in (200, 201), f"status={resp.status_code}")
pctype = jdata(resp) or {}
pctype_id = pctype.get("computertypeid")
if pctype_id:
sim.created["computertype_ids"].append(pctype_id)
sim.check("PC type created with id", bool(pctype_id), f"id={pctype_id}")
# --- 4. Enable the feature flag ------------------------------------------
with app.app_context():
flag = Setting.query.filter_by(
key="computers_configpolicies_enabled").first()
if flag is None:
db.session.add(Setting(
key="computers_configpolicies_enabled", value="true"))
sim.created["setting_created"] = True
else:
sim._prev_flag = flag.value
flag.value = "true"
db.session.commit()
sim.check("Feature flag computers_configpolicies_enabled=true", True)
# --- 5. Create a Configuration Policy carrying monitor:<id> ---------------
resp = client.post("/api/computers/policies/configuration", headers=headers,
json={
"name": f"Sim Edge Coverage {tag}",
"description": "sim",
"scopejson": {"type": "pctype", "id": pctype_id},
"actions": [
{"action": f"monitor:{monitor_id}",
"mode": "gate"},
],
})
sim.check("POST /policies/configuration returns 2xx",
resp.status_code in (200, 201), f"status={resp.status_code}")
cpolicy = jdata(resp) or {}
cpolicy_id = cpolicy.get("configurationpolicyid")
if cpolicy_id:
sim.created["configpolicy_ids"].append(cpolicy_id)
sim.check("Configuration Policy created with id", bool(cpolicy_id),
f"id={cpolicy_id}")
# --- 5b. Round-trip: the monitor action persisted ------------------------
resp = client.get(
f"/api/computers/policies/configuration/{cpolicy_id}", headers=headers)
body = jdata(resp) or {}
actions = body.get("actions", [])
sim.check("Config policy round-trips the monitor action",
any(a.get("action") == f"monitor:{monitor_id}" for a in actions),
f"actions={[a.get('action') for a in actions]}")
# --- 6. Wrap it in an active Business Policy ------------------------------
resp = client.post("/api/computers/policies/business", headers=headers,
json={
"name": f"Sim Process Monitor Test {tag}",
"priority": 100,
"isactive": True,
"items": [
{"configurationpolicyid": cpolicy_id,
"sortorder": 0},
],
})
sim.check("POST /policies/business returns 2xx",
resp.status_code in (200, 201), f"status={resp.status_code}")
bpolicy = jdata(resp) or {}
bpolicy_id = bpolicy.get("businesspolicyid")
if bpolicy_id:
sim.created["businesspolicy_ids"].append(bpolicy_id)
sim.check("Business Policy created with id", bool(bpolicy_id),
f"id={bpolicy_id}")
# --- 7. Create a Computer of that PC type --------------------------------
hostname = f"SIMPC{tag.upper()}"
with app.app_context():
from plugins.computers.models import Computer
atype = AssetType.query.filter_by(assettype="computer").first()
if atype is None:
atype = AssetType(assettype="computer")
db.session.add(atype)
db.session.flush()
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
isactive=True)
db.session.add(asset)
db.session.flush()
comp = Computer(assetid=asset.assetid, hostname=hostname,
computertypeid=pctype_id)
db.session.add(comp)
db.session.commit()
asset_id = asset.assetid
computer_id = comp.computerid
sim.created["asset_ids"].append(asset_id)
sim.created["computer_ids"].append(computer_id)
sim.check("Computer created for the scoped PC type", bool(asset_id),
f"assetid={asset_id}")
# --- 8. The monitor surfaces in the services panel -----------------------
resp = client.get(f"/api/endpoints/asset/{asset_id}/services",
headers=headers)
sim.check("GET /asset/<id>/services returns 200",
resp.status_code == 200, f"status={resp.status_code}")
rows = jdata(resp) or []
edge = next((r for r in rows if r.get("name") == display), None)
sim.check("Policy-attached monitor surfaces in panel", edge is not None,
f"rows={[r.get('name') for r in rows]}")
if edge:
sim.check("Surfaced row is a process", edge.get("kind") == "process",
f"kind={edge.get('kind')}")
sim.check("Surfaced row is Unknown (not yet reported)",
edge.get("statelabel") == "Unknown",
f"statelabel={edge.get('statelabel')}")
action = edge.get("action") or {}
sim.check("Surfaced row offers a Start action targeting the process",
action.get("target") == display
and action.get("targetkind") == "process",
f"action={action}")
# --- 9. Negative: flag off hides the row ---------------------------------
with app.app_context():
flag = Setting.query.filter_by(
key="computers_configpolicies_enabled").first()
flag.value = "false"
db.session.commit()
resp = client.get(f"/api/endpoints/asset/{asset_id}/services",
headers=headers)
rows = jdata(resp) or []
sim.check("Flag off hides the policy monitor from the panel",
not any(r.get("name") == display for r in rows),
f"rows={[r.get('name') for r in rows]}")
with app.app_context():
flag = Setting.query.filter_by(
key="computers_configpolicies_enabled").first()
flag.value = "true"
db.session.commit()
# --- Cleanup -------------------------------------------------------------
cleanup(app, client, headers, sim)
return sim.summary()
def cleanup(app, client, headers, sim):
print("\n--- cleanup ---", flush=True)
for bid in sim.created["businesspolicy_ids"]:
client.delete(f"/api/computers/policies/business/{bid}", headers=headers)
for cid in sim.created["configpolicy_ids"]:
client.delete(f"/api/computers/policies/configuration/{cid}",
headers=headers)
for mid in sim.created["monitor_ids"]:
client.delete(f"/api/endpoints/monitors/{mid}", headers=headers)
with app.app_context():
from plugins.computers.models import Computer, ComputerType
for cid in sim.created["computer_ids"]:
row = db.session.get(Computer, cid)
if row:
db.session.delete(row)
for aid in sim.created["asset_ids"]:
row = db.session.get(Asset, aid)
if row:
db.session.delete(row)
for tid in sim.created["computertype_ids"]:
row = db.session.get(ComputerType, tid)
if row:
db.session.delete(row)
# Restore or remove the feature flag as found.
flag = Setting.query.filter_by(
key="computers_configpolicies_enabled").first()
if flag is not None:
if sim.created["setting_created"]:
db.session.delete(flag)
elif hasattr(sim, "_prev_flag"):
flag.value = sim._prev_flag
db.session.commit()
print("cleanup done", flush=True)
if __name__ == "__main__":
ok = run()
sys.exit(0 if ok else 1)

View File

@@ -53,7 +53,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
'configurationpolicies', 'configurationpolicyactions',
'businesspolicies', 'businesspolicyitems'),
'employees': ('directoryemployees',),
'endpoints': ('endpointstates', 'endpointevents', 'endpointcommands', 'endpointapplications', 'endpointmonitors'),
'endpoints': ('endpointstates', 'endpointevents', 'endpointcommands', 'endpointapplications', 'endpointmonitors', 'operatorreports'),
'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes',
'manifestentryhostnames', 'manifestentrymachinenumbers',
'manifestinusechecks', 'manifestinusecheckprocesses',

View File

@@ -94,7 +94,15 @@ EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
# kill switch. Enqueue (WebUI -> operator) IS permission-gated
# on endpoints.command and IS swept. Contract in
# plugins/endpoints/AGENT-CONTRACT.md section 7.
'endpoints.report_command_result'}
'endpoints.report_command_result',
# Agent-facing operator issue report upload. Authenticated by
# the endpoints collector API key (same shape as the exempt
# collector blueprint), not JWT+role: the tray hands the
# screenshot and note to the service, which uploads on the
# collector key it already holds. The human-facing read and
# image-serve routes ARE permission-gated on endpoints.view
# and ARE swept.
'endpoints.upload_operator_report'}
@pytest.fixture(autouse=True)

View File

@@ -95,8 +95,9 @@ EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
# adds the generic command target/targetkind columns, 0004 adds the
# endpointapplications SBOM snapshot table, 0005 pins a command sha256, 0006
# seeds the reusable monitor catalog, 0007 ships the shopfloor monitor policy
# bundle, and 0008 adds the warn-only reconciler drift cache column.
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0008drift'
# bundle, 0008 adds the warn-only reconciler drift cache column, and 0009 adds
# the operatorreports table for tray issue reports.
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0009opreports'
# Plugins built after the cutover: their 0001 baseline really creates tables the
# core chain never owned.

View File

@@ -0,0 +1,237 @@
"""Operator issue report upload, retention purge, and hardened serve.
The tray captures a screenshot and a free-text note; the agent posts them to
POST /api/endpoints/operatorreports on the collector key. The bytes land on
disk under the operator-reports uploads dir and the row holds metadata. The
human-facing panel and image-serve routes are JWT + endpoints.view gated, and
the serve route carries the inert-upload headers. A retention sweep purges rows
and files older than the configured window.
"""
import io
import os
from datetime import timedelta
from shopdb.core.models import Asset, AssetType, Setting
UPLOAD_URL = '/api/endpoints/operatorreports'
PANEL_URL = '/api/endpoints/panel/{}/operatorreports'
IMAGE_URL = '/api/endpoints/operatorreports/{}/image'
COLLECTOR_KEY = 'testcollectorkey'
def _collector_headers(app):
app.config['COLLECTOR_API_KEY'] = COLLECTOR_KEY
return {'X-API-Key': COLLECTOR_KEY}
def _computer(db, hostname):
"""Create a computer Asset + Computer; return the asset."""
from plugins.computers.models import Computer, ComputerType
atype = AssetType.query.filter_by(assettype='computer').first()
if not atype:
atype = AssetType(assettype='computer')
db.session.add(atype)
db.session.flush()
ctype = ComputerType.query.filter_by(computertype='CMM PC').first()
if not ctype:
ctype = ComputerType(computertype='CMM PC')
db.session.add(ctype)
db.session.flush()
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
isactive=True)
db.session.add(asset)
db.session.flush()
comp = Computer(assetid=asset.assetid, hostname=hostname,
computertypeid=ctype.computertypeid)
db.session.add(comp)
db.session.commit()
return asset
def _png_bytes():
"""A tiny valid 1x1 PNG."""
import base64
return base64.b64decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk'
'YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==')
def test_upload_creates_report_and_stores_screenshot(client, db, app):
"""A collector-key upload with a screenshot creates a row and a file."""
from plugins.endpoints.models import OperatorReport
asset = _computer(db, 'OPREPORT1')
data = {
'hostname': 'OPREPORT1',
'reason': 'Other (free text)',
'details': 'the CMM app will not start',
'screenshot': (io.BytesIO(_png_bytes()), 'shot.png'),
}
response = client.post(UPLOAD_URL, data=data,
content_type='multipart/form-data',
headers=_collector_headers(app))
assert response.status_code == 201
body = response.get_json()['data']
assert body['assetid'] == asset.assetid
assert body['details'] == 'the CMM app will not start'
assert body['hasimage'] is True
report = OperatorReport.query.filter_by(assetid=asset.assetid).first()
assert report is not None
assert report.imagefilename
stored = os.path.join(app.instance_path, 'operatorreports',
report.imagefilename)
assert os.path.isfile(stored)
def test_upload_without_screenshot_is_allowed(client, db, app):
"""A note-only report (no screenshot) is valid; hasimage is false."""
_computer(db, 'OPREPORT2')
response = client.post(
UPLOAD_URL,
data={'hostname': 'OPREPORT2', 'details': 'noisy fan'},
content_type='multipart/form-data',
headers=_collector_headers(app))
assert response.status_code == 201
assert response.get_json()['data']['hasimage'] is False
def test_upload_unknown_hostname_is_validation_error(client, db, app):
"""A hostname with no computer row is rejected, not silently stored."""
response = client.post(
UPLOAD_URL,
data={'hostname': 'NOSUCHBOX', 'details': 'x'},
content_type='multipart/form-data',
headers=_collector_headers(app))
assert response.status_code == 400
def test_upload_requires_collector_auth(client, db, app):
"""Without the collector key the upload refuses (no JWT fallback)."""
_computer(db, 'OPREPORT3')
app.config['COLLECTOR_API_KEY'] = COLLECTOR_KEY
response = client.post(
UPLOAD_URL,
data={'hostname': 'OPREPORT3', 'details': 'x'},
content_type='multipart/form-data')
assert response.status_code in (401, 403)
def test_panel_lists_reports_newest_first(client, db, app, auth_headers):
"""The panel route returns this asset's reports, newest first."""
asset = _computer(db, 'OPREPORT4')
for note in ('first', 'second'):
client.post(UPLOAD_URL,
data={'hostname': 'OPREPORT4', 'details': note},
content_type='multipart/form-data',
headers=_collector_headers(app))
response = client.get(PANEL_URL.format(asset.assetid), headers=auth_headers)
assert response.status_code == 200
reports = response.get_json()['data']['reports']
assert [r['details'] for r in reports] == ['second', 'first']
def test_served_image_is_hardened(client, db, app, auth_headers):
"""The screenshot serve route carries the inert-upload headers."""
from shopdb.utils.uploads import UPLOAD_CSP
asset = _computer(db, 'OPREPORT5')
client.post(UPLOAD_URL,
data={'hostname': 'OPREPORT5', 'details': 'x',
'screenshot': (io.BytesIO(_png_bytes()), 'shot.png')},
content_type='multipart/form-data',
headers=_collector_headers(app))
from plugins.endpoints.models import OperatorReport
report = OperatorReport.query.filter_by(assetid=asset.assetid).first()
response = client.get(IMAGE_URL.format(report.operatorreportid),
headers=auth_headers)
assert response.status_code == 200
assert response.headers.get('Content-Security-Policy') == UPLOAD_CSP
assert response.headers.get('X-Content-Type-Options') == 'nosniff'
def test_image_serve_requires_jwt(client, db, app):
"""The screenshot serve route is not reachable without a user JWT."""
asset = _computer(db, 'OPREPORT6')
client.post(UPLOAD_URL,
data={'hostname': 'OPREPORT6', 'details': 'x',
'screenshot': (io.BytesIO(_png_bytes()), 'shot.png')},
content_type='multipart/form-data',
headers=_collector_headers(app))
from plugins.endpoints.models import OperatorReport
report = OperatorReport.query.filter_by(assetid=asset.assetid).first()
response = client.get(IMAGE_URL.format(report.operatorreportid))
assert response.status_code in (401, 403)
def test_retention_purge_removes_expired_reports_and_files(client, db, app):
"""A report past the retention window is purged, file and all, on upload."""
from plugins.endpoints.models import OperatorReport
from plugins.endpoints.api.routes import (
OPERATOR_REPORT_RETENTION_SETTING,
)
db.session.add(Setting(key=OPERATOR_REPORT_RETENTION_SETTING, value='7'))
db.session.commit()
asset = _computer(db, 'OPREPORT7')
client.post(UPLOAD_URL,
data={'hostname': 'OPREPORT7', 'details': 'old',
'screenshot': (io.BytesIO(_png_bytes()), 'shot.png')},
content_type='multipart/form-data',
headers=_collector_headers(app))
old = OperatorReport.query.filter_by(assetid=asset.assetid).first()
old_file = os.path.join(app.instance_path, 'operatorreports',
old.imagefilename)
assert os.path.isfile(old_file)
# Backdate it past the window, then upload a fresh report to trigger the
# opportunistic sweep.
old.createdat = old.createdat - timedelta(days=8)
db.session.commit()
client.post(UPLOAD_URL,
data={'hostname': 'OPREPORT7', 'details': 'new'},
content_type='multipart/form-data',
headers=_collector_headers(app))
assert db.session.get(OperatorReport, old.operatorreportid) is None
assert not os.path.isfile(old_file)
remaining = OperatorReport.query.filter_by(assetid=asset.assetid).all()
assert [r.details for r in remaining] == ['new']
def test_retention_disabled_keeps_old_reports(client, db, app):
"""A retention of 0 disables the sweep; old rows survive."""
from plugins.endpoints.models import OperatorReport
from plugins.endpoints.api.routes import (
OPERATOR_REPORT_RETENTION_SETTING,
)
db.session.add(Setting(key=OPERATOR_REPORT_RETENTION_SETTING, value='0'))
db.session.commit()
asset = _computer(db, 'OPREPORT8')
client.post(UPLOAD_URL,
data={'hostname': 'OPREPORT8', 'details': 'old'},
content_type='multipart/form-data',
headers=_collector_headers(app))
old = OperatorReport.query.filter_by(assetid=asset.assetid).first()
old.createdat = old.createdat - timedelta(days=365)
db.session.commit()
client.post(UPLOAD_URL,
data={'hostname': 'OPREPORT8', 'details': 'new'},
content_type='multipart/form-data',
headers=_collector_headers(app))
assert db.session.get(OperatorReport, old.operatorreportid) is not None

View File

@@ -69,11 +69,12 @@ 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) == 6
assert len(panels) == 7
for panel in panels:
assert panel['assettypes'] == ['computer']
assert panel['endpoint'].startswith('/api/endpoints/asset/{assetid}')
assert panel['render'] in ('keyvalue', 'list', 'table')
assert panel['endpoint'].startswith('/api/endpoints/')
assert '{assetid}' in panel['endpoint']
assert panel['render'] in ('keyvalue', 'list', 'table', 'operatorreports')
def test_services_and_processes_are_split_into_two_filtered_cards():