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:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -
|
||||
|
||||
52
agent/ShopDbEndpointAgent.Core/OperatorReportPayload.cs
Normal file
52
agent/ShopDbEndpointAgent.Core/OperatorReportPayload.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
34
agent/ShopDbEndpointAgent.Tray/ScreenCapture.cs
Normal file
34
agent/ShopDbEndpointAgent.Tray/ScreenCapture.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
0
docs/ShopDB-Planner-Tasks-v2.csv
Normal file
0
docs/ShopDB-Planner-Tasks-v2.csv
Normal 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">×</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)"
|
||||
>‹</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)"
|
||||
>›</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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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')
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
82
plugins/endpoints/models/operatorreport.py
Normal file
82
plugins/endpoints/models/operatorreport.py
Normal 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),
|
||||
}
|
||||
@@ -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]:
|
||||
|
||||
335
scripts/sim_policy_monitor_e2e.py
Normal file
335
scripts/sim_policy_monitor_e2e.py
Normal 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)
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
237
tests/test_plugins/test_endpoints_operator_reports.py
Normal file
237
tests/test_plugins/test_endpoints_operator_reports.py
Normal 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
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user