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:
benjamin.baker
2026-09-05 14:20:07 -04:00
parent 7f84e8ca1e
commit 86985efcf2
23 changed files with 1261 additions and 192 deletions

View File

@@ -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.
`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)
- **DB tables**: lowercase, concatenated, plural. No underscores, no dashes.

View File

@@ -272,7 +272,13 @@ public sealed class EndpointCollector : ICollector
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)
lastUserContext = collected;

View File

@@ -14,32 +14,76 @@ public sealed class FileLoggerProvider : ILoggerProvider
private readonly string directory;
private readonly LogLevel minLevel;
private readonly int retentionDays;
private readonly string filePrefix;
private readonly bool stampFirst;
private readonly object gate = new();
private readonly ConcurrentDictionary<string, FileLogger> loggers = new();
private string currentDateStamp = "";
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,
int retentionDays = 14)
int retentionDays = 14, string filePrefix = "agent", bool stampFirst = false)
{
this.directory = directory;
this.minLevel = minLevel;
this.retentionDays = retentionDays;
this.filePrefix = filePrefix;
this.stampFirst = stampFirst;
// 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
// that abort construction; Write() degrades to a no-op if the sink is
// 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); }
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:
// %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()
=> Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"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)
=> loggers.GetOrAdd(categoryName, name => new FileLogger(this, name));
@@ -85,7 +129,11 @@ public sealed class FileLoggerProvider : ILoggerProvider
writer?.Flush();
writer?.Dispose();
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(
path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
{
@@ -99,7 +147,8 @@ public sealed class FileLoggerProvider : ILoggerProvider
try
{
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)
File.Delete(file);

View File

@@ -31,6 +31,9 @@ public sealed class UserContextProbe
private const string ProbeScript = """
$ErrorActionPreference = 'Stop'
$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'
$target = Join-Path $spool 'usercontext.json'
$temporary = "$target.tmp"
@@ -145,6 +148,10 @@ public sealed class UserContextProbe
// the telemetry path, so an unresponsive WMI call delays nothing but itself.
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;
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.
public bool Collect()
{
string? scriptPath = null;
try
{
scriptPath = Path.Combine(
Path.GetTempPath(), $"shopdb-usercontext-{Guid.NewGuid():N}.ps1");
File.WriteAllText(scriptPath, ProbeScript);
// Passed as -EncodedCommand rather than staged as a .ps1 and run with
// -File. The script used to be written into %TEMP% and executed from
// 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
{
FileName = "powershell.exe",
Arguments =
"-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "
+ "\"" + scriptPath + "\"",
"-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand "
+ encoded,
WorkingDirectory = Path.GetTempPath(),
UseShellExecute = false,
CreateNoWindow = true,
@@ -196,8 +218,16 @@ public sealed class UserContextProbe
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(
"The user-context probe exited with code {ExitCode}.", process.ExitCode);
"The user-context probe exited with code {ExitCode}.{Detail}",
process.ExitCode, detail);
return false;
}
@@ -208,22 +238,6 @@ public sealed class UserContextProbe
logger.LogWarning(exception, "The user-context probe failed.");
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)

View File

@@ -47,16 +47,36 @@ public sealed class UserContextSpool
this.logger = logger;
}
// Full path to the spool file for the signed-in user, or null (with a
// reason) when nobody is signed in. No session means no session-scoped data
// to collect, which is a normal state on an unattended machine, not a fault.
// Full path to the spool file. Fixed at C:\Users\ShopFloor\ShopDB, because
// every shop-floor endpoint signs in as the same local ShopFloor account.
//
// 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)
{
var pinned = FileLoggerProvider.ShopFloorProfileDirectory();
if (Directory.Exists(pinned))
{
reason = null;
return Path.Combine(pinned, SpoolFolderName, ResultFileName);
}
var profileDirectory =
InteractiveProcessLauncher.TryGetActiveUserProfileDirectory(out var error);
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;
}
@@ -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
// 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

View File

@@ -21,7 +21,14 @@
service version independently (they are separate exes and can be pushed
apart), so the tray carries its own <Version> and the agent reports it
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>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>

View File

@@ -1,6 +1,8 @@
using System.Diagnostics;
using System.Reflection;
using Microsoft.Extensions.Logging;
using ShopDbEndpointAgent.Core;
namespace ShopDbEndpointAgent.Tray;
@@ -44,8 +46,27 @@ internal sealed class TrayContext : ApplicationContext
private bool userContextStarted;
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()
{
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();
menu.Items.Add("Send Status now", null, OnSendStatus);
menu.Items.Add("Report an issue...", null, OnReportIssue);
@@ -90,16 +111,39 @@ internal sealed class TrayContext : ApplicationContext
var agentExe = ResolveAgentExe();
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;
}
collectingUserContext = true;
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
{

View File

@@ -50,8 +50,18 @@ var logDirectory = string.IsNullOrWhiteSpace(config.LogPath)
// diagnose.
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 =>
logging.AddProvider(new FileLoggerProvider(logDirectory)));
logging.AddProvider(new FileLoggerProvider(
FileLoggerProvider.UserProfileLogDirectory(),
filePrefix: "trayagent",
stampFirst: true)));
var probe = new UserContextProbe(
probeLoggerFactory.CreateLogger<UserContextProbe>());
return probe.Collect() ? 0 : 1;

View File

@@ -7,6 +7,22 @@
<Nullable>enable</Nullable>
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
<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
descends into the operator's session with CreateProcessAsUser to run the
probe; that path needs to duplicate a token, is what endpoint protection
@@ -74,7 +90,7 @@
(AgentConfig.LaunchPaths, shipped defaults overridable per site).
1.1.0: generic, data-driven service_*/process_* actions carry their
target over the wire, so new policies/apps need no agent rebuild. -->
<Version>1.8.0</Version>
<Version>1.8.1</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>

View File

@@ -157,10 +157,49 @@ function Uninstall-PreviousAgent {
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 "BaseUrl : $BaseUrl"
Write-Host "InstallDir : $InstallDir"
Write-Host "ServiceName : $ServiceName"
if ($transcriptStarted) { Write-Host "InstallLog : $installLog" }
# 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
@@ -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 "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
# 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.
@@ -352,4 +428,10 @@ else {
Write-Host "`nInstall complete." -ForegroundColor Green
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 { }
}

