diff --git a/agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs b/agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs index a46e98df..9db8776a 100644 --- a/agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs +++ b/agent/ShopDbEndpointAgent.Core/AgentFaultMonitor.cs @@ -34,7 +34,15 @@ public sealed class AgentFaultMonitor } public void ReportFatal(string context, Exception error) - => Report(EventLogEntryType.Error, 1000, $"{context}: {error}"); + { + // File log first, always: the Event Log sink below is admin-gated (the + // source cannot be created without elevation), so an unelevated run such + // as the tray-launched --once/--report path would otherwise fail with no + // trace anywhere. The rolling file log is the one sink guaranteed to work + // in every context. + logger.LogError(error, "Fatal in {Context}", context); + Report(EventLogEntryType.Error, 1000, $"{context}: {error}"); + } private void OnUnhandledException(object sender, UnhandledExceptionEventArgs args) { diff --git a/agent/ShopDbEndpointAgent.Core/CollectorClient.cs b/agent/ShopDbEndpointAgent.Core/CollectorClient.cs index fa9f724e..6f119139 100644 --- a/agent/ShopDbEndpointAgent.Core/CollectorClient.cs +++ b/agent/ShopDbEndpointAgent.Core/CollectorClient.cs @@ -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 UploadOperatorReportAsync( + string reason, string details, string? screenshotPath, string hostname, + CancellationToken token) + { + using var form = new MultipartFormDataContent(); + form.Add(new StringContent(reason ?? string.Empty), "reason"); + form.Add(new StringContent(details ?? string.Empty), "details"); + form.Add(new StringContent(hostname ?? string.Empty), "hostname"); + + StreamContent? imageContent = null; + FileStream? imageStream = null; + try + { + if (!string.IsNullOrWhiteSpace(screenshotPath) && File.Exists(screenshotPath)) + { + imageStream = File.OpenRead(screenshotPath); + imageContent = new StreamContent(imageStream); + imageContent.Headers.ContentType = + new System.Net.Http.Headers.MediaTypeHeaderValue("image/png"); + form.Add(imageContent, "screenshot", + Path.GetFileName(screenshotPath)); + } + + using var response = await http.PostAsync( + "api/endpoints/operatorreports", form, token); + var body = await response.Content.ReadAsStringAsync(token); + if (!response.IsSuccessStatusCode) + throw new CollectorException((int)response.StatusCode, body); + + var envelope = JsonSerializer.Deserialize( + body, JsonOptions); + return envelope?.Data; + } + finally + { + imageContent?.Dispose(); + imageStream?.Dispose(); + } + } + public void Dispose() => http.Dispose(); } diff --git a/agent/ShopDbEndpointAgent.Core/CommandClient.cs b/agent/ShopDbEndpointAgent.Core/CommandClient.cs index 0dcc395c..f1ad6d6e 100644 --- a/agent/ShopDbEndpointAgent.Core/CommandClient.cs +++ b/agent/ShopDbEndpointAgent.Core/CommandClient.cs @@ -22,12 +22,26 @@ public sealed class CommandClient : IDisposable }; private readonly HttpClient http; + private readonly SocketsHttpHandler handler; private readonly ILogger logger; public CommandClient(AgentConfig config, ILogger logger) { this.logger = logger; - http = new HttpClient + // The command channel long-polls: the server holds each claim open for a + // hold window and then, between windows, the pooled TLS connection sits + // idle. Gunicorn's keep-alive closes an idle socket after a couple of + // seconds, so a connection reused after that yields "unexpected EOF or 0 + // bytes from the transport stream" on the next TLS handshake. Retire + // pooled connections faster than the server's keep-alive so a socket the + // server has already dropped is never handed back out; the lifetime cap + // is a hard backstop so no connection lingers indefinitely. + handler = new SocketsHttpHandler + { + PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1), + PooledConnectionLifetime = TimeSpan.FromMinutes(2), + }; + http = new HttpClient(handler, disposeHandler: true) { BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"), // Longer than the collector client: the claim request is held open diff --git a/agent/ShopDbEndpointAgent.Core/Contracts.cs b/agent/ShopDbEndpointAgent.Core/Contracts.cs index f389ce8b..37a7f000 100644 --- a/agent/ShopDbEndpointAgent.Core/Contracts.cs +++ b/agent/ShopDbEndpointAgent.Core/Contracts.cs @@ -72,6 +72,20 @@ public sealed class CollectorEnvelope [JsonPropertyName("data")] public CollectorResponse? Data { get; set; } } +// Server response to an operator issue report upload. Carries the new row id so +// the agent can log it for correlation. +public sealed class OperatorReportUploadResponse +{ + [JsonPropertyName("operatorreportid")] public int? OperatorReportId { get; set; } + [JsonPropertyName("assetid")] public int? AssetId { get; set; } +} + +public sealed class OperatorReportUploadEnvelope +{ + [JsonPropertyName("status")] public string Status { get; set; } = ""; + [JsonPropertyName("data")] public OperatorReportUploadResponse? Data { get; set; } +} + // A command the server claimed for this endpoint on the long-poll. The // allowlisted action name crosses the wire (see AGENT-CONTRACT.md section 7); // generic actions (service_* / process_*) also carry the subject to act on - diff --git a/agent/ShopDbEndpointAgent.Core/OperatorReportPayload.cs b/agent/ShopDbEndpointAgent.Core/OperatorReportPayload.cs new file mode 100644 index 00000000..0402a1b3 --- /dev/null +++ b/agent/ShopDbEndpointAgent.Core/OperatorReportPayload.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ShopDbEndpointAgent.Core; + +// The on-disk hand-off between the interactive tray (which captures the +// operator's screen and notes) and the headless service binary (which holds the +// collector key and performs the upload). The tray writes this JSON sidecar to a +// per-run temp folder and invokes `shopdbagent.exe --report `; the agent +// reads it, uploads, and deletes the folder. Keeping the secret in the service +// preserves the "tray never holds a credential" rule already used by --once. +public sealed class OperatorReportPayload +{ + [JsonPropertyName("reason")] + public string Reason { get; set; } = "Other (free text)"; + + [JsonPropertyName("details")] + public string Details { get; set; } = string.Empty; + + // Absolute path to the captured PNG, or null when the operator declined a + // screenshot. Written by the tray into the interactive operator's + // %USERPROFILE%\ShopDBScreenshots folder and passed through as an absolute + // path; the agent reads it verbatim and never re-derives the location (as + // SYSTEM its own profile would resolve elsewhere). + [JsonPropertyName("screenshotpath")] + public string? ScreenshotPath { get; set; } + + private static readonly JsonSerializerOptions Options = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, + }; + + public static void Write(string filePath, string reason, string details, + string? screenshotPath) + { + var payload = new OperatorReportPayload + { + Reason = reason, + Details = details, + ScreenshotPath = screenshotPath, + }; + File.WriteAllText(filePath, JsonSerializer.Serialize(payload, Options)); + } + + public static OperatorReportPayload Read(string filePath) + { + var json = File.ReadAllText(filePath); + return JsonSerializer.Deserialize(json, Options) + ?? throw new InvalidDataException("empty or invalid report payload"); + } +} diff --git a/agent/ShopDbEndpointAgent.Tray/ScreenCapture.cs b/agent/ShopDbEndpointAgent.Tray/ScreenCapture.cs new file mode 100644 index 00000000..675b628e --- /dev/null +++ b/agent/ShopDbEndpointAgent.Tray/ScreenCapture.cs @@ -0,0 +1,34 @@ +using System.Drawing.Imaging; + +namespace ShopDbEndpointAgent.Tray; + +// Captures the full virtual desktop (all monitors) to a PNG on disk, porting +// the operator PowerShell proof of concept: +// CopyFromScreen over SystemInformation.VirtualScreen, then Bitmap.Save as +// PNG. Kept in the tray project because only the interactive tray session can +// see the operator's desktop; the headless service runs in session 0 and +// would capture a black frame. +internal static class ScreenCapture +{ + // Grab every monitor in one shot. VirtualScreen spans the whole desktop so + // a multi-head shop-floor PC is captured intact rather than just the + // primary display. Returns the saved file path. + public static string CaptureToPng(string filePath) + { + var bounds = SystemInformation.VirtualScreen; + using var bitmap = new Bitmap(bounds.Width, bounds.Height, + PixelFormat.Format32bppArgb); + using (var graphics = Graphics.FromImage(bitmap)) + { + graphics.CopyFromScreen(bounds.Left, bounds.Top, 0, 0, + bounds.Size, CopyPixelOperation.SourceCopy); + } + + var directory = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + bitmap.Save(filePath, ImageFormat.Png); + return filePath; + } +} diff --git a/agent/ShopDbEndpointAgent.Tray/TicketForm.cs b/agent/ShopDbEndpointAgent.Tray/TicketForm.cs index 392418e2..f953dcc8 100644 --- a/agent/ShopDbEndpointAgent.Tray/TicketForm.cs +++ b/agent/ShopDbEndpointAgent.Tray/TicketForm.cs @@ -1,22 +1,20 @@ namespace ShopDbEndpointAgent.Tray; -// The 5-click-max issue reporter (SCAFFOLD). Target flow from -// DESIGN-DECISIONS-agent.md section 6: -// network precheck -> issue dropdown -> optional screenshot -> submit. -// This iteration renders the dropdown of fixed reasons and a submit button but -// does NOT talk to ServiceNow yet - the submission path (direct API vs proxied -// through a ShopDB server endpoint so ServiceNow creds stay server-side) is an -// open decision. Submit currently shows what WOULD be sent. +// The operator issue reporter. Collects a free-text description and an optional +// screenshot request, then hands the result back to TrayContext which performs +// the capture (after this dialog closes, so it never appears in the shot) and +// the upload via the service binary. +// +// Only "Other (free text)" is offered today: the fixed-reason routing from the +// original design is not wired to a ticketing backend, so a single free-text +// path is the one flow that works end to end. The dropdown is kept (disabled, +// preselected) so the richer reason list can return without reworking the form. internal sealed class TicketForm : Form { - // Fixed dropdown reasons from the source conversation (section 6). + // Only the free-text reason is functional. Kept as a one-item list so the + // control and its layout stay in place for the future reason routing. private static readonly string[] Reasons = { - "Can't logon to CSF", - "Data collections down", - "Can't download program", - "Machine communication failing", - "Network drives down", "Other (free text)", }; @@ -24,6 +22,11 @@ internal sealed class TicketForm : Form private readonly TextBox detailBox; private readonly CheckBox screenshotBox; + // Collected result, read by TrayContext after DialogResult.OK. + public string Reason => reasonBox.SelectedItem?.ToString() ?? "Other (free text)"; + public string Details => detailBox.Text?.Trim() ?? string.Empty; + public bool IncludeScreenshot => screenshotBox.Checked; + public TicketForm() { Text = "Report an issue"; @@ -46,13 +49,16 @@ internal sealed class TicketForm : Form Top = 40, Width = 388, DropDownStyle = ComboBoxStyle.DropDownList, + // Disabled until the fixed-reason routing exists; free text is the + // only functional path, so the selection is fixed on it. + Enabled = false, }; reasonBox.Items.AddRange(Reasons); reasonBox.SelectedIndex = 0; var detailLabel = new Label { - Text = "Details (optional)", + Text = "Details", Left = 16, Top = 76, Width = 388, @@ -72,6 +78,7 @@ internal sealed class TicketForm : Form Left = 16, Top = 188, Width = 388, + Checked = true, }; var submit = new Button @@ -82,7 +89,6 @@ internal sealed class TicketForm : Form Width = 76, DialogResult = DialogResult.OK, }; - submit.Click += OnSubmit; var cancel = new Button { @@ -101,18 +107,4 @@ internal sealed class TicketForm : Form AcceptButton = submit; CancelButton = cancel; } - - private void OnSubmit(object? sender, EventArgs e) - { - // Placeholder: show what would be submitted. The real path (screenshot - // capture to Pictures\Screenshots, then ServiceNow submit) lands once - // the direct-vs-proxied decision is made. - var summary = - $"Reason: {reasonBox.SelectedItem}\n" + - $"Details: {(string.IsNullOrWhiteSpace(detailBox.Text) ? "(none)" : detailBox.Text)}\n" + - $"Screenshot: {(screenshotBox.Checked ? "yes" : "no")}"; - MessageBox.Show( - "Ticketing is not wired up yet. This would be submitted:\n\n" + summary, - "ShopDB agent (scaffold)", MessageBoxButtons.OK, MessageBoxIcon.Information); - } } diff --git a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs index 93827706..f6b80d72 100644 --- a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs +++ b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs @@ -1,4 +1,7 @@ using System.Diagnostics; +using System.Reflection; + +using ShopDbEndpointAgent.Core; namespace ShopDbEndpointAgent.Tray; @@ -44,10 +47,102 @@ internal sealed class TrayContext : ApplicationContext trayIcon.DoubleClick += OnSendStatus; } - private void OnReportIssue(object? sender, EventArgs e) + private async void OnReportIssue(object? sender, EventArgs e) { - using var dialog = new TicketForm(); - dialog.ShowDialog(); + string reason; + string details; + bool includeScreenshot; + using (var dialog = new TicketForm()) + { + if (dialog.ShowDialog() != DialogResult.OK) + return; + reason = dialog.Reason; + details = dialog.Details; + includeScreenshot = dialog.IncludeScreenshot; + } + + var agentExe = ResolveAgentExe(); + if (agentExe is null) + { + ShowBalloon("Report an issue", "Could not find " + AgentExeName + + ". Is the ShopDB Endpoint Agent installed?", ToolTipIcon.Error); + return; + } + + // Capture AFTER the dialog closes so the report form is never in the + // shot. Stage into the interactive operator's own profile + // (%USERPROFILE%\ShopDBScreenshots), NOT %TEMP%: on this locked-down + // image the tray process's TEMP resolves to a different account's + // AppData that ShopFloor cannot write, and AppData/Temp are aggressively + // ACL'd/redirected. The profile root is owned by the operator and, by + // inherited ACL, readable by SYSTEM (the agent service) so the handoff + // works. Files are kept for the operator to show a DT/specialist later; + // pruning is the agent's future server-driven Capture Cleanup job. + var storeDir = OperatorReportStoreDir(); + var stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss"); + string? payloadPath = null; + try + { + Directory.CreateDirectory(storeDir); + string? screenshotPath = null; + if (includeScreenshot) + { + screenshotPath = Path.Combine(storeDir, "screenshot-" + stamp + ".png"); + ScreenCapture.CaptureToPng(screenshotPath); + } + + payloadPath = Path.Combine(storeDir, "report-" + stamp + ".json"); + OperatorReportPayload.Write(payloadPath, reason, details, screenshotPath); + + ShowBalloon("Report an issue", "Sending your report to ShopDB...", + ToolTipIcon.Info); + + int exitCode = await RunAgentAsync(agentExe, "--report \"" + payloadPath + "\""); + if (exitCode == 0) + ShowBalloon("Report an issue", "Your report was sent to ShopDB.", + ToolTipIcon.Info); + else + ShowBalloon("Report an issue", + "Report send failed (exit " + exitCode + + "). See the agent log under ProgramData\\ShopDB.", + ToolTipIcon.Warning); + } + catch (Exception error) + { + ShowBalloon("Report an issue", "Could not send the report: " + + error.Message, ToolTipIcon.Error); + } + finally + { + // Only the transient JSON sidecar is removed; the screenshot stays + // for the operator. The agent also deletes the sidecar on success, + // so this just covers the failure path. + if (payloadPath is not null) + TryDeleteFile(payloadPath); + } + } + + // The operator-visible folder screenshots and the report sidecar are staged + // into, under the interactive user's profile. Resolved in the tray (running + // as that user) and passed to the agent as an absolute path; the agent must + // never re-derive it, since as SYSTEM its profile would resolve elsewhere. + private static string OperatorReportStoreDir() + => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "ShopDBScreenshots"); + + private static void TryDeleteFile(string filePath) + { + try + { + if (File.Exists(filePath)) + File.Delete(filePath); + } + catch + { + // Best-effort; a leftover sidecar is harmless and the agent also + // removes it on a successful upload. + } } // Runs shopdbagent.exe --once out of process so the operator can push a @@ -74,7 +169,7 @@ internal sealed class TrayContext : ApplicationContext try { - int exitCode = await RunAgentOnceAsync(agentExe); + int exitCode = await RunAgentAsync(agentExe, "--once"); if (exitCode == 0) ShowBalloon("Send Status", "Status sent to ShopDB.", ToolTipIcon.Info); @@ -96,12 +191,12 @@ internal sealed class TrayContext : ApplicationContext } } - private static Task RunAgentOnceAsync(string agentExe) + private static Task RunAgentAsync(string agentExe, string arguments) { var startInfo = new ProcessStartInfo { FileName = agentExe, - Arguments = "--once", + Arguments = arguments, WorkingDirectory = Path.GetDirectoryName(agentExe) ?? Environment.CurrentDirectory, UseShellExecute = false, CreateNoWindow = true, @@ -181,9 +276,36 @@ internal sealed class TrayContext : ApplicationContext => MessageBox.Show( "ShopDB Endpoint Agent tray.\n" + "Telemetry runs as the ShopDB Endpoint Agent Windows service.\n" + - "Send Status now reports this PC to ShopDB on demand.", + "Send Status now reports this PC to ShopDB on demand.\n\n" + + "Tray version: " + TrayVersion() + "\n" + + "Agent version: " + AgentVersion(), "About", MessageBoxButtons.OK, MessageBoxIcon.Information); + // The tray's own assembly version, the same value stamped from the csproj + // at build time. + private static string TrayVersion() + => Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; + + // The installed agent service exe's file version, read from wherever the + // tray resolves it (beside the tray, then the default install dir). The + // service and tray can legitimately be different versions, so this is shown + // as its own line rather than assumed equal to the tray. Degrades to a + // clear note when the agent exe is not found or its version is unreadable. + private static string AgentVersion() + { + var agentExe = ResolveAgentExe(); + if (agentExe is null) + return "not found"; + try + { + return FileVersionInfo.GetVersionInfo(agentExe).FileVersion ?? "unknown"; + } + catch (FileNotFoundException) + { + return "not found"; + } + } + private void OnExit(object? sender, EventArgs e) { trayIcon.Visible = false; diff --git a/agent/ShopDbEndpointAgent/Program.cs b/agent/ShopDbEndpointAgent/Program.cs index aa75f556..b003d9f6 100644 --- a/agent/ShopDbEndpointAgent/Program.cs +++ b/agent/ShopDbEndpointAgent/Program.cs @@ -10,6 +10,9 @@ using ShopDbEndpointAgent.Core; // (default) run as a Windows Service (AddWindowsService) // --console run interactively in the foreground for debugging // --once collect and report exactly once, then exit (smoke test / cron) +// --report upload one operator issue report (JSON sidecar written by +// the tray), then exit. Keeps the collector key in the +// service so the tray never holds a secret. // // Secrets are never read here: AgentConfigProvider layers the registry // (HKLM\SOFTWARE\GE\ShopDB: CollectorKey, BaseUrl) over the shipped, non-secret @@ -19,6 +22,7 @@ using ShopDbEndpointAgent.Core; var runOnce = args.Any(a => string.Equals(a, "--once", StringComparison.OrdinalIgnoreCase)); var forceConsole = args.Any(a => string.Equals(a, "--console", StringComparison.OrdinalIgnoreCase)); +var reportPayloadPath = ArgValue(args, "--report"); var agentVersion = Assembly.GetExecutingAssembly() .GetName().Version?.ToString() ?? "0.0.0"; @@ -58,6 +62,9 @@ try if (runOnce) return await RunOnceAsync(config, agentVersion, logDirectory, faultMonitor); + if (!string.IsNullOrWhiteSpace(reportPayloadPath)) + return await RunReportAsync(config, reportPayloadPath, logDirectory, faultMonitor); + await RunHostAsync(config, agentVersion, logDirectory, forceConsole); return 0; } @@ -103,6 +110,61 @@ static async Task RunOnceAsync(AgentConfig config, string agentVersion, } } +// Uploads one operator issue report described by a tray-written JSON sidecar, +// then deletes the payload folder. Runs out of process from the tray so the +// collector key stays in the service binary. The exit code is the tray's +// contract: 0 = uploaded, non-zero = failed (detail in the agent log). +static async Task RunReportAsync(AgentConfig config, string payloadPath, + string logDirectory, AgentFaultMonitor faultMonitor) +{ + using var services = BuildOnceServices(config, "0.0.0", logDirectory); + var client = services.GetRequiredService(); + var logger = services.GetRequiredService>(); + try + { + var payload = OperatorReportPayload.Read(payloadPath); + var response = await client.UploadOperatorReportAsync( + payload.Reason, payload.Details, payload.ScreenshotPath, + Environment.MachineName, CancellationToken.None); + logger.LogInformation("Operator report uploaded: id={ReportId}", + response?.OperatorReportId); + return 0; + } + catch (Exception error) + { + faultMonitor.ReportFatal("--report", error); + return 1; + } + finally + { + // Remove only the transient JSON sidecar. The screenshot lives in the + // operator's %USERPROFILE%\ShopDBScreenshots folder and is deliberately + // KEPT so the operator can show it to a DT/specialist later; pruning it + // is the future server-driven Capture Cleanup job, not this path. (The + // sidecar previously sat in a throwaway temp folder we deleted whole; + // now it shares the operator's folder, so a recursive delete here would + // wipe saved screenshots.) + try + { + if (File.Exists(payloadPath)) + File.Delete(payloadPath); + } + catch { /* best-effort; the tray also removes the sidecar */ } + } +} + +// Read the value following a flag (e.g. --report C:\path\report.json). Returns +// null when the flag is absent or has no following token. +static string? ArgValue(string[] args, string flag) +{ + for (int i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], flag, StringComparison.OrdinalIgnoreCase)) + return args[i + 1]; + } + return null; +} + static ServiceProvider BuildOnceServices(AgentConfig config, string agentVersion, string logDirectory) { diff --git a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj index c877a7fa..63e33106 100644 --- a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj +++ b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj @@ -7,7 +7,23 @@ enable ShopDbEndpointAgent shopdbagent - - 1.6.1 + 1.6.3 true win-x64 true diff --git a/docs/ShopDB-Planner-Tasks-v2.csv b/docs/ShopDB-Planner-Tasks-v2.csv new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/components/PluginAssetPanels.vue b/frontend/src/components/PluginAssetPanels.vue index aba66951..335fd2ed 100644 --- a/frontend/src/components/PluginAssetPanels.vue +++ b/frontend/src/components/PluginAssetPanels.vue @@ -143,21 +143,170 @@ {{ b.label }} + + +
+ +
+ {{ panel.empty || 'No operator reports.' }} +
+
+ + +