Fix user-context collection, split agent log destinations, widen upload cap
Mapped drives stopped reporting because the user-context path failed silently in three separate places, and none of them left a trace to read back. - Probe: launch via -EncodedCommand instead of staging a temp .ps1 and running powershell.exe -File. A missing or quarantined script file fails BEFORE any statement runs, so the script's own try/catch never sees it and the only symptom is exit -196608. - Spool: pin to the ShopFloor profile. Resolving it through WTSQueryUserToken is privileged, endpoint protection blocks it, and on failure the tray (writer) and service (reader) silently disagree about the path. - Spool: consume after a successful parse, so a stopped tray cannot keep re-reporting stale mapped drives as current. Unparseable files are left in place for the next cycle. - Logging: user-session components cannot write to Program Files or to the SYSTEM-owned ProgramData log directory, and the file sink degrades to a silent no-op when it cannot create its directory. Tray and probe now log to C:\Users\ShopFloor\ShopDB\Logs, installs transcript to ProgramData, and the sink records a SinkProblem instead of swallowing UnauthorizedAccessException. - Installer: provision the operator spool/log directories with ShopFloor:Modify before the tray first runs, and stop the transcript via trap so a throw cannot orphan exactly the failed install worth reading. - Upload cap 64 -> 128 MB: the self-contained tray publishes at ~69 MB and was rejected on SIZE, but the size check returns before the PE is parsed, so the operator only ever saw a version-detection failure. AGENT-CONTRACT.md gains a normative section defining the three security contexts, their log destinations, the fixed spool path and consume-on-read. Agent 1.8.0 -> 1.8.1, tray 1.1.0 -> 1.1.1 so the deployed build is identifiable from a log line.
This commit is contained in:
4
.github/copilot-instructions.md
vendored
4
.github/copilot-instructions.md
vendored
@@ -4,6 +4,10 @@ Follow these when suggesting code. They are enforced by a naming/style hook and
|
|||||||
by CI (`.github/workflows/ci.yml`) - suggestions that break them fail the build.
|
by CI (`.github/workflows/ci.yml`) - suggestions that break them fail the build.
|
||||||
`CONTRIBUTING.md` is the full authority; this is the short version.
|
`CONTRIBUTING.md` is the full authority; this is the short version.
|
||||||
|
|
||||||
|
## General Guidelines
|
||||||
|
- Finish the in-flight task before starting new workstreams.
|
||||||
|
- When asked to "take notes" on a future idea, capture it in the notes file only - do not implement it, and do not expand on it at length in replies.
|
||||||
|
|
||||||
## Naming (LOCKED - the hook rejects violations)
|
## Naming (LOCKED - the hook rejects violations)
|
||||||
|
|
||||||
- **DB tables**: lowercase, concatenated, plural. No underscores, no dashes.
|
- **DB tables**: lowercase, concatenated, plural. No underscores, no dashes.
|
||||||
|
|||||||
@@ -272,7 +272,13 @@ public sealed class EndpointCollector : ICollector
|
|||||||
return lastUserContext;
|
return lastUserContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
var collected = userContextSpool.TryReadResult(resultPath);
|
// Consume rather than peek: the file is removed once it has been read, so
|
||||||
|
// a tray that has died cannot keep this endpoint reporting the same
|
||||||
|
// mapped drives forever. lastUserContext still repeats the previous value
|
||||||
|
// between successful reads, because the tray collects on a much slower
|
||||||
|
// cadence than telemetry and dropping the key in between would read on
|
||||||
|
// the server as "the drives went away".
|
||||||
|
var collected = userContextSpool.ConsumeResult(resultPath);
|
||||||
if (collected is not null)
|
if (collected is not null)
|
||||||
lastUserContext = collected;
|
lastUserContext = collected;
|
||||||
|
|
||||||
|
|||||||
@@ -14,32 +14,76 @@ public sealed class FileLoggerProvider : ILoggerProvider
|
|||||||
private readonly string directory;
|
private readonly string directory;
|
||||||
private readonly LogLevel minLevel;
|
private readonly LogLevel minLevel;
|
||||||
private readonly int retentionDays;
|
private readonly int retentionDays;
|
||||||
|
private readonly string filePrefix;
|
||||||
|
private readonly bool stampFirst;
|
||||||
private readonly object gate = new();
|
private readonly object gate = new();
|
||||||
private readonly ConcurrentDictionary<string, FileLogger> loggers = new();
|
private readonly ConcurrentDictionary<string, FileLogger> loggers = new();
|
||||||
private string currentDateStamp = "";
|
private string currentDateStamp = "";
|
||||||
private StreamWriter? writer;
|
private StreamWriter? writer;
|
||||||
|
|
||||||
|
// Why the sink can be unusable, or null when it opened normally. The
|
||||||
|
// constructor cannot throw (see below), but a caller that has a second,
|
||||||
|
// known-writable place to complain to needs to be able to ask.
|
||||||
|
public string? SinkProblem { get; private set; }
|
||||||
|
|
||||||
public FileLoggerProvider(string directory, LogLevel minLevel = LogLevel.Information,
|
public FileLoggerProvider(string directory, LogLevel minLevel = LogLevel.Information,
|
||||||
int retentionDays = 14)
|
int retentionDays = 14, string filePrefix = "agent", bool stampFirst = false)
|
||||||
{
|
{
|
||||||
this.directory = directory;
|
this.directory = directory;
|
||||||
this.minLevel = minLevel;
|
this.minLevel = minLevel;
|
||||||
this.retentionDays = retentionDays;
|
this.retentionDays = retentionDays;
|
||||||
|
this.filePrefix = filePrefix;
|
||||||
|
this.stampFirst = stampFirst;
|
||||||
// Best-effort: creating the log directory can fail on a locked-down host
|
// Best-effort: creating the log directory can fail on a locked-down host
|
||||||
// or when a non-privileged user targets a SYSTEM-owned path. Never let
|
// or when a non-privileged user targets a SYSTEM-owned path. Never let
|
||||||
// that abort construction; Write() degrades to a no-op if the sink is
|
// that abort construction; Write() degrades to a no-op if the sink is
|
||||||
// unusable.
|
// unusable.
|
||||||
|
//
|
||||||
|
// The reason is KEPT rather than discarded. A silently dead sink is what
|
||||||
|
// made the user-context probe undiagnosable: running as the operator it
|
||||||
|
// could not write into the SYSTEM-owned ProgramData log directory, so a
|
||||||
|
// probe that failed and a probe that never ran produced identical
|
||||||
|
// silence. Callers that have a writable fallback can now surface this.
|
||||||
try { Directory.CreateDirectory(directory); }
|
try { Directory.CreateDirectory(directory); }
|
||||||
catch (Exception error) when (error is IOException or UnauthorizedAccessException) { }
|
catch (Exception error) when (error is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
SinkProblem = $"{directory}: {error.Message}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The default log directory when config leaves LogPath empty:
|
// The default log directory when config leaves LogPath empty:
|
||||||
// %ProgramData%\ShopDB\EndpointAgent\logs
|
// %ProgramData%\ShopDB\EndpointAgent\logs
|
||||||
|
//
|
||||||
|
// SERVICE-ONLY. This path is SYSTEM-owned and a signed-in operator cannot
|
||||||
|
// write to it, so anything running in the user's session must use
|
||||||
|
// UserProfileLogDirectory() instead.
|
||||||
public static string DefaultDirectory()
|
public static string DefaultDirectory()
|
||||||
=> Path.Combine(
|
=> Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||||
"ShopDB", "EndpointAgent", "logs");
|
"ShopDB", "EndpointAgent", "logs");
|
||||||
|
|
||||||
|
// The shop-floor operator account. Every shop-floor endpoint uses this same
|
||||||
|
// local account name, so the path is fixed rather than resolved through the
|
||||||
|
// interactive token: the token lookup is the part that fails on a hardened
|
||||||
|
// endpoint, and when it fails the writer and the reader silently disagree
|
||||||
|
// about where the spool lives. If a site ever uses a different account only
|
||||||
|
// user-context is lost - telemetry, commands and inventory are unaffected.
|
||||||
|
public const string ShopFloorUserName = "ShopFloor";
|
||||||
|
|
||||||
|
public static string ShopFloorProfileDirectory()
|
||||||
|
=> Path.Combine(DrivesRoot, "Users", ShopFloorUserName);
|
||||||
|
|
||||||
|
private static string DrivesRoot =>
|
||||||
|
Path.GetPathRoot(Environment.GetFolderPath(Environment.SpecialFolder.Windows))
|
||||||
|
?? @"C:\";
|
||||||
|
|
||||||
|
// Where the TRAY and the user-context probe log:
|
||||||
|
// C:\Users\ShopFloor\ShopDB\Logs
|
||||||
|
// The operator owns this directory, so it is writable from the user session
|
||||||
|
// where ProgramData is not.
|
||||||
|
public static string UserProfileLogDirectory()
|
||||||
|
=> Path.Combine(ShopFloorProfileDirectory(), "ShopDB", "Logs");
|
||||||
|
|
||||||
public ILogger CreateLogger(string categoryName)
|
public ILogger CreateLogger(string categoryName)
|
||||||
=> loggers.GetOrAdd(categoryName, name => new FileLogger(this, name));
|
=> loggers.GetOrAdd(categoryName, name => new FileLogger(this, name));
|
||||||
|
|
||||||
@@ -85,7 +129,11 @@ public sealed class FileLoggerProvider : ILoggerProvider
|
|||||||
writer?.Flush();
|
writer?.Flush();
|
||||||
writer?.Dispose();
|
writer?.Dispose();
|
||||||
currentDateStamp = stamp;
|
currentDateStamp = stamp;
|
||||||
var path = Path.Combine(directory, $"agent-{stamp}.log");
|
// stampFirst gives "<datetime>-trayagent.log"; the service keeps its
|
||||||
|
// established "agent-<date>.log" so existing log collection still finds
|
||||||
|
// it. Both roll daily and prune on the same pattern.
|
||||||
|
var name = stampFirst ? $"{stamp}-{filePrefix}.log" : $"{filePrefix}-{stamp}.log";
|
||||||
|
var path = Path.Combine(directory, name);
|
||||||
writer = new StreamWriter(new FileStream(
|
writer = new StreamWriter(new FileStream(
|
||||||
path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
|
path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
|
||||||
{
|
{
|
||||||
@@ -99,7 +147,8 @@ public sealed class FileLoggerProvider : ILoggerProvider
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var cutoff = DateTime.Now.AddDays(-retentionDays);
|
var cutoff = DateTime.Now.AddDays(-retentionDays);
|
||||||
foreach (var file in Directory.EnumerateFiles(directory, "agent-*.log"))
|
var pattern = stampFirst ? $"*-{filePrefix}.log" : $"{filePrefix}-*.log";
|
||||||
|
foreach (var file in Directory.EnumerateFiles(directory, pattern))
|
||||||
{
|
{
|
||||||
if (File.GetLastWriteTime(file) < cutoff)
|
if (File.GetLastWriteTime(file) < cutoff)
|
||||||
File.Delete(file);
|
File.Delete(file);
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ public sealed class UserContextProbe
|
|||||||
private const string ProbeScript = """
|
private const string ProbeScript = """
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$result = [ordered]@{}
|
$result = [ordered]@{}
|
||||||
|
# Must match UserContextSpool.TryGetResultPath on the reader side. Uses
|
||||||
|
# USERPROFILE, which for the shop-floor operator resolves to
|
||||||
|
# C:\Users\ShopFloor - the same fixed location the service reads.
|
||||||
$spool = Join-Path $env:USERPROFILE 'ShopDB'
|
$spool = Join-Path $env:USERPROFILE 'ShopDB'
|
||||||
$target = Join-Path $spool 'usercontext.json'
|
$target = Join-Path $spool 'usercontext.json'
|
||||||
$temporary = "$target.tmp"
|
$temporary = "$target.tmp"
|
||||||
@@ -145,6 +148,10 @@ public sealed class UserContextProbe
|
|||||||
// the telemetry path, so an unresponsive WMI call delays nothing but itself.
|
// the telemetry path, so an unresponsive WMI call delays nothing but itself.
|
||||||
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(60);
|
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
|
// powershell.exe's exit code when it rejects its command line and prints
|
||||||
|
// usage instead of running anything. Named so the failure is recognisable.
|
||||||
|
private const int PowerShellStartupFailure = -196608;
|
||||||
|
|
||||||
private readonly ILogger<UserContextProbe> logger;
|
private readonly ILogger<UserContextProbe> logger;
|
||||||
|
|
||||||
public UserContextProbe(ILogger<UserContextProbe> logger)
|
public UserContextProbe(ILogger<UserContextProbe> logger)
|
||||||
@@ -160,19 +167,34 @@ public sealed class UserContextProbe
|
|||||||
// failure must not take the tray or the agent process down with it.
|
// failure must not take the tray or the agent process down with it.
|
||||||
public bool Collect()
|
public bool Collect()
|
||||||
{
|
{
|
||||||
string? scriptPath = null;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
scriptPath = Path.Combine(
|
// Passed as -EncodedCommand rather than staged as a .ps1 and run with
|
||||||
Path.GetTempPath(), $"shopdb-usercontext-{Guid.NewGuid():N}.ps1");
|
// -File. The script used to be written into %TEMP% and executed from
|
||||||
File.WriteAllText(scriptPath, ProbeScript);
|
// there, which put a dependency on that file still existing, and still
|
||||||
|
// being readable, at the moment powershell.exe started. On a hardened
|
||||||
|
// endpoint that window is not safe: endpoint protection quarantines a
|
||||||
|
// freshly written script in the temp directory between the write and
|
||||||
|
// the launch, and roaming/redirected profiles can make the path
|
||||||
|
// unusable outright.
|
||||||
|
//
|
||||||
|
// When powershell.exe cannot use its -File argument it prints its usage
|
||||||
|
// banner and exits -196608 (0xFFFD0000) WITHOUT running a single
|
||||||
|
// statement. That is a startup failure, not a script failure, so the
|
||||||
|
// script's own try/catch never runs, nothing is logged about drives,
|
||||||
|
// and the operator sees a silent "no mapped drives reported" while the
|
||||||
|
// log only shows the bare exit code. -EncodedCommand carries the script
|
||||||
|
// in the command line itself, so there is no file to lose and no
|
||||||
|
// execution-policy or quarantine step between us and the probe.
|
||||||
|
var encoded = Convert.ToBase64String(
|
||||||
|
System.Text.Encoding.Unicode.GetBytes(ProbeScript));
|
||||||
|
|
||||||
var startInfo = new System.Diagnostics.ProcessStartInfo
|
var startInfo = new System.Diagnostics.ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = "powershell.exe",
|
FileName = "powershell.exe",
|
||||||
Arguments =
|
Arguments =
|
||||||
"-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "
|
"-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand "
|
||||||
+ "\"" + scriptPath + "\"",
|
+ encoded,
|
||||||
WorkingDirectory = Path.GetTempPath(),
|
WorkingDirectory = Path.GetTempPath(),
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
CreateNoWindow = true,
|
CreateNoWindow = true,
|
||||||
@@ -196,8 +218,16 @@ public sealed class UserContextProbe
|
|||||||
|
|
||||||
if (process.ExitCode != 0)
|
if (process.ExitCode != 0)
|
||||||
{
|
{
|
||||||
|
// 0xFFFD0000 is powershell.exe refusing its own command line and
|
||||||
|
// printing usage: the probe never ran. Called out by name because
|
||||||
|
// as a bare number it reads like a script error and sends the
|
||||||
|
// reader looking for a WMI or drive fault that is not there.
|
||||||
|
var detail = process.ExitCode == PowerShellStartupFailure
|
||||||
|
? " (powershell.exe rejected its command line; the probe did not run)"
|
||||||
|
: string.Empty;
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"The user-context probe exited with code {ExitCode}.", process.ExitCode);
|
"The user-context probe exited with code {ExitCode}.{Detail}",
|
||||||
|
process.ExitCode, detail);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,22 +238,6 @@ public sealed class UserContextProbe
|
|||||||
logger.LogWarning(exception, "The user-context probe failed.");
|
logger.LogWarning(exception, "The user-context probe failed.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (scriptPath is not null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (File.Exists(scriptPath))
|
|
||||||
File.Delete(scriptPath);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// A leftover temp script is harmless; it is uniquely named
|
|
||||||
// and cleaned by the OS.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TryKill(System.Diagnostics.Process process)
|
private void TryKill(System.Diagnostics.Process process)
|
||||||
|
|||||||
@@ -47,16 +47,36 @@ public sealed class UserContextSpool
|
|||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full path to the spool file for the signed-in user, or null (with a
|
// Full path to the spool file. Fixed at C:\Users\ShopFloor\ShopDB, because
|
||||||
// reason) when nobody is signed in. No session means no session-scoped data
|
// every shop-floor endpoint signs in as the same local ShopFloor account.
|
||||||
// to collect, which is a normal state on an unattended machine, not a fault.
|
//
|
||||||
|
// This used to be resolved through WTSQueryUserToken/GetUserProfileDirectory,
|
||||||
|
// and that lookup is precisely what fails on a hardened endpoint: querying
|
||||||
|
// another session's token is a privileged operation that endpoint protection
|
||||||
|
// blocks. When it failed the service simply reported "no session data" while
|
||||||
|
// the tray was writing a perfectly good file, so writer and reader silently
|
||||||
|
// disagreed about the location and the operator saw an empty card.
|
||||||
|
//
|
||||||
|
// The token lookup is kept ONLY as a fallback for a machine whose operator
|
||||||
|
// account is named something else. If both are unavailable there is no
|
||||||
|
// session data, which costs mapped drives and idle time but never affects
|
||||||
|
// telemetry, commands or inventory.
|
||||||
public string? TryGetResultPath(out string? reason)
|
public string? TryGetResultPath(out string? reason)
|
||||||
{
|
{
|
||||||
|
var pinned = FileLoggerProvider.ShopFloorProfileDirectory();
|
||||||
|
if (Directory.Exists(pinned))
|
||||||
|
{
|
||||||
|
reason = null;
|
||||||
|
return Path.Combine(pinned, SpoolFolderName, ResultFileName);
|
||||||
|
}
|
||||||
|
|
||||||
var profileDirectory =
|
var profileDirectory =
|
||||||
InteractiveProcessLauncher.TryGetActiveUserProfileDirectory(out var error);
|
InteractiveProcessLauncher.TryGetActiveUserProfileDirectory(out var error);
|
||||||
if (string.IsNullOrWhiteSpace(profileDirectory))
|
if (string.IsNullOrWhiteSpace(profileDirectory))
|
||||||
{
|
{
|
||||||
reason = error ?? "The interactive user profile could not be resolved.";
|
reason = $"No {FileLoggerProvider.ShopFloorUserName} profile at {pinned}, "
|
||||||
|
+ "and the interactive profile could not be resolved either: "
|
||||||
|
+ (error ?? "unknown error");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +179,38 @@ public sealed class UserContextSpool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read the spool and then REMOVE it, so the next report can only carry a
|
||||||
|
// result the tray produced after this read. Leaving a consumed file in place
|
||||||
|
// means a tray that has stopped running keeps re-reporting the same drives
|
||||||
|
// as though they were current, which is worse than reporting nothing: the
|
||||||
|
// card looks healthy while the data is frozen.
|
||||||
|
//
|
||||||
|
// The removal is deliberately AFTER a successful parse. If the file was
|
||||||
|
// mid-write (truncated JSON) it is left alone rather than destroyed, so a
|
||||||
|
// read that raced the tray costs one cycle instead of a good result. The
|
||||||
|
// collectedat age check still applies as a second bound for the window
|
||||||
|
// between the tray writing and the service next waking up.
|
||||||
|
public Dictionary<string, object?>? ConsumeResult(string resultPath)
|
||||||
|
{
|
||||||
|
var parsed = TryReadResult(resultPath);
|
||||||
|
if (parsed is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(resultPath);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
// Non-fatal: the value was already read. It does mean the next cycle
|
||||||
|
// may re-read the same result, which the age check will retire.
|
||||||
|
logger.LogWarning(exception,
|
||||||
|
"Read the user-context spool but could not remove it at {Path}", resultPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
// Age is taken from the stamp the probe wrote, not from the file's last
|
// Age is taken from the stamp the probe wrote, not from the file's last
|
||||||
// write time, because the file can be copied or restored with a roaming
|
// write time, because the file can be copied or restored with a roaming
|
||||||
// profile and carry a misleading timestamp. A result with no readable stamp
|
// profile and carry a misleading timestamp. A result with no readable stamp
|
||||||
|
|||||||
@@ -21,7 +21,14 @@
|
|||||||
service version independently (they are separate exes and can be pushed
|
service version independently (they are separate exes and can be pushed
|
||||||
apart), so the tray carries its own <Version> and the agent reports it
|
apart), so the tray carries its own <Version> and the agent reports it
|
||||||
as trayversion alongside its own agentversion (AGENT-CONTRACT.md 2). -->
|
as trayversion alongside its own agentversion (AGENT-CONTRACT.md 2). -->
|
||||||
<Version>1.1.0</Version>
|
<!-- 1.1.1: the tray now logs. Every user-context failure path was silent -
|
||||||
|
a missing shopdbagent.exe returned bare, and the probe launch was
|
||||||
|
wrapped in an empty catch - so "no mapped drives" produced no evidence
|
||||||
|
anywhere on the machine. The tray writes
|
||||||
|
C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log (the operator's
|
||||||
|
own profile; it cannot write to ProgramData or Program Files) covering
|
||||||
|
tray startup, agent-exe resolution, probe launch and probe exit code. -->
|
||||||
|
<Version>1.1.1</Version>
|
||||||
<InvariantGlobalization>true</InvariantGlobalization>
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
<SelfContained>true</SelfContained>
|
<SelfContained>true</SelfContained>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
using ShopDbEndpointAgent.Core;
|
using ShopDbEndpointAgent.Core;
|
||||||
|
|
||||||
namespace ShopDbEndpointAgent.Tray;
|
namespace ShopDbEndpointAgent.Tray;
|
||||||
@@ -44,8 +46,27 @@ internal sealed class TrayContext : ApplicationContext
|
|||||||
private bool userContextStarted;
|
private bool userContextStarted;
|
||||||
private bool collectingUserContext;
|
private bool collectingUserContext;
|
||||||
|
|
||||||
|
// The tray logs into the operator's own profile
|
||||||
|
// (C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log), NOT into the
|
||||||
|
// service's ProgramData directory. The operator cannot write there, and the
|
||||||
|
// file sink fails silently when it cannot create its directory, so tray-side
|
||||||
|
// failures previously left no trace anywhere. Keeping the two apart means a
|
||||||
|
// tray problem is diagnosable from a file the operator can actually reach.
|
||||||
|
private readonly FileLoggerProvider logProvider;
|
||||||
|
private readonly ILogger logger;
|
||||||
|
|
||||||
public TrayContext()
|
public TrayContext()
|
||||||
{
|
{
|
||||||
|
logProvider = new FileLoggerProvider(
|
||||||
|
FileLoggerProvider.UserProfileLogDirectory(),
|
||||||
|
filePrefix: "trayagent",
|
||||||
|
stampFirst: true);
|
||||||
|
logger = logProvider.CreateLogger("Tray");
|
||||||
|
logger.LogInformation(
|
||||||
|
"Tray {Version} started; user-context probe in {Delay}, then every {Interval}.",
|
||||||
|
typeof(TrayContext).Assembly.GetName().Version?.ToString() ?? "unknown",
|
||||||
|
UserContextStartupDelay, UserContextInterval);
|
||||||
|
|
||||||
var menu = new ContextMenuStrip();
|
var menu = new ContextMenuStrip();
|
||||||
menu.Items.Add("Send Status now", null, OnSendStatus);
|
menu.Items.Add("Send Status now", null, OnSendStatus);
|
||||||
menu.Items.Add("Report an issue...", null, OnReportIssue);
|
menu.Items.Add("Report an issue...", null, OnReportIssue);
|
||||||
@@ -90,16 +111,39 @@ internal sealed class TrayContext : ApplicationContext
|
|||||||
|
|
||||||
var agentExe = ResolveAgentExe();
|
var agentExe = ResolveAgentExe();
|
||||||
if (agentExe is null)
|
if (agentExe is null)
|
||||||
|
{
|
||||||
|
// Previously a bare return. This is the single most likely reason for
|
||||||
|
// "no mapped drives" with nothing at all in any log, so it must say so.
|
||||||
|
logger.LogWarning(
|
||||||
|
"Skipping the user-context probe: {Agent} was not found beside the tray "
|
||||||
|
+ "({Beside}) or at {Installed}.",
|
||||||
|
AgentExeName, AppContext.BaseDirectory, DefaultInstallDir);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
collectingUserContext = true;
|
collectingUserContext = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await RunAgentAsync(agentExe, "--collect-usercontext");
|
logger.LogInformation("Running the user-context probe: {Agent}", agentExe);
|
||||||
|
var exitCode = await RunAgentAsync(agentExe, "--collect-usercontext");
|
||||||
|
if (exitCode == 0)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"The user-context probe finished; spool written to {Spool}.",
|
||||||
|
Path.Combine(
|
||||||
|
FileLoggerProvider.ShopFloorProfileDirectory(), "ShopDB"));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"The user-context probe exited with code {ExitCode}.", exitCode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
// Intentionally silent. The agent logs the detail to its own file.
|
// No longer silent. The agent logs its own detail, but if the process
|
||||||
|
// could not even be started the agent never ran and never logged.
|
||||||
|
logger.LogWarning(exception, "Could not run the user-context probe.");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -50,8 +50,18 @@ var logDirectory = string.IsNullOrWhiteSpace(config.LogPath)
|
|||||||
// diagnose.
|
// diagnose.
|
||||||
if (collectUserContext)
|
if (collectUserContext)
|
||||||
{
|
{
|
||||||
|
// Logs to the OPERATOR's profile, not the service's ProgramData directory.
|
||||||
|
// This verb always runs as the signed-in user (the tray invokes it), and a
|
||||||
|
// non-privileged user cannot write to the SYSTEM-owned log directory. The
|
||||||
|
// file sink degrades to a no-op when it cannot create its directory, so
|
||||||
|
// pointing this at the service location meant every probe failure was
|
||||||
|
// discarded unread - which is exactly how a broken probe looked identical to
|
||||||
|
// a probe that was never invoked.
|
||||||
using var probeLoggerFactory = LoggerFactory.Create(logging =>
|
using var probeLoggerFactory = LoggerFactory.Create(logging =>
|
||||||
logging.AddProvider(new FileLoggerProvider(logDirectory)));
|
logging.AddProvider(new FileLoggerProvider(
|
||||||
|
FileLoggerProvider.UserProfileLogDirectory(),
|
||||||
|
filePrefix: "trayagent",
|
||||||
|
stampFirst: true)));
|
||||||
var probe = new UserContextProbe(
|
var probe = new UserContextProbe(
|
||||||
probeLoggerFactory.CreateLogger<UserContextProbe>());
|
probeLoggerFactory.CreateLogger<UserContextProbe>());
|
||||||
return probe.Collect() ? 0 : 1;
|
return probe.Collect() ? 0 : 1;
|
||||||
|
|||||||
@@ -7,6 +7,22 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
|
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
|
||||||
<AssemblyName>shopdbagent</AssemblyName>
|
<AssemblyName>shopdbagent</AssemblyName>
|
||||||
|
<!-- 1.8.1: user-context logging and spool location are split from the
|
||||||
|
service's. The probe runs as the signed-in operator but logged into
|
||||||
|
%ProgramData%\ShopDB\EndpointAgent\logs, which is SYSTEM-owned and not
|
||||||
|
writable from a user session; the file sink swallows that failure, so a
|
||||||
|
probe that failed and a probe that never ran looked identical - no log
|
||||||
|
line either way. The probe and the tray now log to
|
||||||
|
C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log, which the
|
||||||
|
operator owns. The spool path is pinned to C:\Users\ShopFloor\ShopDB
|
||||||
|
instead of being resolved through WTSQueryUserToken (a privileged call
|
||||||
|
endpoint protection blocks, after which writer and reader disagreed
|
||||||
|
silently about the location). The service now CONSUMES the spool -
|
||||||
|
removing it after a successful parse - so a dead tray stops re-reporting
|
||||||
|
stale drives instead of pinning them forever. The probe itself is
|
||||||
|
launched with -EncodedCommand rather than a staged .ps1 run via -File:
|
||||||
|
a quarantined or missing temp script makes powershell.exe print usage
|
||||||
|
and exit -196608 without running a statement. -->
|
||||||
<!-- 1.8.0: user-context collection is inverted. The service no longer
|
<!-- 1.8.0: user-context collection is inverted. The service no longer
|
||||||
descends into the operator's session with CreateProcessAsUser to run the
|
descends into the operator's session with CreateProcessAsUser to run the
|
||||||
probe; that path needs to duplicate a token, is what endpoint protection
|
probe; that path needs to duplicate a token, is what endpoint protection
|
||||||
@@ -74,7 +90,7 @@
|
|||||||
(AgentConfig.LaunchPaths, shipped defaults overridable per site).
|
(AgentConfig.LaunchPaths, shipped defaults overridable per site).
|
||||||
1.1.0: generic, data-driven service_*/process_* actions carry their
|
1.1.0: generic, data-driven service_*/process_* actions carry their
|
||||||
target over the wire, so new policies/apps need no agent rebuild. -->
|
target over the wire, so new policies/apps need no agent rebuild. -->
|
||||||
<Version>1.8.0</Version>
|
<Version>1.8.1</Version>
|
||||||
<InvariantGlobalization>true</InvariantGlobalization>
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
<SelfContained>true</SelfContained>
|
<SelfContained>true</SelfContained>
|
||||||
|
|||||||
@@ -157,10 +157,49 @@ function Uninstall-PreviousAgent {
|
|||||||
Start-Sleep -Milliseconds 500
|
Start-Sleep -Milliseconds 500
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Installer transcript. Deliberately SEPARATE from both the service log
|
||||||
|
# (%ProgramData%\ShopDB\EndpointAgent\logs\agent-<date>.log) and the tray log
|
||||||
|
# (C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log): the installer runs
|
||||||
|
# elevated and one-shot, and mixing its output into the service's rolling log
|
||||||
|
# made an install indistinguishable from runtime behaviour when reading back a
|
||||||
|
# failure. This runs as admin, so ProgramData is writable here.
|
||||||
|
$installLogDir = Join-Path $env:ProgramData 'ShopDB\Logs'
|
||||||
|
try {
|
||||||
|
New-Item -ItemType Directory -Path $installLogDir -Force | Out-Null
|
||||||
|
$installLog = Join-Path $installLogDir (
|
||||||
|
'{0}-shopdbinstalls.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
|
||||||
|
Start-Transcript -Path $installLog -Append | Out-Null
|
||||||
|
$transcriptStarted = $true
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# Never let logging block an install.
|
||||||
|
$transcriptStarted = $false
|
||||||
|
Write-Warning "could not start the install transcript: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The shop-floor operator profile. The tray and the user-context probe run as
|
||||||
|
# this local account on every shop-floor endpoint, and it is the one place both
|
||||||
|
# the operator (writer) and the SYSTEM service (reader) can reach. Defined here
|
||||||
|
# so the install can provision the directories and report their location.
|
||||||
|
$shopFloorProfile = Join-Path (Join-Path $env:SystemDrive 'Users') 'ShopFloor'
|
||||||
|
|
||||||
|
# Stop the transcript on ANY terminating error, not just the success path. The
|
||||||
|
# script throws on several validation and smoke-test failures, and those are
|
||||||
|
# precisely the installs worth reading back afterwards - an orphaned transcript
|
||||||
|
# would truncate exactly the failure it was started to capture.
|
||||||
|
trap {
|
||||||
|
if ($transcriptStarted) {
|
||||||
|
try { Stop-Transcript | Out-Null } catch { }
|
||||||
|
$script:transcriptStarted = $false
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host "== ShopDB Endpoint Agent install ==" -ForegroundColor Cyan
|
Write-Host "== ShopDB Endpoint Agent install ==" -ForegroundColor Cyan
|
||||||
Write-Host "BaseUrl : $BaseUrl"
|
Write-Host "BaseUrl : $BaseUrl"
|
||||||
Write-Host "InstallDir : $InstallDir"
|
Write-Host "InstallDir : $InstallDir"
|
||||||
Write-Host "ServiceName : $ServiceName"
|
Write-Host "ServiceName : $ServiceName"
|
||||||
|
if ($transcriptStarted) { Write-Host "InstallLog : $installLog" }
|
||||||
|
|
||||||
# 0. Clear the Mark-of-the-Web on the SOURCE payload first. The operator
|
# 0. Clear the Mark-of-the-Web on the SOURCE payload first. The operator
|
||||||
# typically extracts installer.zip into Downloads, so every file carries a
|
# typically extracts installer.zip into Downloads, so every file carries a
|
||||||
@@ -188,6 +227,43 @@ Write-Host "`n[1/5] Provisioning registry $regPath (Registry64) ..."
|
|||||||
Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "BaseUrl" -Value $BaseUrl
|
Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "BaseUrl" -Value $BaseUrl
|
||||||
Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "CollectorKey" -Value $CollectorKey
|
Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "CollectorKey" -Value $CollectorKey
|
||||||
|
|
||||||
|
# 1a. Provision the user-context spool and tray log directories in the operator
|
||||||
|
# profile, and grant the operator Modify on them.
|
||||||
|
#
|
||||||
|
# This is the handoff point between the two security contexts (AGENT-CONTRACT.md
|
||||||
|
# section 5): the tray WRITES here as the ShopFloor operator, and the SYSTEM
|
||||||
|
# service READS and then removes the spool. The operator cannot write to
|
||||||
|
# Program Files or to the SYSTEM-owned ProgramData log directory, and the file
|
||||||
|
# log sink degrades to a silent no-op when it cannot create its directory - so
|
||||||
|
# without this step a tray-side failure leaves no trace anywhere on the machine.
|
||||||
|
# Creating them here, elevated, means the directories exist and are writable
|
||||||
|
# before the tray ever starts.
|
||||||
|
Write-Host "[1a/5] Provisioning operator spool/log directories under $shopFloorProfile ..."
|
||||||
|
if (Test-Path $shopFloorProfile) {
|
||||||
|
foreach ($dir in @(
|
||||||
|
(Join-Path $shopFloorProfile 'ShopDB'),
|
||||||
|
(Join-Path $shopFloorProfile 'ShopDB\Logs'))) {
|
||||||
|
try {
|
||||||
|
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||||
|
$acl = Get-Acl $dir
|
||||||
|
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
||||||
|
'ShopFloor', 'Modify',
|
||||||
|
'ContainerInherit,ObjectInherit', 'None', 'Allow')
|
||||||
|
$acl.SetAccessRule($rule)
|
||||||
|
Set-Acl -Path $dir -AclObject $acl
|
||||||
|
Write-Host " provisioned $dir (ShopFloor: Modify)"
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# Non-fatal: telemetry, commands and inventory are unaffected. Only
|
||||||
|
# user-context (mapped drives, idle time) depends on this path.
|
||||||
|
Write-Warning " could not provision $dir : $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Warning " no ShopFloor profile at $shopFloorProfile; user-context (mapped drives, idle time) will be unavailable until that user has logged in once."
|
||||||
|
}
|
||||||
|
|
||||||
# 2. Copy agent files to the install directory. The agent and tray are
|
# 2. Copy agent files to the install directory. The agent and tray are
|
||||||
# single-file self-contained executables, so the .exe plus appsettings.json is
|
# single-file self-contained executables, so the .exe plus appsettings.json is
|
||||||
# the whole payload; there are no side-by-side runtime DLLs to copy.
|
# the whole payload; there are no side-by-side runtime DLLs to copy.
|
||||||
@@ -352,4 +428,10 @@ else {
|
|||||||
|
|
||||||
Write-Host "`nInstall complete." -ForegroundColor Green
|
Write-Host "`nInstall complete." -ForegroundColor Green
|
||||||
Write-Host "Service '$ServiceName' is running and set to auto-start."
|
Write-Host "Service '$ServiceName' is running and set to auto-start."
|
||||||
Write-Host "Logs: $logDir"
|
# The three log destinations are separate on purpose; see AGENT-CONTRACT.md.
|
||||||
|
Write-Host "Service log : $logDir"
|
||||||
|
Write-Host "Tray log : $(Join-Path $shopFloorProfile 'ShopDB\Logs')"
|
||||||
|
if ($transcriptStarted) {
|
||||||
|
Write-Host "Install log : $installLog"
|
||||||
|
try { Stop-Transcript | Out-Null } catch { }
|
||||||
|
}
|
||||||
|
|||||||
@@ -446,6 +446,15 @@ const props = defineProps({
|
|||||||
// the detail pages that have not adopted tabs still do - so this stays
|
// the detail pages that have not adopted tabs still do - so this stays
|
||||||
// additive and no existing page changes behaviour.
|
// additive and no existing page changes behaviour.
|
||||||
group: { type: String, default: null },
|
group: { type: String, default: null },
|
||||||
|
// Render exactly these panel ids, ignoring group. For lifting one card out
|
||||||
|
// of its tab and showing it somewhere else - Network Interfaces is pinned to
|
||||||
|
// the top of Details while still belonging to monitors. Kept as a panel-id
|
||||||
|
// filter rather than a new group because a group would need the four-file
|
||||||
|
// vocabulary change AND would add a tab nobody asked for.
|
||||||
|
only: { type: Array, default: null },
|
||||||
|
// The other half of `only`: the group instance must drop what was lifted out
|
||||||
|
// of it, or the card renders twice on two different tabs.
|
||||||
|
exclude: { type: Array, default: null },
|
||||||
})
|
})
|
||||||
|
|
||||||
const panels = ref([])
|
const panels = ref([])
|
||||||
@@ -466,9 +475,14 @@ function groupOf(panel) {
|
|||||||
return RENDERED_GROUPS.includes(panel.group) ? panel.group : 'details'
|
return RENDERED_GROUPS.includes(panel.group) ? panel.group : 'details'
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupedPanels = computed(() =>
|
const groupedPanels = computed(() => {
|
||||||
props.group ? panels.value.filter((p) => groupOf(p) === props.group) : panels.value
|
if (props.only) return panels.value.filter((p) => props.only.includes(p.id))
|
||||||
)
|
let list = props.group
|
||||||
|
? panels.value.filter((p) => groupOf(p) === props.group)
|
||||||
|
: panels.value
|
||||||
|
if (props.exclude) list = list.filter((p) => !props.exclude.includes(p.id))
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
// Which groups actually have a panel with data, so a parent can hide a tab
|
// Which groups actually have a panel with data, so a parent can hide a tab
|
||||||
// rather than offering an empty one.
|
// rather than offering an empty one.
|
||||||
|
|||||||
203
frontend/src/components/pinnedPanels.spec.js
Normal file
203
frontend/src/components/pinnedPanels.spec.js
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
// Network Interfaces is DECLARED in the monitors group but RENDERED at the top
|
||||||
|
// of the Details tab. That is done with a panel-id filter (`only`) rather than
|
||||||
|
// a new group, because a new group would need the four-file vocabulary change
|
||||||
|
// and would add a tab nobody asked for.
|
||||||
|
//
|
||||||
|
// The pair is the fragile part: `only` lifts the panel out, and `exclude` must
|
||||||
|
// drop it from the group instance it was lifted from. Miss the second half and
|
||||||
|
// the same card renders on two tabs, which looks like duplicated data rather
|
||||||
|
// than a layout bug.
|
||||||
|
|
||||||
|
function read(relative) {
|
||||||
|
return readFileSync(fileURLToPath(new URL(relative, import.meta.url)), 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
const panelsSource = () => read('./PluginAssetPanels.vue')
|
||||||
|
const pcDetailSource = () => read('../../../plugins/computers/frontend/views/PCDetail.vue')
|
||||||
|
|
||||||
|
describe('lifting one panel out of its group', () => {
|
||||||
|
it('accepts an only prop', () => {
|
||||||
|
expect(panelsSource()).toMatch(/only:\s*\{\s*type:\s*Array/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts an exclude prop', () => {
|
||||||
|
expect(panelsSource()).toMatch(/exclude:\s*\{\s*type:\s*Array/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets only bypass the group filter entirely', () => {
|
||||||
|
// Details renders a panel belonging to monitors, so `only` must NOT be
|
||||||
|
// and-ed with the group or it would match nothing.
|
||||||
|
const source = panelsSource()
|
||||||
|
const start = source.indexOf('const groupedPanels')
|
||||||
|
const block = source.slice(start, start + 400)
|
||||||
|
expect(block).toMatch(/if\s*\(props\.only\)/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('the pinned Network Interfaces card', () => {
|
||||||
|
it('renders at the top of Details by panel id', () => {
|
||||||
|
expect(pcDetailSource()).toContain("only=\"['endpoints-interfaces']\"")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is excluded from System Monitors so it cannot render twice', () => {
|
||||||
|
// Scoped to the monitors instance and matched on membership, not on the
|
||||||
|
// exact array text: more than one panel is lifted now.
|
||||||
|
const source = pcDetailSource()
|
||||||
|
const start = source.indexOf('group="monitors"')
|
||||||
|
const block = source.slice(start, start + 200)
|
||||||
|
expect(block).toContain('endpoints-interfaces')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is still declared in monitors, not moved to a new group', () => {
|
||||||
|
// One declaration, no vocabulary change. If somebody "fixes" this by
|
||||||
|
// adding a reports-style group, the four-file dance applies again.
|
||||||
|
// Bounded by the NEXT panel's id: the columns list contains '},' so
|
||||||
|
// slicing to that would cut the block off before the group key.
|
||||||
|
const plugin = read('../../../plugins/endpoints/plugin.py')
|
||||||
|
const start = plugin.indexOf("'id': 'endpoints-interfaces'")
|
||||||
|
const next = plugin.indexOf("'id': '", start + 10)
|
||||||
|
const block = plugin.slice(start, next === -1 ? undefined : next)
|
||||||
|
expect(block).toContain("'group': 'monitors'")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('the header cloud identity block', () => {
|
||||||
|
it('shows the block on template presence, not on this PC having an id', () => {
|
||||||
|
// A site with no Intune/Entra at all must not see a permanent request for
|
||||||
|
// an id it will never have.
|
||||||
|
const source = pcDetailSource()
|
||||||
|
const start = source.indexOf('const hasCloudIdentity')
|
||||||
|
const block = source.slice(start, start + 260)
|
||||||
|
expect(block).toContain('entraUrlTemplate.value')
|
||||||
|
expect(block).toContain('intuneUrlTemplate.value')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('waits for the portal templates before rendering', () => {
|
||||||
|
// The templates come from an async settings fetch. Rendering the ids
|
||||||
|
// first and adding the buttons a moment later looked like the buttons
|
||||||
|
// were missing until the page was manually refreshed.
|
||||||
|
const source = pcDetailSource()
|
||||||
|
const start = source.indexOf('const hasCloudIdentity')
|
||||||
|
const block = source.slice(start, start + 260)
|
||||||
|
expect(block).toContain('portalTemplatesLoaded.value')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the templates settled even when the fetch fails', () => {
|
||||||
|
// Otherwise a down settings backend hides the block permanently.
|
||||||
|
const source = pcDetailSource()
|
||||||
|
const start = source.indexOf('getEntraDeviceUrls()')
|
||||||
|
const block = source.slice(start, start + 420)
|
||||||
|
expect(block).toContain('finally')
|
||||||
|
expect(block).toContain('portalTemplatesLoaded.value = true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('names which id is missing rather than rendering an empty block', () => {
|
||||||
|
const source = pcDetailSource()
|
||||||
|
expect(source).toContain('Please provide DeviceID')
|
||||||
|
expect(source).toContain('Please provide ObjectID')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('no longer carries the old Entra Device Info card', () => {
|
||||||
|
expect(pcDetailSource()).not.toContain('<h3 class="section-title">Entra Device Info</h3>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('no longer carries the hand-written Network card', () => {
|
||||||
|
// Its two halves are the IP History and IP Configuration panels now.
|
||||||
|
expect(pcDetailSource()).not.toContain('<h3 class="section-title">Network</h3>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the full ids on screen so they can be copied', () => {
|
||||||
|
// The move to the header was a MOVE, not a redesign. Rendering only an
|
||||||
|
// "Open device" link loses the id itself, which gets pasted into tickets
|
||||||
|
// and Graph queries. Both ids must still be printed in full.
|
||||||
|
const source = pcDetailSource()
|
||||||
|
expect(source).toContain('computer.computer.azuredeviceid }}')
|
||||||
|
expect(source).toContain('computer.computer.azureobjectid }}')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers a copy button for each id', () => {
|
||||||
|
// Clicking to select still leaves the operator to press ctrl+c; these ids
|
||||||
|
// get pasted into tickets and Graph queries constantly.
|
||||||
|
const source = pcDetailSource()
|
||||||
|
expect(source).toContain("copyId(computer.computer.azuredeviceid, 'Device ID')")
|
||||||
|
expect(source).toContain("copyId(computer.computer.azureobjectid, 'Object ID')")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a failed copy instead of a false success', () => {
|
||||||
|
// clipboard.writeText is undefined over plain HTTP and rejects when the
|
||||||
|
// tab is unfocused. Claiming success there would be a lie.
|
||||||
|
const source = pcDetailSource()
|
||||||
|
const start = source.indexOf('async function copyId')
|
||||||
|
const block = source.slice(start, start + 420)
|
||||||
|
expect(block).toContain('catch')
|
||||||
|
expect(block).toContain('toast.error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the id selectable as a clipboard fallback', () => {
|
||||||
|
const source = pcDetailSource()
|
||||||
|
const start = source.indexOf('.hero-portal-id {')
|
||||||
|
const block = source.slice(start, start + 260)
|
||||||
|
expect(block).toContain('user-select: all')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not truncate an id with an ellipsis', () => {
|
||||||
|
// A half-copied GUID is worse than no GUID: it looks complete.
|
||||||
|
const source = pcDetailSource()
|
||||||
|
const start = source.indexOf('.hero-portal-id {')
|
||||||
|
const block = source.slice(start, start + 260)
|
||||||
|
expect(block).not.toContain('text-overflow: ellipsis')
|
||||||
|
expect(block).toContain('user-select: all')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the original Intune and Entra buttons', () => {
|
||||||
|
// These carry a race-condition fix. Relabelling them to "Open device" /
|
||||||
|
// "Open object" threw that away once already.
|
||||||
|
const source = pcDetailSource()
|
||||||
|
expect(source).toContain('>Intune</a>')
|
||||||
|
expect(source).toContain('>Entra</a>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lays the portal block out horizontally, not as a narrow rail', () => {
|
||||||
|
// A fixed-width right rail wrapped 36-character GUIDs into a tall ragged
|
||||||
|
// stack that grew past the identity block beside it.
|
||||||
|
const source = pcDetailSource()
|
||||||
|
const start = source.indexOf('.hero-portals {')
|
||||||
|
const block = source.slice(start, start + 260)
|
||||||
|
expect(block).toContain('flex-wrap: wrap')
|
||||||
|
expect(block).not.toContain('width: 300px')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not restyle the header classes other assets share', () => {
|
||||||
|
// Machines, Printers and friends share .hero-details/.hero-detail-* from
|
||||||
|
// global style.css. A PC-only header must not redefine them.
|
||||||
|
const source = pcDetailSource()
|
||||||
|
const style = source.slice(source.indexOf('<style scoped>'))
|
||||||
|
expect(style).not.toContain('.hero-details {')
|
||||||
|
expect(style).not.toContain('.hero-detail-label {')
|
||||||
|
expect(style).not.toContain('.hero-detail-value {')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('the lifted Mapped drives card', () => {
|
||||||
|
it('renders on Details', () => {
|
||||||
|
expect(pcDetailSource()).toContain("['endpoints-mappeddrives']")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is excluded from System Monitors so it renders once', () => {
|
||||||
|
const source = pcDetailSource()
|
||||||
|
const start = source.indexOf('group="monitors"')
|
||||||
|
const block = source.slice(start, start + 200)
|
||||||
|
expect(block).toContain('endpoints-mappeddrives')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is still declared in monitors, not moved to a new group', () => {
|
||||||
|
const plugin = read('../../../plugins/endpoints/plugin.py')
|
||||||
|
const start = plugin.indexOf("'id': 'endpoints-mappeddrives'")
|
||||||
|
const block = plugin.slice(start, start + 1200)
|
||||||
|
expect(block).toContain("'group': 'monitors'")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -34,32 +34,103 @@
|
|||||||
<img :src="withBase(computer.computer.imageurl)" :alt="computer.computer.modelname || 'Model photo'" />
|
<img :src="withBase(computer.computer.imageurl)" :alt="computer.computer.modelname || 'Model photo'" />
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-content">
|
<div class="hero-content">
|
||||||
<div class="hero-title">
|
<!-- Identity, then the cloud row beneath it. An earlier cut put the
|
||||||
<h1>{{ computer.assetnumber }}</h1>
|
portal block in a fixed-width rail on the right; GUIDs are too
|
||||||
<span v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span>
|
long for a narrow column and it wrapped into a tall ragged
|
||||||
</div>
|
stack. Full width in the normal flow costs one short row. -->
|
||||||
<div class="hero-meta">
|
<div class="hero-primary">
|
||||||
<span class="badge badge-lg badge-info">Computer</span>
|
<div class="hero-identity">
|
||||||
<span class="badge badge-lg" :style="colorStyle(computer.statuscolor)">
|
<div class="hero-title">
|
||||||
{{ computer.statusname || 'Unknown' }}
|
<h1>{{ computer.assetnumber }}</h1>
|
||||||
</span>
|
<span v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span>
|
||||||
<span v-if="heroWarranty" class="badge badge-lg" :style="colorStyle(heroWarranty.statuscolor)"
|
</div>
|
||||||
:title="heroWarranty.enddate ? `Warranty ends ${formatWarrantyDate(heroWarranty.enddate)}` : 'Warranty'">
|
<div class="hero-meta">
|
||||||
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ formatWarrantyDate(heroWarranty.enddate) }}</template>
|
<span class="badge badge-lg badge-info">Computer</span>
|
||||||
</span>
|
<span class="badge badge-lg" :style="colorStyle(computer.statuscolor)">
|
||||||
</div>
|
{{ computer.statusname || 'Unknown' }}
|
||||||
<div class="hero-details">
|
</span>
|
||||||
<div class="hero-detail" v-if="computer.computer?.computertypename">
|
<span v-if="heroWarranty" class="badge badge-lg" :style="colorStyle(heroWarranty.statuscolor)"
|
||||||
<span class="hero-detail-label">PC Type</span>
|
:title="heroWarranty.enddate ? `Warranty ends ${formatWarrantyDate(heroWarranty.enddate)}` : 'Warranty'">
|
||||||
<span class="hero-detail-value">{{ computer.computer.computertypename }}</span>
|
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ formatWarrantyDate(heroWarranty.enddate) }}</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="hero-details">
|
||||||
|
<div class="hero-detail" v-if="computer.computer?.computertypename">
|
||||||
|
<span class="hero-detail-label">PC Type</span>
|
||||||
|
<span class="hero-detail-value">{{ computer.computer.computertypename }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="hero-detail" v-if="computer.computer?.osname">
|
||||||
|
<span class="hero-detail-label">OS</span>
|
||||||
|
<span class="hero-detail-value">{{ computer.computer.osname }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="hero-detail" v-if="computer.locationname">
|
||||||
|
<span class="hero-detail-label">Location</span>
|
||||||
|
<span class="hero-detail-value">{{ computer.locationname }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-detail" v-if="computer.computer?.osname">
|
|
||||||
<span class="hero-detail-label">OS</span>
|
<!-- Cloud identity. Laid out as a horizontal row in the same
|
||||||
<span class="hero-detail-value">{{ computer.computer.osname }}</span>
|
label-over-value language as hero-details, not as a narrow
|
||||||
</div>
|
right rail: a 36-character GUID cannot wrap sanely into a
|
||||||
<div class="hero-detail" v-if="computer.locationname">
|
300px column, and the ragged stack that produced grew taller
|
||||||
<span class="hero-detail-label">Location</span>
|
than the identity block beside it.
|
||||||
<span class="hero-detail-value">{{ computer.locationname }}</span>
|
Held back until the portal templates resolve so the row does
|
||||||
|
not paint once without its buttons and then again with them. -->
|
||||||
|
<div v-if="hasCloudIdentity" class="hero-portals">
|
||||||
|
<!-- The link sits beside the id it is built from, not in a row
|
||||||
|
of its own: the pairing is counterintuitive - the DEVICE id
|
||||||
|
opens Intune and the OBJECT id opens Entra - and a separate
|
||||||
|
action row would have to re-label which is which. The full
|
||||||
|
id stays selectable because it gets copied into tickets. -->
|
||||||
|
<div class="hero-portal">
|
||||||
|
<span class="hero-portal-label">
|
||||||
|
Device ID
|
||||||
|
<a
|
||||||
|
v-if="intuneDeviceLink"
|
||||||
|
:href="intuneDeviceLink"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="portal-link"
|
||||||
|
>Intune</a>
|
||||||
|
</span>
|
||||||
|
<span v-if="computer.computer?.azuredeviceid" class="hero-portal-idrow">
|
||||||
|
<span class="hero-portal-id mono">{{ computer.computer.azuredeviceid }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="copy-btn"
|
||||||
|
title="Copy Device ID"
|
||||||
|
@click="copyId(computer.computer.azuredeviceid, 'Device ID')"
|
||||||
|
>Copy</button>
|
||||||
|
</span>
|
||||||
|
<span v-else class="hero-portal-missing">Please provide DeviceID</span>
|
||||||
|
</div>
|
||||||
|
<div class="hero-portal">
|
||||||
|
<span class="hero-portal-label">
|
||||||
|
Object ID
|
||||||
|
<a
|
||||||
|
v-if="entraDeviceLink"
|
||||||
|
:href="entraDeviceLink"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="portal-link"
|
||||||
|
>Entra</a>
|
||||||
|
</span>
|
||||||
|
<span v-if="computer.computer?.azureobjectid" class="hero-portal-idrow">
|
||||||
|
<span class="hero-portal-id mono">{{ computer.computer.azureobjectid }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="copy-btn"
|
||||||
|
title="Copy Object ID"
|
||||||
|
@click="copyId(computer.computer.azureobjectid, 'Object ID')"
|
||||||
|
>Copy</button>
|
||||||
|
</span>
|
||||||
|
<span v-else class="hero-portal-missing">Please provide ObjectID</span>
|
||||||
|
</div>
|
||||||
|
<div class="hero-portal" v-if="computer.computer?.azuredevicecategory">
|
||||||
|
<span class="hero-portal-label">Category</span>
|
||||||
|
<span class="hero-portal-value">{{ computer.computer.azuredevicecategory }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -96,8 +167,35 @@
|
|||||||
answering for this asset type. -->
|
answering for this asset type. -->
|
||||||
<AssetDetailTabs v-model="activegroup" :tabs="detailtabs" label="Computer detail sections" />
|
<AssetDetailTabs v-model="activegroup" :tabs="detailtabs" label="Computer detail sections" />
|
||||||
|
|
||||||
|
<!-- Network interfaces is pinned to the top of Details rather than left
|
||||||
|
in System Monitors: which adapter is up and what address it holds is
|
||||||
|
identity, asked at the same moment as "what is this box". It still
|
||||||
|
BELONGS to monitors (declared there, and excluded from that tab
|
||||||
|
below), so there is one declaration and no new group. Full width and
|
||||||
|
outside content-grid because the table has eight columns and would
|
||||||
|
be unreadable in a half-width column. -->
|
||||||
|
<div v-show="activegroup === 'details'" class="detail-full">
|
||||||
|
<PluginAssetPanels
|
||||||
|
:assetid="computer.assetid"
|
||||||
|
:only="['endpoints-interfaces']"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-show="activegroup === 'details'" class="content-grid">
|
<div v-show="activegroup === 'details'" class="content-grid">
|
||||||
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" group="details" />
|
<PluginAssetPanels
|
||||||
|
ref="assetPanels"
|
||||||
|
:assetid="computer.assetid"
|
||||||
|
group="details"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Mapped drives is lifted the same way as network interfaces: a
|
||||||
|
missing share is a "why can't this operator open their program"
|
||||||
|
question, which gets asked on Details, not under monitors. It
|
||||||
|
stays declared in monitors and is excluded from that tab. -->
|
||||||
|
<PluginAssetPanels
|
||||||
|
:assetid="computer.assetid"
|
||||||
|
:only="['endpoints-mappeddrives']"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Left Column -->
|
<!-- Left Column -->
|
||||||
<div class="content-column">
|
<div class="content-column">
|
||||||
@@ -199,70 +297,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Entra / Azure device info -->
|
<!-- The Entra Device Info card lived here. Its links moved to the
|
||||||
<div
|
header's right-hand block, which was dead space at every width,
|
||||||
v-if="computer.computer?.azuredeviceid || computer.computer?.azureobjectid || computer.computer?.azuredevicecategory"
|
so they now cost no vertical room and sit beside the identity
|
||||||
class="section-card"
|
they belong to rather than partway down the page. -->
|
||||||
>
|
|
||||||
<h3 class="section-title">Entra Device Info</h3>
|
|
||||||
<div class="info-list">
|
|
||||||
<!-- The link sits beside the id it is built from, not in a row
|
|
||||||
of its own at the top: the pairing is counterintuitive - the
|
|
||||||
DEVICE id opens Intune and the OBJECT id opens Entra - and a
|
|
||||||
separate action row would have to re-label which is which. -->
|
|
||||||
<div v-if="computer.computer?.azuredeviceid" class="info-row">
|
|
||||||
<span class="info-label">Device ID</span>
|
|
||||||
<span class="info-value">
|
|
||||||
{{ computer.computer.azuredeviceid }}
|
|
||||||
<a
|
|
||||||
v-if="intuneDeviceLink"
|
|
||||||
:href="intuneDeviceLink"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="portal-link"
|
|
||||||
>Intune</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="computer.computer?.azureobjectid" class="info-row">
|
|
||||||
<span class="info-label">Object ID</span>
|
|
||||||
<span class="info-value">
|
|
||||||
{{ computer.computer.azureobjectid }}
|
|
||||||
<a
|
|
||||||
v-if="entraDeviceLink"
|
|
||||||
:href="entraDeviceLink"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="portal-link"
|
|
||||||
>Entra</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="computer.computer?.azuredevicecategory" class="info-row">
|
|
||||||
<span class="info-label">Device Category</span>
|
|
||||||
<span class="info-value">{{ computer.computer.azuredevicecategory }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Column -->
|
<!-- Right Column -->
|
||||||
<div class="content-column">
|
<div class="content-column">
|
||||||
<!-- Network -->
|
<!-- The hand-written Network card lived here. It listed current
|
||||||
<div class="section-card">
|
addresses with a Primary badge and nothing else, which undersold
|
||||||
<h3 class="section-title">Network</h3>
|
rows that also carry subnet, gateway, DNS, and their own
|
||||||
<div v-if="computer.communications?.length" class="network-list">
|
first-seen/last-changed stamps. Split into the IP History and IP
|
||||||
<div v-for="comm in computer.communications" :key="comm.communicationid" class="network-item">
|
Configuration panels under System Monitors, which read the same
|
||||||
<div class="network-primary">
|
Communication rows. -->
|
||||||
<span class="ip-address mono">{{ comm.ipaddress || comm.address || '-' }}</span>
|
|
||||||
<span v-if="comm.communicationtypename" class="comm-type">{{ comm.communicationtypename }}</span>
|
|
||||||
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
|
|
||||||
</div>
|
|
||||||
<div class="network-secondary" v-if="comm.macaddress">
|
|
||||||
<span class="mac-address mono">{{ comm.macaddress }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-else class="muted">No network addresses on record</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Location & Organization -->
|
<!-- Location & Organization -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
@@ -344,7 +392,11 @@
|
|||||||
rather than the long lists that make Software and Activity go full
|
rather than the long lists that make Software and Activity go full
|
||||||
width. -->
|
width. -->
|
||||||
<div v-show="activegroup === 'monitors'" class="content-grid">
|
<div v-show="activegroup === 'monitors'" class="content-grid">
|
||||||
<PluginAssetPanels :assetid="computer.assetid" group="monitors" />
|
<PluginAssetPanels
|
||||||
|
:assetid="computer.assetid"
|
||||||
|
group="monitors"
|
||||||
|
:exclude="['endpoints-interfaces', 'endpoints-mappeddrives']"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="activegroup === 'serviceprocess'" class="content-grid">
|
<div v-show="activegroup === 'serviceprocess'" class="content-grid">
|
||||||
<PluginAssetPanels :assetid="computer.assetid" group="serviceprocess" />
|
<PluginAssetPanels :assetid="computer.assetid" group="serviceprocess" />
|
||||||
@@ -433,6 +485,11 @@ const activegroup = ref('details')
|
|||||||
// blade paths, so guessing one would send people to a dead page.
|
// blade paths, so guessing one would send people to a dead page.
|
||||||
const entraUrlTemplate = ref('')
|
const entraUrlTemplate = ref('')
|
||||||
const intuneUrlTemplate = ref('')
|
const intuneUrlTemplate = ref('')
|
||||||
|
// The templates arrive from an async settings fetch. Until that settles the
|
||||||
|
// block stays hidden: rendering the ids first and popping the Intune/Entra
|
||||||
|
// buttons in a moment later reads as the buttons being missing, which is
|
||||||
|
// exactly what it looked like before a manual refresh warmed the cache.
|
||||||
|
const portalTemplatesLoaded = ref(false)
|
||||||
|
|
||||||
function fillTemplate(template, token, value) {
|
function fillTemplate(template, token, value) {
|
||||||
if (!template || !value) return null
|
if (!template || !value) return null
|
||||||
@@ -443,6 +500,30 @@ const entraDeviceLink = computed(() => fillTemplate(
|
|||||||
const intuneDeviceLink = computed(() => fillTemplate(
|
const intuneDeviceLink = computed(() => fillTemplate(
|
||||||
intuneUrlTemplate.value, '{deviceid}', computer.value?.computer?.azuredeviceid))
|
intuneUrlTemplate.value, '{deviceid}', computer.value?.computer?.azuredeviceid))
|
||||||
|
|
||||||
|
// clipboard.writeText needs a secure context: it is undefined over plain HTTP
|
||||||
|
// and rejects when the tab is not focused. Say copy failed and leave the id
|
||||||
|
// selectable rather than reporting a success that did not happen.
|
||||||
|
async function copyId(value, label) {
|
||||||
|
if (!value) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
toast.success(`${label} copied.`)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Copy failed - select the ${label} and copy manually.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether to show the header's cloud block at all.
|
||||||
|
//
|
||||||
|
// Gated on the site having CONFIGURED a portal template, not on this PC having
|
||||||
|
// an id. A site that does not use Intune/Entra has no templates, so the block
|
||||||
|
// is absent entirely rather than permanently asking for an id it will never
|
||||||
|
// get. Where templates exist, a PC missing an id shows which one is missing -
|
||||||
|
// that is the actionable case the empty state was requested for.
|
||||||
|
const hasCloudIdentity = computed(() => portalTemplatesLoaded.value && Boolean(
|
||||||
|
entraUrlTemplate.value || intuneUrlTemplate.value
|
||||||
|
|| computer.value?.computer?.azuredevicecategory))
|
||||||
|
|
||||||
// Only offer a tab that has something in it. An empty tab is worse than an
|
// Only offer a tab that has something in it. An empty tab is worse than an
|
||||||
// absent one - it reads as broken rather than as "this site does not collect
|
// absent one - it reads as broken rather than as "this site does not collect
|
||||||
// that". Details always shows because core fills it regardless of plugins.
|
// that". Details always shows because core fills it regardless of plugins.
|
||||||
@@ -534,7 +615,11 @@ onMounted(async () => {
|
|||||||
getEntraDeviceUrls().then(({ entraUrl, intuneUrl }) => {
|
getEntraDeviceUrls().then(({ entraUrl, intuneUrl }) => {
|
||||||
entraUrlTemplate.value = entraUrl
|
entraUrlTemplate.value = entraUrl
|
||||||
intuneUrlTemplate.value = intuneUrl
|
intuneUrlTemplate.value = intuneUrl
|
||||||
}).catch(() => {})
|
}).catch(() => {}).finally(() => {
|
||||||
|
// Settled either way. On failure the templates stay empty and the block
|
||||||
|
// falls back to whatever else it has, rather than hiding forever.
|
||||||
|
portalTemplatesLoaded.value = true
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await computersApi.get(route.params.id)
|
const response = await computersApi.get(route.params.id)
|
||||||
@@ -722,6 +807,96 @@ async function confirmRetire() {
|
|||||||
}
|
}
|
||||||
.portal-link:hover { border-color: var(--primary); }
|
.portal-link:hover { border-color: var(--primary); }
|
||||||
|
|
||||||
|
/* PC-only header additions. These class names exist nowhere else in the app -
|
||||||
|
Machines, Printers and the rest share only .hero-details/.hero-detail-*
|
||||||
|
from global style.css, which is deliberately left alone. */
|
||||||
|
.hero-primary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.hero-identity {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
/* Horizontal, in the same visual language as .hero-details directly above it,
|
||||||
|
separated by a rule rather than sitting in a box. Wraps to a second line on
|
||||||
|
a narrow window instead of forcing a scrollbar. */
|
||||||
|
.hero-portals {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2rem;
|
||||||
|
padding-top: 0.85rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.hero-portal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
/* The portal button lives on the label line, so it reads as an action on the
|
||||||
|
id below it and adds no height of its own. */
|
||||||
|
.hero-portal-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
.hero-portal-value {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
/* Full id, never truncated: a half-copied GUID looks complete and is worse
|
||||||
|
than none. user-select:all keeps manual selection working as a fallback for
|
||||||
|
when the clipboard API is unavailable over plain HTTP. */
|
||||||
|
.hero-portal-idrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.hero-portal-id {
|
||||||
|
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
user-select: all;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.copy-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-light);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.copy-btn:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
.hero-portal-missing {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-light);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.hero-portals { gap: 1rem 1.5rem; }
|
||||||
|
/* Only here does the id need to break: at this width it cannot fit a line. */
|
||||||
|
.hero-portal-id { white-space: normal; overflow-wrap: anywhere; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* PC-specific styles - shared styles are in global style.css */
|
/* PC-specific styles - shared styles are in global style.css */
|
||||||
|
|
||||||
@@ -810,43 +985,8 @@ async function confirmRetire() {
|
|||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Network card (IP / MAC list) */
|
/* Network card styles removed with the card itself - IP History and IP
|
||||||
.network-list {
|
Configuration are plugin panels and carry their own table styling. */
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.6rem;
|
|
||||||
}
|
|
||||||
.network-item {
|
|
||||||
padding: 0.5rem 0.7rem;
|
|
||||||
background: var(--bg);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
.network-primary {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.ip-address {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.comm-type {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-light);
|
|
||||||
}
|
|
||||||
.primary-badge {
|
|
||||||
padding: 0.1rem 0.5rem;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 600;
|
|
||||||
background: var(--primary);
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
.mac-address {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--text-light);
|
|
||||||
}
|
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ schema changes, update this file in the same change.
|
|||||||
- Method/URL: `POST {baseurl}/api/collector/endpoints`
|
- Method/URL: `POST {baseurl}/api/collector/endpoints`
|
||||||
- Auth header: `X-API-Key: <collector key>` (Bearer token also accepted). The
|
- Auth header: `X-API-Key: <collector key>` (Bearer token also accepted). The
|
||||||
key is a UI-managed, endpoints-only service token minted from Settings ->
|
key is a UI-managed, endpoints-only service token minted from Settings ->
|
||||||
Endpoint agent key (section 6). A bootstrap `.env` collector key still works
|
Endpoint agent key (section 7). A bootstrap `.env` collector key still works
|
||||||
until the first UI key is generated, then it is switched off (section 6). No
|
until the first UI key is generated, then it is switched off (section 7). No
|
||||||
querystring fallback.
|
querystring fallback.
|
||||||
- Body: `application/json`, a single flat object (NOT an array). One POST per
|
- Body: `application/json`, a single flat object (NOT an array). One POST per
|
||||||
report per endpoint.
|
report per endpoint.
|
||||||
@@ -194,10 +194,11 @@ Notes:
|
|||||||
rather than a string match against a Windows enum the server would have to
|
rather than a string match against a Windows enum the server would have to
|
||||||
keep in sync. `null` means the drive did not answer: unknown, NOT healthy.
|
keep in sync. `null` means the drive did not answer: unknown, NOT healthy.
|
||||||
`healthstatus` keeps the human-readable source value alongside it.
|
`healthstatus` keeps the human-readable source value alongside it.
|
||||||
- `usercontext` is gathered by a probe the agent runs inside the signed-in
|
- `usercontext` is gathered by a probe the TRAY runs inside the signed-in
|
||||||
user's session, because mapped drives, user-owned listeners and idle time do
|
operator's session, because mapped drives, user-owned listeners and idle time
|
||||||
not exist in the agent's own LocalSystem session 0. It is omitted when no one
|
do not exist in the agent's own LocalSystem session 0. It is omitted when no
|
||||||
is signed in, which is a normal unattended state, not a fault.
|
one is signed in, which is a normal unattended state, not a fault. The
|
||||||
|
security-context split that makes this work is normative; see section 5.
|
||||||
- `ports` carries LISTENING sockets only. The full connection table is available
|
- `ports` carries LISTENING sockets only. The full connection table is available
|
||||||
on demand via the `network_connections` command instead, because established
|
on demand via the `network_connections` command instead, because established
|
||||||
connections churn constantly and would change every report for no diagnostic
|
connections churn constantly and would change every report for no diagnostic
|
||||||
@@ -294,7 +295,49 @@ public sealed class CollectorResponse
|
|||||||
Serializer settings: `DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull`
|
Serializer settings: `DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull`
|
||||||
so omitted fields are not sent (server upserts only present keys). Send UTC ISO-8601.
|
so omitted fields are not sent (server upserts only present keys). Send UTC ISO-8601.
|
||||||
|
|
||||||
## 5. "Build site agent" settings flow (build cadence)
|
## 5. Security contexts, spool handoff and log destinations (NORMATIVE)
|
||||||
|
|
||||||
|
Three processes run as three different principals. Confusing them is what makes
|
||||||
|
`usercontext` silently empty, so the boundaries are fixed by contract.
|
||||||
|
|
||||||
|
| Process | Runs as | May write to |
|
||||||
|
|---|---|---|
|
||||||
|
| Service (`shopdbagent.exe`) | LocalSystem, session 0 | `%ProgramData%\ShopDB\EndpointAgent\logs\agent-<date>.log` |
|
||||||
|
| Tray + probe (`shopdb-endpoint-tray.exe`, `shopdbagent.exe --collect-usercontext`) | `ShopFloor` operator, interactive session | `C:\Users\ShopFloor\ShopDB\` and `C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log` |
|
||||||
|
| Installer (`install-shopdbagent.ps1`) | Elevated admin, one-shot | `%ProgramData%\ShopDB\Logs\<datetime>-shopdbinstalls.log` |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
1. **The operator cannot write to `C:\Program Files` or to the SYSTEM-owned
|
||||||
|
`%ProgramData%\ShopDB\EndpointAgent\logs`.** Anything running in the user's
|
||||||
|
session MUST log under `C:\Users\ShopFloor\ShopDB\Logs`. The file log sink
|
||||||
|
degrades to a silent no-op when it cannot create its directory, so violating
|
||||||
|
this does not raise an error - it discards the log line and the failure
|
||||||
|
becomes invisible.
|
||||||
|
2. **Only the tray collects user context**, because mapped drives, user-owned
|
||||||
|
listeners and idle time are per-session and do not exist in session 0. The
|
||||||
|
service MUST NOT try to descend into the session to collect them.
|
||||||
|
3. **The tray WRITES the spool; the service READS it and then REMOVES it.**
|
||||||
|
The service consumes the file after a successful parse, so a tray that has
|
||||||
|
stopped running cannot keep re-reporting stale mapped drives as current. A
|
||||||
|
file that fails to parse (a read that raced the write) is left in place.
|
||||||
|
4. **The spool location is fixed at `C:\Users\ShopFloor\ShopDB\usercontext.json`.**
|
||||||
|
Every shop-floor endpoint uses the same local `ShopFloor` account. Resolving
|
||||||
|
it through `WTSQueryUserToken` is a fallback only: that call is privileged,
|
||||||
|
endpoint protection blocks it, and when it fails the writer and reader
|
||||||
|
disagree about the location with no error on either side.
|
||||||
|
5. **Losing user context is never fatal.** If the `ShopFloor` profile is absent
|
||||||
|
or unwritable, `usercontext` is omitted and telemetry, commands, inventory
|
||||||
|
and the command channel continue unaffected.
|
||||||
|
6. **The installer provisions** `C:\Users\ShopFloor\ShopDB\` and its `Logs`
|
||||||
|
subdirectory with `ShopFloor: Modify` before the tray first runs.
|
||||||
|
|
||||||
|
The three log destinations are deliberately separate: an install is one-shot and
|
||||||
|
elevated, the service log is a rolling runtime log, and the tray log is the only
|
||||||
|
one written from the operator's session. Merging them makes an install
|
||||||
|
indistinguishable from runtime behaviour when reading a failure back.
|
||||||
|
|
||||||
|
## 6. "Build site agent" settings flow (build cadence)
|
||||||
|
|
||||||
Goal: an operator in ShopDB Settings produces an instance-locked `.exe` that
|
Goal: an operator in ShopDB Settings produces an instance-locked `.exe` that
|
||||||
knows its server URL and its collector key, needing no manual config on the
|
knows its server URL and its collector key, needing no manual config on the
|
||||||
@@ -321,7 +364,7 @@ Open item (from DESIGN-DECISIONS-agent.md 1a): whether `adopt` stays a
|
|||||||
per-report flag or is promoted to a site setting (strict vs self-adopting
|
per-report flag or is promoted to a site setting (strict vs self-adopting
|
||||||
fleet). Default proposed: agent sends `adopt=true` on first run only.
|
fleet). Default proposed: agent sends `adopt=true` on first run only.
|
||||||
|
|
||||||
## 6. UI-managed agent key (kill switch)
|
## 7. UI-managed agent key (kill switch)
|
||||||
|
|
||||||
The endpoint agent authenticates with a single UI-managed key scoped to ONLY
|
The endpoint agent authenticates with a single UI-managed key scoped to ONLY
|
||||||
`collector.ingest.endpoints`, so rotating or revoking it stops all
|
`collector.ingest.endpoints`, so rotating or revoking it stops all
|
||||||
@@ -346,13 +389,13 @@ The bootstrap `.env` key (`COLLECTOR_API_KEY[_ENDPOINTS]`) remains only as a
|
|||||||
first-run path before a UI key exists; it cannot be the runtime kill switch
|
first-run path before a UI key exists; it cannot be the runtime kill switch
|
||||||
because container env is injected at boot and cannot be rotated live.
|
because container env is injected at boot and cannot be rotated live.
|
||||||
|
|
||||||
## 7. Command channel (WebUI -> agent)
|
## 8. Command channel (WebUI -> agent)
|
||||||
|
|
||||||
The WebUI can send an endpoint an ALLOWLISTED command (never an arbitrary
|
The WebUI can send an endpoint an ALLOWLISTED command (never an arbitrary
|
||||||
command string). Delivery is agent-initiated long-poll, so shop-floor PCs open
|
command string). Delivery is agent-initiated long-poll, so shop-floor PCs open
|
||||||
no inbound port: the agent makes an outbound request the server holds open until
|
no inbound port: the agent makes an outbound request the server holds open until
|
||||||
a command is queued, runs it locally, and posts the result back. All three
|
a command is queued, runs it locally, and posts the result back. All three
|
||||||
routes reuse the same endpoints collector key (section 6) via `X-API-Key`, so
|
routes reuse the same endpoints collector key (section 7) via `X-API-Key`, so
|
||||||
the command channel shares the kill switch and adds no second secret.
|
the command channel shares the kill switch and adds no second secret.
|
||||||
|
|
||||||
Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
|
Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
|
||||||
|
|||||||
@@ -26,10 +26,18 @@ from shopdb.api import (success_response, error_response, ErrorCodes, AuditLog,
|
|||||||
from .routes import endpoints_bp
|
from .routes import endpoints_bp
|
||||||
from . import agentstore
|
from . import agentstore
|
||||||
|
|
||||||
# Hard cap on an uploaded agent binary. The real agent is a few MB; this is a
|
# Hard cap on an uploaded agent binary. This is a generous ceiling that still
|
||||||
# generous ceiling that still refuses an operator who points at a giant wrong
|
# refuses an operator who points at a giant wrong file before it is buffered
|
||||||
# file before it is buffered fully into memory-staged storage.
|
# fully into memory-staged storage.
|
||||||
_MAX_UPLOAD_BYTES = 64 * 1024 * 1024
|
#
|
||||||
|
# 128 MB, not 64: the self-contained single-file tray publishes at ~69 MB
|
||||||
|
# (it carries the whole .NET runtime plus WinForms), which the old 64 MB cap
|
||||||
|
# rejected outright. The upload failed on SIZE, but the operator saw only a
|
||||||
|
# version-detection failure, because the size check returns before the PE is
|
||||||
|
# ever parsed. Config.MAX_CONTENT_LENGTH must stay above this - it bounds the
|
||||||
|
# whole multipart envelope, so a cap equal to it would reject a legal file on
|
||||||
|
# the boundary bytes alone.
|
||||||
|
_MAX_UPLOAD_BYTES = 128 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
@endpoints_bp.route('/siteagent/binaries/<binkey>', methods=['GET'])
|
@endpoints_bp.route('/siteagent/binaries/<binkey>', methods=['GET'])
|
||||||
|
|||||||
@@ -1413,6 +1413,137 @@ def panel_interfaces(asset_id: int):
|
|||||||
return success_response(rows)
|
return success_response(rows)
|
||||||
|
|
||||||
|
|
||||||
|
# Columns for the two cards carved out of the old hand-written Network card on
|
||||||
|
# the Details tab. That card listed current addresses with a Primary badge and
|
||||||
|
# nothing else, which undersold what the rows actually hold.
|
||||||
|
_IPHISTORY_COLUMNS = [
|
||||||
|
{'key': 'ipaddress', 'label': 'IP address'},
|
||||||
|
{'key': 'macaddress', 'label': 'MAC'},
|
||||||
|
{'key': 'status', 'label': 'Status'},
|
||||||
|
{'key': 'firstseen', 'label': 'First seen'},
|
||||||
|
{'key': 'lastchanged', 'label': 'Last changed'},
|
||||||
|
]
|
||||||
|
|
||||||
|
_IPCONFIG_COLUMNS = [
|
||||||
|
{'key': 'ipaddress', 'label': 'IP address'},
|
||||||
|
{'key': 'subnetmask', 'label': 'Subnet mask'},
|
||||||
|
{'key': 'gateway', 'label': 'Gateway'},
|
||||||
|
{'key': 'dns1', 'label': 'DNS 1'},
|
||||||
|
{'key': 'dns2', 'label': 'DNS 2'},
|
||||||
|
{'key': 'assignment', 'label': 'Assignment'},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _isostamp(value):
|
||||||
|
"""ISO-8601 with a Z suffix, matching every other timestamp this file emits.
|
||||||
|
|
||||||
|
Formatting is left to the client so it can render in the viewer's locale
|
||||||
|
and timezone; returning a preformatted string here would pin every reader
|
||||||
|
to the server's.
|
||||||
|
"""
|
||||||
|
return value.isoformat() + 'Z' if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _communication_rows(asset_id: int):
|
||||||
|
"""Every Communication row for an asset, newest change first.
|
||||||
|
|
||||||
|
These rows are an ACCIDENTAL history rather than a designed one.
|
||||||
|
_record_interfaces in the computers plugin matches an incoming NIC on MAC
|
||||||
|
then on IP and inserts when neither matches, and it never deletes - so an
|
||||||
|
address the machine has not held for a year is still sitting here next to
|
||||||
|
the current one. That is genuinely useful (it answers "what was this box on
|
||||||
|
before?"), so it is surfaced as history rather than cleaned away.
|
||||||
|
"""
|
||||||
|
from shopdb.api import Communication
|
||||||
|
|
||||||
|
# MySQL has no NULLS LAST, so sort on an explicit "is null" key first.
|
||||||
|
# Rows that never got a modifieddate sink to the bottom on every dialect.
|
||||||
|
return (Communication.query
|
||||||
|
.filter_by(assetid=asset_id)
|
||||||
|
.order_by(Communication.modifieddate.is_(None),
|
||||||
|
Communication.modifieddate.desc(),
|
||||||
|
Communication.communicationid.desc())
|
||||||
|
.all())
|
||||||
|
|
||||||
|
|
||||||
|
def _iphistory_status(comm):
|
||||||
|
"""What this row IS, in the vocabulary somebody troubleshooting would use.
|
||||||
|
|
||||||
|
Primary is the address the collector last picked as this box's main one.
|
||||||
|
APIPA is called out by name because a 169.254.x.x address is not an address
|
||||||
|
the machine was given - it is what Windows assigns when DHCP failed, so its
|
||||||
|
presence in the list is a record of a past network fault, not a past
|
||||||
|
subnet.
|
||||||
|
"""
|
||||||
|
address = (comm.ipaddress or '').strip()
|
||||||
|
if address.startswith('169.254.'):
|
||||||
|
return 'APIPA (no DHCP)'
|
||||||
|
if comm.isprimary:
|
||||||
|
return 'Primary'
|
||||||
|
if comm.ismachinenetwork:
|
||||||
|
return 'Machine network'
|
||||||
|
return 'Previous'
|
||||||
|
|
||||||
|
|
||||||
|
@endpoints_bp.route('/asset/<int:asset_id>/iphistory', methods=['GET'])
|
||||||
|
@require_permission('endpoints.view')
|
||||||
|
def panel_iphistory(asset_id: int):
|
||||||
|
"""Table panel: every address this asset has held, newest change first.
|
||||||
|
|
||||||
|
Replaces the hand-written Network card that used to sit on the Details tab
|
||||||
|
and show only the current addresses. Dates come from the row's own
|
||||||
|
lifecycle columns, so they are labelled for what they are: 'Last changed'
|
||||||
|
is when a field on the row last moved, NOT when the address was last seen.
|
||||||
|
An address the machine still holds and reports unchanged does not update
|
||||||
|
modifieddate, so calling that column 'Last seen' would be a lie the data
|
||||||
|
cannot support.
|
||||||
|
"""
|
||||||
|
rows = []
|
||||||
|
for comm in _communication_rows(asset_id):
|
||||||
|
address = (comm.ipaddress or '').strip()
|
||||||
|
if not address:
|
||||||
|
continue
|
||||||
|
rows.append({
|
||||||
|
'ipaddress': address,
|
||||||
|
'macaddress': (comm.macaddress or '').strip() or '-',
|
||||||
|
'status': _iphistory_status(comm),
|
||||||
|
'firstseen': _isostamp(comm.createddate),
|
||||||
|
'lastchanged': _isostamp(comm.modifieddate),
|
||||||
|
})
|
||||||
|
return success_response(rows)
|
||||||
|
|
||||||
|
|
||||||
|
@endpoints_bp.route('/asset/<int:asset_id>/ipconfig', methods=['GET'])
|
||||||
|
@require_permission('endpoints.view')
|
||||||
|
def panel_ipconfig(asset_id: int):
|
||||||
|
"""Table panel: subnet, gateway and DNS per recorded address.
|
||||||
|
|
||||||
|
Split from IP history rather than folded into it: history answers "what has
|
||||||
|
this box been", configuration answers "how is this address set up", and one
|
||||||
|
table trying to do both would be eleven columns wide. Rows with no
|
||||||
|
configuration recorded are dropped - an address with no subnet, gateway or
|
||||||
|
DNS contributes nothing here and already appears in the history card.
|
||||||
|
"""
|
||||||
|
rows = []
|
||||||
|
for comm in _communication_rows(asset_id):
|
||||||
|
address = (comm.ipaddress or '').strip()
|
||||||
|
subnet = (comm.subnetmask or '').strip()
|
||||||
|
gateway = (comm.gateway or '').strip()
|
||||||
|
dns1 = (comm.dns1 or '').strip()
|
||||||
|
dns2 = (comm.dns2 or '').strip()
|
||||||
|
if not address or not (subnet or gateway or dns1 or dns2):
|
||||||
|
continue
|
||||||
|
rows.append({
|
||||||
|
'ipaddress': address,
|
||||||
|
'subnetmask': subnet or '-',
|
||||||
|
'gateway': gateway or '-',
|
||||||
|
'dns1': dns1 or '-',
|
||||||
|
'dns2': dns2 or '-',
|
||||||
|
'assignment': 'DHCP' if comm.isdhcp else 'Static',
|
||||||
|
})
|
||||||
|
return success_response(rows)
|
||||||
|
|
||||||
|
|
||||||
@endpoints_bp.route('/asset/<int:asset_id>/applications', methods=['GET'])
|
@endpoints_bp.route('/asset/<int:asset_id>/applications', methods=['GET'])
|
||||||
@require_permission('endpoints.view')
|
@require_permission('endpoints.view')
|
||||||
def panel_applications(asset_id: int):
|
def panel_applications(asset_id: int):
|
||||||
|
|||||||
@@ -319,9 +319,9 @@
|
|||||||
<div
|
<div
|
||||||
v-for="binary in binaries"
|
v-for="binary in binaries"
|
||||||
:key="binary.key"
|
:key="binary.key"
|
||||||
class="binary-block"
|
class="card binary-block"
|
||||||
>
|
>
|
||||||
<h4>{{ binary.label }}</h4>
|
<h4 class="binary-title">{{ binary.label }}</h4>
|
||||||
<p class="field-hint">
|
<p class="field-hint">
|
||||||
Upload {{ binary.name }} updated EXE. Uploading here will update the
|
Upload {{ binary.name }} updated EXE. Uploading here will update the
|
||||||
binaries available for your "Build Site Agent" install package.
|
binaries available for your "Build Site Agent" install package.
|
||||||
@@ -910,10 +910,18 @@ function editConfiguration() {
|
|||||||
.settings-section > h3 { margin-bottom: 0.25rem; }
|
.settings-section > h3 { margin-bottom: 0.25rem; }
|
||||||
.info-list { margin: 0.25rem 0 0; padding-left: 1.25rem; }
|
.info-list { margin: 0.25rem 0 0; padding-left: 1.25rem; }
|
||||||
.info-list li { margin-bottom: 0.5rem; }
|
.info-list li { margin-bottom: 0.5rem; }
|
||||||
|
/* Each agent is its own nested card inside the collapsible, so the two
|
||||||
|
binaries read as distinct units instead of one continuous wall of
|
||||||
|
controls separated by a hairline rule. */
|
||||||
.binary-block {
|
.binary-block {
|
||||||
margin-top: 1.25rem;
|
margin-top: 1rem;
|
||||||
padding-top: 1rem;
|
padding: 1rem;
|
||||||
border-top: 1px solid var(--color-border, #333);
|
}
|
||||||
|
.binary-block:first-of-type { margin-top: 0.75rem; }
|
||||||
|
.binary-title {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--color-border, #333);
|
||||||
}
|
}
|
||||||
.binary-upload {
|
.binary-upload {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -507,7 +507,66 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
],
|
],
|
||||||
'empty': 'No network interfaces reported.',
|
'empty': 'No network interfaces reported.',
|
||||||
'position': 16,
|
'position': 16,
|
||||||
# adapter state is watched, not something you act on
|
# adapter state is watched, not something you act on.
|
||||||
|
# Declared in monitors but RENDERED at the top of the Details
|
||||||
|
# tab (PCDetail.vue lifts it by id and excludes it here): which
|
||||||
|
# adapter is up and what address it holds is asked at the same
|
||||||
|
# moment as "what is this box".
|
||||||
|
'group': 'monitors',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
# What this box has been on the network, carved out of the
|
||||||
|
# hand-written Network card that used to sit on Details and
|
||||||
|
# show only current addresses with a Primary badge.
|
||||||
|
#
|
||||||
|
# The history is ACCIDENTAL: _record_interfaces matches on MAC
|
||||||
|
# then IP and never deletes, so old addresses accumulate. That
|
||||||
|
# turned out to be worth reading rather than worth cleaning
|
||||||
|
# away, so it is presented as what it already is.
|
||||||
|
'id': 'endpoints-iphistory',
|
||||||
|
'title': 'IP History',
|
||||||
|
'assettypes': ['computer'],
|
||||||
|
'endpoint': '/api/endpoints/asset/{assetid}/iphistory',
|
||||||
|
'render': 'table',
|
||||||
|
'collapsible': True,
|
||||||
|
'columns': [
|
||||||
|
{'key': 'ipaddress', 'label': 'IP address', 'mono': True},
|
||||||
|
{'key': 'macaddress', 'label': 'MAC', 'mono': True},
|
||||||
|
{'key': 'status', 'label': 'Status'},
|
||||||
|
{'key': 'firstseen', 'label': 'First seen',
|
||||||
|
'format': 'date'},
|
||||||
|
# NOT "last seen": modifieddate only moves when a field on
|
||||||
|
# the row changes, so an address still held and reported
|
||||||
|
# unchanged keeps an old stamp here.
|
||||||
|
{'key': 'lastchanged', 'label': 'Last changed',
|
||||||
|
'format': 'date'},
|
||||||
|
],
|
||||||
|
'empty': 'No addresses on record for this endpoint.',
|
||||||
|
'position': 16.5,
|
||||||
|
'group': 'monitors',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
# The other half of the old Network card: how each address is
|
||||||
|
# configured. Split from history because one table carrying
|
||||||
|
# both would be eleven columns wide, and the two answer
|
||||||
|
# different questions - what has this box been, versus how is
|
||||||
|
# this address set up.
|
||||||
|
'id': 'endpoints-ipconfig',
|
||||||
|
'title': 'IP Configuration',
|
||||||
|
'assettypes': ['computer'],
|
||||||
|
'endpoint': '/api/endpoints/asset/{assetid}/ipconfig',
|
||||||
|
'render': 'table',
|
||||||
|
'collapsible': True,
|
||||||
|
'columns': [
|
||||||
|
{'key': 'ipaddress', 'label': 'IP address', 'mono': True},
|
||||||
|
{'key': 'subnetmask', 'label': 'Subnet mask', 'mono': True},
|
||||||
|
{'key': 'gateway', 'label': 'Gateway', 'mono': True},
|
||||||
|
{'key': 'dns1', 'label': 'DNS 1', 'mono': True},
|
||||||
|
{'key': 'dns2', 'label': 'DNS 2', 'mono': True},
|
||||||
|
{'key': 'assignment', 'label': 'Assignment'},
|
||||||
|
],
|
||||||
|
'empty': 'No addressing configuration on record.',
|
||||||
|
'position': 16.6,
|
||||||
'group': 'monitors',
|
'group': 'monitors',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -163,11 +163,14 @@ class Config:
|
|||||||
# three different routes, which makes it a property of the app rather than
|
# three different routes, which makes it a property of the app rather than
|
||||||
# of any handler.
|
# of any handler.
|
||||||
#
|
#
|
||||||
# 128 MB clears the largest thing anyone legitimately sends - the agent
|
# 256 MB clears the largest thing anyone legitimately sends - the agent
|
||||||
# binary upload caps itself at 64 MB - with room for the multipart envelope.
|
# binary upload caps itself at 128 MB - with room for the multipart
|
||||||
|
# envelope. This must stay STRICTLY ABOVE the agent cap: it bounds the
|
||||||
|
# whole request (file + boundaries + form fields), so setting the two
|
||||||
|
# equal would reject a file sitting exactly at the agent limit.
|
||||||
# Over this, Werkzeug answers 413 without reading the body.
|
# Over this, Werkzeug answers 413 without reading the body.
|
||||||
MAX_CONTENT_LENGTH = int(
|
MAX_CONTENT_LENGTH = int(
|
||||||
os.environ.get('MAX_CONTENT_LENGTH', 128 * 1024 * 1024))
|
os.environ.get('MAX_CONTENT_LENGTH', 256 * 1024 * 1024))
|
||||||
|
|
||||||
|
|
||||||
class DevelopmentConfig(Config):
|
class DevelopmentConfig(Config):
|
||||||
|
|||||||
@@ -103,7 +103,10 @@ EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
|
|||||||
# 0011 adds the trayversion cache column to endpointstates.
|
# 0011 adds the trayversion cache column to endpointstates.
|
||||||
# 0012 adds endpointevents.eventrecordid plus the unique index that dedups a
|
# 0012 adds endpointevents.eventrecordid plus the unique index that dedups a
|
||||||
# replayed Windows event record.
|
# replayed Windows event record.
|
||||||
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0013inventorycolumns'
|
# 0013 adds the inventory columns.
|
||||||
|
# 0014 adds endpointstates.diskhealthjson, and 0015 adds the system telemetry
|
||||||
|
# columns (boot info, reboot reasons, Defender health, NIC link state).
|
||||||
|
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0015systemtelemetry'
|
||||||
|
|
||||||
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
||||||
# core chain never owned.
|
# core chain never owned.
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ reaches the version gate.
|
|||||||
import io
|
import io
|
||||||
|
|
||||||
from plugins.endpoints.api import agentstore
|
from plugins.endpoints.api import agentstore
|
||||||
|
from plugins.endpoints.api import agentupload
|
||||||
|
from shopdb.config import Config
|
||||||
|
|
||||||
UPLOAD_URL = '/api/endpoints/siteagent/binaries/{}'
|
UPLOAD_URL = '/api/endpoints/siteagent/binaries/{}'
|
||||||
|
|
||||||
@@ -63,3 +65,26 @@ def test_upload_rejects_when_neither_typed_nor_pe_is_usable(
|
|||||||
response = _upload(client, auth_headers, monkeypatch, None)
|
response = _upload(client, auth_headers, monkeypatch, None)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert 'dotted-numeric' in response.get_json()['data']['error']['message']
|
assert 'dotted-numeric' in response.get_json()['data']['error']['message']
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_cap_admits_a_self_contained_single_file_publish():
|
||||||
|
"""The tray publishes ~69 MB; the cap must clear a real binary.
|
||||||
|
|
||||||
|
This is the bug that presented as "upload does not detect version": the
|
||||||
|
size check returns before the PE is parsed, so an oversized file looks
|
||||||
|
like a parser failure to the operator. Pinned at 100 MB rather than the
|
||||||
|
observed 69 MB so runtime growth does not silently re-break it.
|
||||||
|
"""
|
||||||
|
assert agentupload._MAX_UPLOAD_BYTES >= 100 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_cap_stays_above_the_agent_upload_cap():
|
||||||
|
"""MAX_CONTENT_LENGTH bounds the whole multipart envelope, not the file.
|
||||||
|
|
||||||
|
Setting the two equal rejects a file sitting exactly at the agent cap,
|
||||||
|
because the boundaries and form fields push the request over. They must
|
||||||
|
move together, with real headroom between them.
|
||||||
|
"""
|
||||||
|
envelope = Config.MAX_CONTENT_LENGTH
|
||||||
|
assert envelope > agentupload._MAX_UPLOAD_BYTES
|
||||||
|
assert envelope - agentupload._MAX_UPLOAD_BYTES >= 1024 * 1024
|
||||||
|
|||||||
148
tests/test_plugins/test_endpoints_ip_history.py
Normal file
148
tests/test_plugins/test_endpoints_ip_history.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"""IP History and IP Configuration, carved out of the old Details Network card.
|
||||||
|
|
||||||
|
These read core Communication rows, which accumulate as an ACCIDENTAL history:
|
||||||
|
_record_interfaces matches an incoming NIC on MAC then on IP and inserts when
|
||||||
|
neither matches, and never deletes. So an address the machine has not held for
|
||||||
|
a year sits next to the current one. These tests pin the reading of that, not
|
||||||
|
the collecting.
|
||||||
|
|
||||||
|
The date columns are the delicate part. modifieddate only moves when a field on
|
||||||
|
the row actually changes, so an address still held and reported unchanged keeps
|
||||||
|
an old stamp. It is labelled 'Last changed' for that reason and must not drift
|
||||||
|
back to 'Last seen'.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from plugins.endpoints.api.routes import (
|
||||||
|
_IPCONFIG_COLUMNS,
|
||||||
|
_IPHISTORY_COLUMNS,
|
||||||
|
_iphistory_status,
|
||||||
|
_isostamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeComm:
|
||||||
|
"""Only the columns these two routes read."""
|
||||||
|
|
||||||
|
def __init__(self, ipaddress='10.0.0.1', macaddress='AA:BB',
|
||||||
|
isprimary=False, ismachinenetwork=False, isdhcp=False,
|
||||||
|
subnetmask='', gateway='', dns1='', dns2='',
|
||||||
|
createddate=None, modifieddate=None):
|
||||||
|
self.ipaddress = ipaddress
|
||||||
|
self.macaddress = macaddress
|
||||||
|
self.isprimary = isprimary
|
||||||
|
self.ismachinenetwork = ismachinenetwork
|
||||||
|
self.isdhcp = isdhcp
|
||||||
|
self.subnetmask = subnetmask
|
||||||
|
self.gateway = gateway
|
||||||
|
self.dns1 = dns1
|
||||||
|
self.dns2 = dns2
|
||||||
|
self.createddate = createddate
|
||||||
|
self.modifieddate = modifieddate
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_apipa_address_is_named_as_a_dhcp_failure():
|
||||||
|
# 169.254.x.x is not an address the machine was given - it is what Windows
|
||||||
|
# assigns when DHCP failed. Calling it 'Previous' would file a past network
|
||||||
|
# fault as a past subnet.
|
||||||
|
assert _iphistory_status(FakeComm(ipaddress='169.254.122.88')) == 'APIPA (no DHCP)'
|
||||||
|
|
||||||
|
|
||||||
|
def test_apipa_wins_over_primary():
|
||||||
|
# A box currently failing DHCP can have its APIPA address flagged primary
|
||||||
|
# by the collector. The fault is the more useful thing to say.
|
||||||
|
comm = FakeComm(ipaddress='169.254.1.1', isprimary=True)
|
||||||
|
assert _iphistory_status(comm) == 'APIPA (no DHCP)'
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_normal_address_is_not_mistaken_for_apipa():
|
||||||
|
# Guards a substring match: 169.254 must be a prefix, not found anywhere.
|
||||||
|
assert _iphistory_status(FakeComm(ipaddress='10.169.254.5')) == 'Previous'
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_primary_address_is_marked():
|
||||||
|
assert _iphistory_status(FakeComm(isprimary=True)) == 'Primary'
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_machine_network_address_is_distinguished_from_a_stale_one():
|
||||||
|
# ismachinenetwork means it talks to the machine, not that it is old.
|
||||||
|
assert _iphistory_status(FakeComm(ismachinenetwork=True)) == 'Machine network'
|
||||||
|
|
||||||
|
|
||||||
|
def test_everything_else_reads_as_previous():
|
||||||
|
assert _iphistory_status(FakeComm()) == 'Previous'
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_timestamp_yields_null_not_a_crash():
|
||||||
|
# createddate is nullable on legacy rows imported before BaseModel stamps.
|
||||||
|
assert _isostamp(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_timestamp_is_emitted_as_utc_iso():
|
||||||
|
# Matches every other timestamp this file emits; the client formats it to
|
||||||
|
# the viewer's locale rather than being pinned to the server's.
|
||||||
|
stamp = _isostamp(datetime(2026, 9, 5, 11, 30, 0))
|
||||||
|
assert stamp == '2026-09-05T11:30:00Z'
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_second_date_is_not_labelled_last_seen():
|
||||||
|
# The whole reason for the label. modifieddate cannot support 'last seen':
|
||||||
|
# an unchanged address reported daily keeps a months-old stamp.
|
||||||
|
labels = {col['key']: col['label'] for col in _IPHISTORY_COLUMNS}
|
||||||
|
assert labels['lastchanged'] == 'Last changed'
|
||||||
|
assert 'seen' not in labels['lastchanged'].lower()
|
||||||
|
assert labels['firstseen'] == 'First seen'
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_two_cards_do_not_duplicate_columns_beyond_the_address():
|
||||||
|
# They are split so neither is eleven columns wide. The address is the
|
||||||
|
# deliberate join between them; anything else repeated means the split
|
||||||
|
# drifted back together.
|
||||||
|
history = {col['key'] for col in _IPHISTORY_COLUMNS}
|
||||||
|
config = {col['key'] for col in _IPCONFIG_COLUMNS}
|
||||||
|
assert history & config == {'ipaddress'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_history_ordering_compiles_on_mysql():
|
||||||
|
# Regression: the first cut ordered with .nullslast(), which is valid
|
||||||
|
# ANSI/Postgres but blows up on MySQL with a 1064 syntax error. The test
|
||||||
|
# suite runs on SQLite, so it passed and the page still 500'd in the
|
||||||
|
# browser. Compile the real ordering against the MySQL dialect so the
|
||||||
|
# dialect gap is caught here instead of in a container log.
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
from shopdb.api import Communication
|
||||||
|
|
||||||
|
ordering = (select(Communication.ipaddress)
|
||||||
|
.where(Communication.assetid == 1)
|
||||||
|
.order_by(Communication.modifieddate.is_(None),
|
||||||
|
Communication.modifieddate.desc(),
|
||||||
|
Communication.communicationid.desc()))
|
||||||
|
|
||||||
|
compiled = str(ordering.compile(dialect=mysql.dialect()))
|
||||||
|
assert 'NULLS LAST' not in compiled.upper()
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_panel_handler_still_has_a_route():
|
||||||
|
# Regression: inserting the ipconfig route directly above panel_applications
|
||||||
|
# ate that function's @endpoints_bp.route line, leaving a handler decorated
|
||||||
|
# with require_permission only. It imported fine and every test passed; the
|
||||||
|
# endpoint was simply gone. Assert each panel_* handler is actually mapped.
|
||||||
|
import re
|
||||||
|
|
||||||
|
from plugins.endpoints.api import routes as routes_module
|
||||||
|
|
||||||
|
source = Path(routes_module.__file__).read_text(encoding='utf-8')
|
||||||
|
handlers = re.findall(r'^def (panel_\w+)', source, re.MULTILINE)
|
||||||
|
assert handlers, 'expected panel handlers in routes.py'
|
||||||
|
|
||||||
|
routed = set()
|
||||||
|
for match in re.finditer(r'^@endpoints_bp\.route\(', source, re.MULTILINE):
|
||||||
|
tail = source[match.start():]
|
||||||
|
found = re.search(r'^def (\w+)', tail, re.MULTILINE)
|
||||||
|
if found:
|
||||||
|
routed.add(found.group(1))
|
||||||
|
|
||||||
|
assert set(handlers) - routed == set()
|
||||||
@@ -69,7 +69,7 @@ def test_endpoints_asset_panels_are_computer_scoped():
|
|||||||
"""Every asset panel targets computer assets only, with an endpoint."""
|
"""Every asset panel targets computer assets only, with an endpoint."""
|
||||||
plugin = EndpointsPlugin()
|
plugin = EndpointsPlugin()
|
||||||
panels = plugin.get_asset_panels()
|
panels = plugin.get_asset_panels()
|
||||||
assert len(panels) == 13
|
assert len(panels) == 15
|
||||||
for panel in panels:
|
for panel in panels:
|
||||||
assert panel['assettypes'] == ['computer']
|
assert panel['assettypes'] == ['computer']
|
||||||
assert panel['endpoint'].startswith('/api/endpoints/')
|
assert panel['endpoint'].startswith('/api/endpoints/')
|
||||||
|
|||||||
Reference in New Issue
Block a user