View File

@@ -446,6 +446,15 @@ const props = defineProps({
// the detail pages that have not adopted tabs still do - so this stays
// additive and no existing page changes behaviour.
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([])
@@ -466,9 +475,14 @@ function groupOf(panel) {
return RENDERED_GROUPS.includes(panel.group) ? panel.group : 'details'
}
const groupedPanels = computed(() =>
props.group ? panels.value.filter((p) => groupOf(p) === props.group) : panels.value
)
const groupedPanels = computed(() => {
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
// rather than offering an empty one.

View 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'")
})
})

View File

@@ -34,32 +34,103 @@
<img :src="withBase(computer.computer.imageurl)" :alt="computer.computer.modelname || 'Model photo'" />
</div>
<div class="hero-content">
<div class="hero-title">
<h1>{{ computer.assetnumber }}</h1>
<span v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span>
</div>
<div class="hero-meta">
<span class="badge badge-lg badge-info">Computer</span>
<span class="badge badge-lg" :style="colorStyle(computer.statuscolor)">
{{ computer.statusname || 'Unknown' }}
</span>
<span v-if="heroWarranty" class="badge badge-lg" :style="colorStyle(heroWarranty.statuscolor)"
:title="heroWarranty.enddate ? `Warranty ends ${formatWarrantyDate(heroWarranty.enddate)}` : 'Warranty'">
{{ 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>
<!-- Identity, then the cloud row beneath it. An earlier cut put the
portal block in a fixed-width rail on the right; GUIDs are too
long for a narrow column and it wrapped into a tall ragged
stack. Full width in the normal flow costs one short row. -->
<div class="hero-primary">
<div class="hero-identity">
<div class="hero-title">
<h1>{{ computer.assetnumber }}</h1>
<span v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span>
</div>
<div class="hero-meta">
<span class="badge badge-lg badge-info">Computer</span>
<span class="badge badge-lg" :style="colorStyle(computer.statuscolor)">
{{ computer.statusname || 'Unknown' }}
</span>
<span v-if="heroWarranty" class="badge badge-lg" :style="colorStyle(heroWarranty.statuscolor)"
:title="heroWarranty.enddate ? `Warranty ends ${formatWarrantyDate(heroWarranty.enddate)}` : 'Warranty'">
{{ 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 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>
<!-- Cloud identity. Laid out as a horizontal row in the same
label-over-value language as hero-details, not as a narrow
right rail: a 36-character GUID cannot wrap sanely into a
300px column, and the ragged stack that produced grew taller
than the identity block beside it.
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>
@@ -96,8 +167,35 @@
answering for this asset type. -->
<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">
<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 -->
<div class="content-column">
@@ -199,70 +297,20 @@
</div>
</div>
<!-- Entra / Azure device info -->
<div
v-if="computer.computer?.azuredeviceid || computer.computer?.azureobjectid || computer.computer?.azuredevicecategory"
class="section-card"
>
<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>
<!-- The Entra Device Info card lived here. Its links moved to the
header's right-hand block, which was dead space at every width,
so they now cost no vertical room and sit beside the identity
they belong to rather than partway down the page. -->
</div>
<!-- Right Column -->
<div class="content-column">
<!-- Network -->
<div class="section-card">
<h3 class="section-title">Network</h3>
<div v-if="computer.communications?.length" class="network-list">
<div v-for="comm in computer.communications" :key="comm.communicationid" class="network-item">
<div class="network-primary">
<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>
<!-- The hand-written Network card lived here. It listed current
addresses with a Primary badge and nothing else, which undersold
rows that also carry subnet, gateway, DNS, and their own
first-seen/last-changed stamps. Split into the IP History and IP
Configuration panels under System Monitors, which read the same
Communication rows. -->
<!-- Location & Organization -->
<div class="section-card">
@@ -344,7 +392,11 @@
rather than the long lists that make Software and Activity go full
width. -->
<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 v-show="activegroup === 'serviceprocess'" class="content-grid">
<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.
const entraUrlTemplate = 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) {
if (!template || !value) return null
@@ -443,6 +500,30 @@ const entraDeviceLink = computed(() => fillTemplate(
const intuneDeviceLink = computed(() => fillTemplate(
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
// 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.
@@ -534,7 +615,11 @@ onMounted(async () => {
getEntraDeviceUrls().then(({ entraUrl, intuneUrl }) => {
entraUrlTemplate.value = entraUrl
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 {
const response = await computersApi.get(route.params.id)
@@ -722,6 +807,96 @@ async function confirmRetire() {
}
.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 */
@@ -810,43 +985,8 @@ async function confirmRetire() {
color: var(--text-light);
}
/* Network card (IP / MAC list) */
.network-list {
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);
}
/* Network card styles removed with the card itself - IP History and IP
Configuration are plugin panels and carry their own table styling. */
.muted {
color: var(--text-light);
margin: 0;

View File

@@ -10,8 +10,8 @@ schema changes, update this file in the same change.
- Method/URL: `POST {baseurl}/api/collector/endpoints`
- Auth header: `X-API-Key: <collector key>` (Bearer token also accepted). The
key is a UI-managed, endpoints-only service token minted from Settings ->
Endpoint agent key (section 6). A bootstrap `.env` collector key still works
until the first UI key is generated, then it is switched off (section 6). No
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 7). No
querystring fallback.
- Body: `application/json`, a single flat object (NOT an array). One POST per
report per endpoint.
@@ -194,10 +194,11 @@ Notes:
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.
`healthstatus` keeps the human-readable source value alongside it.
- `usercontext` is gathered by a probe the agent runs inside the signed-in
user's session, because mapped drives, user-owned listeners and idle time do
not exist in the agent's own LocalSystem session 0. It is omitted when no one
is signed in, which is a normal unattended state, not a fault.
- `usercontext` is gathered by a probe the TRAY runs inside the signed-in
operator's session, because mapped drives, user-owned listeners and idle time
do not exist in the agent's own LocalSystem session 0. It is omitted when no
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
on demand via the `network_connections` command instead, because established
connections churn constantly and would change every report for no diagnostic
@@ -294,7 +295,49 @@ public sealed class CollectorResponse
Serializer settings: `DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull`
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
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
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
`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
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
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
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.
Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):

View File

@@ -26,10 +26,18 @@ from shopdb.api import (success_response, error_response, ErrorCodes, AuditLog,
from .routes import endpoints_bp
from . import agentstore
# Hard cap on an uploaded agent binary. The real agent is a few MB; this is a
# generous ceiling that still refuses an operator who points at a giant wrong
# file before it is buffered fully into memory-staged storage.
_MAX_UPLOAD_BYTES = 64 * 1024 * 1024
# Hard cap on an uploaded agent binary. This is a generous ceiling that still
# refuses an operator who points at a giant wrong file before it is buffered
# fully into memory-staged storage.
#
# 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'])

View File

@@ -1413,6 +1413,137 @@ def panel_interfaces(asset_id: int):
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'])
@require_permission('endpoints.view')
def panel_applications(asset_id: int):

View File

@@ -319,9 +319,9 @@
<div
v-for="binary in binaries"
: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">
Upload {{ binary.name }} updated EXE. Uploading here will update the
binaries available for your "Build Site Agent" install package.
@@ -910,10 +910,18 @@ function editConfiguration() {
.settings-section > h3 { margin-bottom: 0.25rem; }
.info-list { margin: 0.25rem 0 0; padding-left: 1.25rem; }
.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 {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border, #333);
margin-top: 1rem;
padding: 1rem;
}
.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 {
display: flex;

View File

@@ -507,7 +507,66 @@ class EndpointsPlugin(BasePlugin):
],
'empty': 'No network interfaces reported.',
'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',
},
{

View File

@@ -163,11 +163,14 @@ class Config:
# three different routes, which makes it a property of the app rather than
# of any handler.
#
# 128 MB clears the largest thing anyone legitimately sends - the agent
# binary upload caps itself at 64 MB - with room for the multipart envelope.
# 256 MB clears the largest thing anyone legitimately sends - the agent
# 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.
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):

View File

@@ -103,7 +103,10 @@ EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
# 0011 adds the trayversion cache column to endpointstates.
# 0012 adds endpointevents.eventrecordid plus the unique index that dedups a
# 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
# core chain never owned.

View File

@@ -14,6 +14,8 @@ reaches the version gate.
import io
from plugins.endpoints.api import agentstore
from plugins.endpoints.api import agentupload
from shopdb.config import Config
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)
assert response.status_code == 400
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

View 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()

View File

@@ -69,7 +69,7 @@ def test_endpoints_asset_panels_are_computer_scoped():
"""Every asset panel targets computer assets only, with an endpoint."""
plugin = EndpointsPlugin()
panels = plugin.get_asset_panels()
assert len(panels) == 13
assert len(panels) == 15
for panel in panels:
assert panel['assettypes'] == ['computer']
assert panel['endpoint'].startswith('/api/endpoints/')