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>
|
||||
|
||||
Reference in New Issue
Block a user