TruGrid Secure RDP - Reading and interpreting component logs

When troubleshooting a TruGrid session, the two most useful sources of information are the agent log on the server-side (TruGrid Sentry or SecureConnect) and the Windows Connector log on the end user's device. Knowing where they live and what to look for will resolve many issues without having to reach out to support, and will dramatically speed up the ones that do need TruGrid Customer Care.


This article walks through both logs.


We created a script that will gather relevant Error data and present it in HTML report, please see the bottom of the article to run it.The script is also available as a part of the TruGrid SecureRDP Toolkit.


In this article



Reading the TruGrid Sentry or SecureConnect Agent log


1. Where to find it


On the server where the agent is installed:

  • TruGrid Sentry: C:\Program Files\TruGrid\Sentry\Agent.log
  • TruGrid SecureConnect: C:\Program Files\TruGrid\Secure Connect\Agent.log


2. Line format


Two formats appear in the same file:

yyyy-MM-dd HH:mm:ss.ffff UTC | LEVEL | message
yyyy-MM-dd HH:mm:ss.ffffUTC|LEVEL|Component|TruGrid_Sentry_Broker_N:message


The first format is used by the main agent process for service start, Active Directory reporting, and Web Service connection. The second format is used by the broker child processes, which handle the actual RDP tunnels. The broker variant has no spaces around the pipes and includes TruGrid_Sentry_Broker_0, _1, or _2 as a prefix on the message body.


3. What a healthy session looks like


When a user successfully connects, the log produces a sequence like this:

INFO | Incoming command: <guid>; EstablishRdpConnection
INFO | SentryTunnel | ...:Attempting to create a new RDP tunnel for user:"<userGuid>"
INFO | TruGridRelayProvider | ...:Attempting to connect to TruGrid Relay via TCP:<relayIp>:443
INFO | TruGridRelayProvider | ...:A new connection has been established
INFO | TruGridRelayProvider | ...:Connection is encrypted
INFO | TruGridRelayTunnel | ...:Connection has been established to the destination machine <hostIp>:3389 via TCP
INFO | TruGridRelayTunnel | ...:TCP forwarding has started
WARN | TruGridRelayTunnel | ...:UDP forwarding is not started
INFO | TruGridRelayTunnel | ...:Connection initiated


The UDP forwarding is not started warning is logged on every tunnel and is normal.


4. Log patterns that indicate a real problem


The following events are worth recognizing and acting on. They are listed in order of how often they actually impact users.

Server timeout


ERROR | Server timeout (30000.00ms) elapsed without receiving a message from the server.


The agent stopped receiving messages from the TruGrid cloud for 30 seconds. Almost always followed by a Reconnecting to TruGrid webservice and then a Reconnected to TruGrid webservice line a few seconds later. If reconnect happens within 15 seconds, active RDP sessions usually survive.


If these are frequent (more than a few per day), the network path between the agent server and ws.trugrid.com should be reviewed. Common causes are flaky firewall connections, intermittent proxy issues, and saturated upstream bandwidth.


You can also diagnose these connectivity issues by using the Reachability Diagnostic Scripts for host and connector, respectively.


In some cases, outbound traffic might be blocked or filtered, see How to whitelist TruGrid outbound traffic on the firewall for the required outbound destinations.


Tunnel reset and recovery
WARN | TruGridRelayTunnel | ...:Forward termination. Network connection closed by remote host. Socket error:ConnectionReset. Direction:Backward. Tunnel:"<guid>"
INFO | TruGridRelayTunnel | ...:Need to reconnect. Tunnel:"<guid>"
INFO | TruGridRelayProvider | ...:A new connection has been established. Tunnel:"<guid>"
INFO | TruGridRelayTunnel | ...:TCP forwarding has started. Tunnel:"<guid>"


The same tunnel GUID appears in all four lines. This is a transient network blip on either the user's end (Direction Backward) or the agent-to-relay leg (Direction Straight), recovered automatically. The user may have noticed a brief freeze.

Isolated occurrences are normal. Investigate only when a single tunnel GUID shows three or more Need to reconnect events within five minutes, which indicates a genuinely unstable path for that user.


Failed to establish tunnel
ERROR | TruGridRelayTunnel | ...:Failed to establish tunnel. Tunnel:"<guid>" System.Net.Sockets.SocketException ... <hostIp>:3389


The agent reached the relay, but the destination Windows host did not answer on port 3389. The user clicked Connect and got nothing.

  1. Verify the target host is online.
  2. Confirm Remote Desktop is enabled on the host (Get-Service TermService should show Running).
  3. Confirm Allow Remote Desktop is permitted in the Windows firewall for Domain or Private profiles.
  4. Check network ACLs or VLAN rules between the agent server and the host.


If the same tunnel GUID has a Collect inactive tunnel line within the previous 60 seconds, this error is firing on a tunnel that was already being torn down and should be ignored. Look for a fresh tunnel GUID instead. See also How to fix RDP connection stuck at "configuring remote session" or offline machine.


Authentication failures
ERROR | Unsuccessful authentication from TruGrid to local AD for <upn>. Reason: The user name or password is incorrect
ERROR | Unsuccessful authentication from TruGrid to local AD for <upn>. Reason: The password for this account has expired
ERROR | The authentication mechanism is unknown.
  1. The user name or password is incorrect. A single occurrence followed by a Successful authentication for the same UPN within five minutes is just a typo. Three or more failures in a 60-second window with no success in between is worth investigating, either as a brute-force attempt or as a service somewhere holding a stale credential.
  2. The password for this account has expired. The user must change their password before they can log in again.
  3. The authentication mechanism is unknown. Rare. Usually indicates a stale token or client-side configuration issue. Have the user sign out of the Windows Connector completely and sign back in.


Sentry service stop and Health Monitor restart
INFO | Sentry service stopped
INFO | Sentry Health Monitor. Sentry is not running. Attempting to start it
INFO | Starting Sentry (X.Y.Z.W)


The Sentry Health Monitor is a separate Windows service that auto-restarts the Sentry agent when it stops. If the gap between stopped and the next Starting Sentry is under 30 seconds, active tunnels typically survive because brokers run as independent processes.


If the gap is longer than 30 seconds, or if you see repeated stop and start cycles, check that the TruGrid Sentry Health Monitor service is running, and review the Windows Application event log for any service-related errors around that timestamp.


5. Log noise that is safe to ignore



These appear at WARN or even ERROR level but are normal operational logging. Filter them out before searching for problems:

  1. UDP forwarding is not started. Logged on every tunnel.
  2. Forward termination. Stream was already disposed. Normal disconnect path when a user closes their session cleanly.
  3. MainBroker | ...:Maximum number of broker instances. All pipe instances are busy. Internal broker pool maintenance.
  4. Sentry Health Monitor service session changed to RemoteConnect / SessionLogon / SessionLock. Session events on the agent server itself, unrelated to end-user RDP sessions.
  5. Create or update ServiceAccount error: Access is denied. Logged at startup when the agent's service account lacks rights to manage its AD service account. Not session-impacting.


6. Quick triage by time window


When a user reports a problem, search the log for the time window of the issue. The PowerShell snippet below filters to a window and removes the four most common noise patterns:

$start = "2026-05-06 14:30"
$end = "2026-05-06 14:45"
Get-Content "C:\Program Files\TruGrid\Sentry\Agent.log" |
Where-Object { $_ -match "^(?<ts>\S+ \S+)" -and $matches.ts -ge $start -and $matches.ts -le $end } |
Where-Object { $_ -match "ERROR|WARN" } |
Where-Object { $_ -notmatch "UDP forwarding is not started|Stream was already disposed|Maximum number of broker instances|Access is denied" }


Reading the TruGrid Windows Connector log


1. Where to find it



On the end user's Windows device, in the Roaming profile of the user who runs the Connector:

  • %APPDATA%\TruGrid Connector\win_headless_logs_yyyy_MM_dd.txt


The file rolls daily. Older days remain in the same directory.


To find the most recent files from PowerShell as the affected user:

$dir = Join-Path $env:APPDATA "TruGrid Connector"
Get-ChildItem $dir -Filter "win_headless_logs_*.txt" |
Sort-Object LastWriteTime -Descending |
Select-Object -First 3


If the user cannot navigate the path, have them paste %APPDATA%\TruGrid Connector into File Explorer's address bar.


2. Line format
yyyy-MM-dd HH:mm:ss.ffff UTC|LEVEL|Component|MethodName|message


Note the space before UTC. This is the visible difference from the agent log format, useful when both logs end up pasted into the same ticket. All timestamps in the log are in UTC.


3. What a healthy connection looks like


INFO | LocalRdpConnection      | Checking if machine '<host>' with IP '<ip>' is on local network.
INFO | WindowsConnectorAdapter | Running relay tunnel.
INFO | ConnectorTunnel | Attempting to create a new RDP tunnel. Tunnel:"<guid>"
INFO | TruGridRelayProvider | Attempting to connect to TruGrid Relay via TCP:<relayIp>:443
INFO | TruGridRelayProvider | A new connection has been established
INFO | TruGridRelayProvider | Connection is encrypted
INFO | TruGridRelayTunnel | Local TCP server started at 127.0.0.1:43000
INFO | WindowsConnectorAdapter | Relay connection initialization complete.
INFO | WindowsConnectorAdapter | Requested RDP session launching.
INFO | BaseRdpConnectionLauncher | RDP client started
INFO | TruGridRelayTunnel | Connection for RDP Client has been received.
INFO | TruGridRelayTunnel | TCP forwarding has started
WARN | TruGridRelayTunnel | UDP forwarding is not started
INFO | TruGridRelayTunnel | Connection initiated


The line Local TCP server started at 127.0.0.1:43000 means the Connector is listening locally for the Microsoft RDP client to attach. The RDP client started line that follows confirms the RDP client launched successfully.


4. Log patterns that indicate a real problem



The user's own internet went down

This is the most useful pattern in the Connector log because the agent-side log on the customer's server will not detect it reliably in real time:

WARN  | TruGridRelayTunnel | Forward termination. ConnectionReset. Direction:Backward. Tunnel:"<guid>"
INFO | TruGridRelayTunnel | Need to reconnect. Tunnel:"<guid>"
ERROR | TruGridRelayTunnel | Checking internet access. Network interface is not available.
WARN | TruGridRelayTunnel | Checking internet access. Error accessing the www.microsoft.com website.
WARN | TruGridRelayTunnel | Checking internet access. Error accessing the www.google.com website.
WARN | TruGridRelayTunnel | Checking internet access. Error accessing the TruGrid service.


The Connector probes three independent endpoints (microsoft.com, google.com, and the TruGrid service) on every retry. If all three fail, the user's own network connection is down. This is definitive. Nothing on the customer side or the TruGrid side is responsible.

The user needs to fix their local network: switch from Wi-Fi to wired or vice versa, restart their router, complete any captive portal sign-in, or confirm their ISP is working.


TruGrid is unreachable but general internet works


ERROR | TruGridRelayProvider | Failed to connect to TruGrid Relay via TCP. Tunnel:"<guid>"
WARN | TruGridRelayTunnel | Checking internet access. Error accessing the TruGrid service.
INFO | TruGridRelayTunnel | Checking internet access. Successful access to the www.microsoft.com website.
INFO | TruGridRelayTunnel | Checking internet access. Successful access to the www.google.com website.


The user's general internet is fine, but TruGrid relay traffic is being blocked. This is almost always an outbound firewall, proxy, or content-filter rule on the user's network that does not permit traffic to *.trugrid.com or to the TruGrid relay IP range.


The user's network admin needs to permit outbound TCP 443 to TruGrid. See How to whitelist TruGrid outbound traffic on the firewall for the full list of destinations.


Certificate name mismatch warning


WARN | LocalRdpConnection | Certificate name check failed. Expected: <hostname>, Actual CN: <ssl-name>


The destination host's RDP certificate has a different CN than the host's NetBIOS name. This is normal in environments where RDP hosts use a wildcard or shared certificate, which is common with Remote Desktop Services deployments. It does not block the connection, although the user may see a one-time prompt asking them to trust the certificate.

No action is required if the connection still works. If the certificate prompt is blocking the user, importing the relevant CA certificate into the user's Trusted Root or Trusted Publishers store usually resolves it.


ObjectDisposedException during reconnect


ERROR | TruGridRelayTunnel | Failed to establish tunnel. Tunnel:"<guid>"
: System.ObjectDisposedException: Cannot access a disposed object.
ERROR | TruGridRelayTunnel | Failed to reconnect after forward termination. Tunnel:"<guid>"
: System.ObjectDisposedException: The semaphore has been disposed.


This looks alarming but is almost always logged on a tunnel that was already being cleaned up. It is an internal race condition in the cleanup path and does not represent a user-facing failure.

If it appears in isolation, ignore it. If the same tunnel GUID is also showing real errors (failed connections to relay, failed internet checks), focus on those instead.


No active tunnels - exiting


INFO | Headless Connector | No active tunnels detected, exiting application


The Connector has shut itself down because no RDP sessions are open. This is normal when the user has finished their work and closed all sessions. It is not an error.


5. Sending logs to TruGrid Customer Care


When opening a support ticket, attach:

  1. The most recent win_headless_logs_yyyy_MM_dd.txt file from the affected user, or the file from the day the issue occurred.
  2. The Agent.log from the customer-side Sentry or SecureConnect server, sliced to the time window of the issue.
  3. The user's UPN.
  4. The approximate UTC time of the failure.


A 5-minute slice on either side of the incident is usually sufficient. Attaching the full file is rarely useful and slows down the review. To slice the agent log to a specific window from PowerShell:

$start = "2026-05-06 14:30"
$end = "2026-05-06 14:45"
Get-Content "C:\Program Files\TruGrid\Sentry\Agent.log" |
Where-Object { $_ -match "^(?<ts>\S+ \S+)" -and $matches.ts -ge $start -and $matches.ts -le $end } |
Out-File -FilePath "C:\temp\agent-slice.log" -Encoding utf8


Cross-referencing the two logs


When the same incident is visible in both logs, the tunnel GUID is the join key. Both the Windows Connector log on the user's PC and the agent log on the customer's server use the same GUID for the same tunnel. Searching for the GUID in both logs reconstructs the full picture: what the user's machine attempted, how the relay responded, and what the agent on the server side reported.

All timestamps in both logs are in UTC. If a user reports an issue with a local time, convert it to UTC before searching.

Please contact TruGrid Customer Care via CHAT for questions, recommendations, and for assistance.


Report Script


Here is a script that scans the data, asks the user for the period they'd like to review and presents the data in a readable, HTML format, sorting the data by severity and source:


[CmdletBinding()]
param(
[ValidateRange(1, 720)]
[int]$HoursBack,

[switch]$IncludeNoise,

[switch]$OpenAfter,

[switch]$NonInteractive,

[string]$OutputDir = [Environment]::GetFolderPath('Desktop')
)

$ErrorActionPreference = 'Stop'

# ============================================================================
# Configuration
# ============================================================================

$Script:SentryInstallDir = 'C:\Program Files\TruGrid\Sentry'
$Script:SecureConnectInstallDir = 'C:\Program Files\TruGrid\Secure Connect'
$Script:ConnectorLogDir = Join-Path $env:APPDATA 'TruGrid Connector'
$Script:ConnectorLogPattern = 'win_headless_logs_*.txt'

# Matches all observed log line formats:
# yyyy-MM-dd HH:mm:ss.ffff UTC | LEVEL | message (Sentry main process, with spaces)
# yyyy-MM-dd HH:mm:ss.ffffUTC|LEVEL|Component|Broker_N:msg (Sentry broker, no spaces)
# yyyy-MM-dd HH:mm:ss.ffffUTC|LEVEL|message (SecureConnect)
# yyyy-MM-dd HH:mm:ss.ffff +HH:MM|LEVEL|Component|Method|msg (Connector, local time + offset)
$Script:LineRegex = '^(?<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3,7})\s?(?<tz>UTC|Z|[+-]\d{2}:\d{2})\s?\|\s?(?<lvl>INFO|WARN|WARNING|ERROR|DEBUG|TRACE)\s?\|\s?(?<body>.+)$'

# Tier classification patterns from the Sentry log glossary.
# Order matters: T1 specific patterns first, then T4, T2, T3 noise last.
$Script:TierMap = @(
@{ ID='1.1'; Tier='T1'; Name='Server-side control timeout'; Action='Flag'; Pattern='Server timeout \([\d.]+ms\) elapsed' }
@{ ID='1.2'; Tier='T1'; Name='Client-side control timeout'; Action='Flag'; Pattern="Client hasn't sent a message/ping within the configured ClientTimeoutInterval" }
@{ ID='1.3'; Tier='T1'; Name='Backend service reload/reroute'; Action='Flag'; Pattern='(Service reloading, please reconnect|Server connection which the client routed to is closed)' }
@{ ID='1.4'; Tier='T1'; Name='Tunnel establishment failed'; Action='Flag'; Pattern='Failed to establish tunnel.*SocketException' }
@{ ID='1.5'; Tier='T1'; Name='WebSocket transport reset'; Action='Flag'; Pattern='(An existing connection was forcibly closed by the remote host|An internal WebSocket error occurred)' }

@{ ID='4.1'; Tier='T4'; Name='Account locked out'; Action='Track'; Pattern='referenced account is currently locked out' }

@{ ID='2.1'; Tier='T2'; Name='Tunnel network reset'; Action='Correlate'; Pattern='Forward termination\. Network connection closed by remote host\. Socket error:(ConnectionReset|ConnectionAborted)' }
@{ ID='2.2'; Tier='T2'; Name='Need to reconnect cycle'; Action='Correlate'; Pattern='Need to reconnect\. Tunnel:' }
@{ ID='2.3'; Tier='T2'; Name='Auth fail (non-credential)'; Action='Correlate'; Pattern='(password for this account has expired|authentication mechanism is unknown)' }
@{ ID='2.4'; Tier='T2'; Name='Client internet check failed'; Action='Correlate'; Pattern='(Network interface is not available|Error accessing the (www\.microsoft\.com|www\.google\.com|TruGrid service))' }
@{ ID='2.5'; Tier='T2'; Name='Sentry box internet drop'; Action='Correlate'; Pattern='Internet connectivity check failed' }

@{ ID='3.1'; Tier='T3'; Name='UDP forwarding warning'; Action='Ignore'; Pattern='UDP forwarding is not started' }
@{ ID='3.2'; Tier='T3'; Name='Stream already disposed'; Action='Ignore'; Pattern='Forward termination\. Stream was already disposed' }
@{ ID='3.3'; Tier='T3'; Name='Auth typo'; Action='Ignore'; Pattern='The user name or password is incorrect' }
@{ ID='3.4'; Tier='T3'; Name='Broker pool churn'; Action='Ignore'; Pattern='(Maximum number of broker instances|Initiate a new instance of Sentry Broker|Terminating TruGrid_Sentry_Broker)' }
@{ ID='3.5'; Tier='T3'; Name='Agent service session change'; Action='Ignore'; Pattern='(Sentry Health Monitor|Secure Connect) service session changed' }
@{ ID='3.6'; Tier='T3'; Name='ObjectDisposedException'; Action='Ignore'; Pattern='ObjectDisposedException' }
@{ ID='3.7'; Tier='T3'; Name='Agent service start/stop'; Action='Ignore'; Pattern='((Sentry|Secure Connect) service (stopped|shutdown)|Starting (Sentry|Secure Connect)|Sentry Health Monitor\. (Attempting to start|Sentry is not running|Sentry status))' }
@{ ID='3.8'; Tier='T3'; Name='Lifecycle noise'; Action='Ignore'; Pattern='(Collect inactive|Reconnecting to TruGrid webservice|Reconnected to TruGrid webservice|Successfully connected to TruGrid Relay|Successful authentication|Web service access granted|Connection has been established|TCP forwarding has started|Attempting to (create|connect)|Connection is encrypted|Connection initiated|Local TCP server started|RDP client started|No active tunnels detected|Access is denied|ServiceAccount error|Establishing connection with TruGrid environment|Connection with TruGrid environment established|Checking if machine|Connection for RDP Client has been received|Running relay tunnel|Requested RDP session launching|Relay connection initialization complete|RDP session launched|Use Web SSO|Connecting to: https?://ws\.trugrid\.com|(Secure Connect|Sentry) configuration completed|Reporting machines|For host .+ was resolved .+ IP address|RDP connection for .+ available|The current machine is joined to Azure:|\d+ machines? reported|TruGrid Relay: (Connecting to Instance|Connection to (Instance|target machine)))' }
)

# ============================================================================
# Functions
# ============================================================================

function Find-LogSources {
# Detect which of the three log sources are present on this box.
# For each install folder, scan for any *.log file and pick the most recent,
# since Sentry uses Agent.log but SecureConnect uses 'Secure Connect.log',
# and either naming could change in future installer revisions.
# Returns an array of hashtables { Role; Path }.
$sources = @()

if (Test-Path -LiteralPath $Script:SentryInstallDir) {
$latest = Get-ChildItem -LiteralPath $Script:SentryInstallDir -Filter '*.log' -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($latest) { $sources += @{ Role = 'Sentry'; Path = $latest.FullName } }
}
if (Test-Path -LiteralPath $Script:SecureConnectInstallDir) {
$latest = Get-ChildItem -LiteralPath $Script:SecureConnectInstallDir -Filter '*.log' -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($latest) { $sources += @{ Role = 'SecureConnect'; Path = $latest.FullName } }
}
if (Test-Path -LiteralPath $Script:ConnectorLogDir) {
$latest = Get-ChildItem -LiteralPath $Script:ConnectorLogDir -Filter $Script:ConnectorLogPattern -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($latest) {
$sources += @{ Role = 'Connector'; Path = $latest.FullName }
}
}

return $sources
}

function Read-LogShared {
# Reads a file using FileShare.ReadWrite so we do not fight the running service.
param([Parameter(Mandatory)] [string]$Path)

$fs = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
try {
$sr = New-Object System.IO.StreamReader($fs)
try {
$lines = New-Object System.Collections.Generic.List[string]
while (-not $sr.EndOfStream) { $lines.Add($sr.ReadLine()) }
return ,$lines.ToArray()
} finally { $sr.Dispose() }
} finally { $fs.Dispose() }
}

function Get-EventClassification {
param([Parameter(Mandatory)] [string]$Body)

foreach ($t in $Script:TierMap) {
if ($Body -match $t.Pattern) { return $t }
}
return $null
}

function ConvertTo-HtmlSafe {
param([string]$Text)
if (-not $Text) { return '' }
return $Text -replace '&','&amp;' -replace '<','&lt;' -replace '>','&gt;' -replace '"','&quot;' -replace "'",'&#39;'
}

function Read-PromptYesNo {
# Returns $true / $false. $defaultYes controls behaviour on empty input.
param(
[Parameter(Mandatory)] [string]$Prompt,
[bool]$DefaultYes = $false
)
$hint = if ($DefaultYes) { '[Y/n]' } else { '[y/N]' }
$answer = Read-Host "$Prompt $hint"
if ([string]::IsNullOrWhiteSpace($answer)) { return $DefaultYes }
return $answer -match '^(y|yes)$'
}

function Read-PromptInt {
param(
[Parameter(Mandatory)] [string]$Prompt,
[Parameter(Mandatory)] [int]$DefaultValue,
[int]$Min = 1,
[int]$Max = [int]::MaxValue
)
while ($true) {
$answer = Read-Host "$Prompt [$DefaultValue]"
if ([string]::IsNullOrWhiteSpace($answer)) { return $DefaultValue }
if ($answer -match '^\d+$') {
$n = [int]$answer
if ($n -ge $Min -and $n -le $Max) { return $n }
}
Write-Host "Enter an integer between $Min and $Max, or blank for default." -ForegroundColor Yellow
}
}

# ============================================================================
# Preference resolution (interactive prompts where parameters not provided)
# ============================================================================

$boundHours = $PSBoundParameters.ContainsKey('HoursBack')
$boundNoise = $PSBoundParameters.ContainsKey('IncludeNoise')
$boundOpen = $PSBoundParameters.ContainsKey('OpenAfter')
$interactive = -not $NonInteractive

Write-Host ""
Write-Host "TruGrid Log Reader" -ForegroundColor Cyan
Write-Host "==================" -ForegroundColor Cyan
Write-Host ""

# Discover sources first so the user can see what is going to be read
$ActiveSources = Find-LogSources
if (-not $ActiveSources) {
Write-Host "No TruGrid log files found. Expected one or more of:" -ForegroundColor Red
Write-Host " $Script:SentryInstallDir*.log"
Write-Host " $Script:SecureConnectInstallDir*.log"
Write-Host " $Script:ConnectorLogDir\$Script:ConnectorLogPattern"
Write-Host ""
Write-Host "If you are on a workstation with only the Connector, ensure you are" -ForegroundColor Yellow
Write-Host "running this as the user whose Connector profile you want to read." -ForegroundColor Yellow
return
}

Write-Host "Detected log sources:" -ForegroundColor Cyan
foreach ($s in $ActiveSources) {
Write-Host (" [+] {0,-14} {1}" -f $s.Role, $s.Path) -ForegroundColor DarkGray
}
foreach ($missing in @(
@{ Role='Sentry'; Path=$Script:SentryLogPath }
@{ Role='SecureConnect'; Path=$Script:SecureConnectLogPath }
)) {
if ($missing.Role -notin ($ActiveSources | ForEach-Object { $_.Role })) {
Write-Host (" [ ] {0,-14} not installed" -f $missing.Role) -ForegroundColor DarkGray
}
}
if ('Connector' -notin ($ActiveSources | ForEach-Object { $_.Role })) {
Write-Host (" [ ] {0,-14} not installed for this user" -f 'Connector') -ForegroundColor DarkGray
}
Write-Host ""

# Resolve HoursBack
if (-not $boundHours) {
if ($interactive) {
$HoursBack = Read-PromptInt -Prompt 'How many hours of log history to include?' -DefaultValue 24 -Min 1 -Max 720
} else {
$HoursBack = 24
}
}

# Resolve IncludeNoise
if (-not $boundNoise) {
if ($interactive) {
$IncludeNoise = Read-PromptYesNo -Prompt 'Include T3 noise events?' -DefaultYes:$false
}
# If non-interactive and not bound, leave at switch default ($false)
}

# Resolve OpenAfter
if (-not $boundOpen) {
if ($interactive) {
$OpenAfter = Read-PromptYesNo -Prompt 'Open the report in the default browser when done?' -DefaultYes:$true
}
# If non-interactive, leave at switch default ($false)
}

Write-Host ""
Write-Host ("Settings: {0}h history | T3 {1} | open after {2}" -f $HoursBack, $(if ($IncludeNoise) { 'included' } else { 'excluded' }), $(if ($OpenAfter) { 'yes' } else { 'no' })) -ForegroundColor DarkGray
Write-Host ""

# ============================================================================
# Parse logs
# ============================================================================

$Cutoff = [datetime]::UtcNow.AddHours(-$HoursBack)
$Events = New-Object System.Collections.Generic.List[object]
$TotalScanned = 0

foreach ($source in $ActiveSources) {
Write-Host ("Reading {0}..." -f $source.Role) -ForegroundColor Cyan -NoNewline

$lines = Read-LogShared -Path $source.Path
$TotalScanned += $lines.Length
$kept = 0

foreach ($line in $lines) {
if ($line -notmatch $Script:LineRegex) { continue }

# Trim fractional seconds to .fff since ParseExact does not tolerate variable precision
$tsRaw = $matches.ts
$tz = $matches.tz
$dotIdx = $tsRaw.IndexOf('.')
if ($dotIdx -gt 0) { $tsRaw = $tsRaw.Substring(0, $dotIdx + 4) }

try {
if ($tz -eq 'UTC' -or $tz -eq 'Z') {
$ts = [datetime]::ParseExact($tsRaw, 'yyyy-MM-dd HH:mm:ss.fff', $null)
$ts = [datetime]::SpecifyKind($ts, [DateTimeKind]::Utc)
} else {
# Local time with offset like +02:00 or -05:00
$combined = "$tsRaw$tz"
$dto = [DateTimeOffset]::ParseExact($combined, 'yyyy-MM-dd HH:mm:ss.fffzzz', [System.Globalization.CultureInfo]::InvariantCulture)
$ts = $dto.UtcDateTime
}
} catch {
continue
}
if ($ts -lt $Cutoff) { continue }

$lvl = $matches.lvl
if ($lvl -eq 'WARNING') { $lvl = 'WARN' }
$body = $matches.body.Trim()

$cls = Get-EventClassification -Body $body
if (-not $cls) {
# Uncategorised events: only keep ERROR/WARN, drop INFO/DEBUG/TRACE
if ($lvl -notin @('ERROR','WARN')) { continue }
$cls = @{ ID=''; Tier='T?'; Name='Uncategorised'; Action='Review' }
}

if (-not $IncludeNoise -and $cls.Tier -eq 'T3') { continue }

$Events.Add([pscustomobject]@{
Timestamp = $ts
Source = $source.Role
Level = $lvl
Tier = $cls.Tier
EventID = $cls.ID
Name = $cls.Name
Action = $cls.Action
Body = $body
})
$kept++
}
Write-Host (" $kept event(s) kept") -ForegroundColor DarkGray
}

Write-Host ("Scanned {0:N0} line(s) total, kept {1:N0}." -f $TotalScanned, $Events.Count) -ForegroundColor DarkGray

# Sort newest first
$Events = @($Events | Sort-Object Timestamp -Descending)

# ============================================================================
# Summary stats
# ============================================================================

$TierCounts = [ordered]@{
T1 = @($Events | Where-Object { $_.Tier -eq 'T1' }).Count
T2 = @($Events | Where-Object { $_.Tier -eq 'T2' }).Count
T4 = @($Events | Where-Object { $_.Tier -eq 'T4' }).Count
T3 = @($Events | Where-Object { $_.Tier -eq 'T3' }).Count
'T?' = @($Events | Where-Object { $_.Tier -eq 'T?' }).Count
}
$SourceCounts = [ordered]@{}
foreach ($s in $ActiveSources) {
$SourceCounts[$s.Role] = @($Events | Where-Object { $_.Source -eq $s.Role }).Count
}

# ============================================================================
# Output path (user context, created if missing)
# ============================================================================

if (-not (Test-Path -LiteralPath $OutputDir)) {
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
}

$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$OutputPath = Join-Path $OutputDir "trugrid-log-report-$stamp.html"

# ============================================================================
# Build HTML
# ============================================================================

$css = @'
:root {
--bg: #0a0e14; --bg-2: #11161f; --surface: #161c26;
--border: #2a3340; --border-soft: #1f2733;
--text: #e6edf3; --text-2: #9ba6b5; --text-3: #6b7585;
--t1: #ff5c5c; --t2: #ffa94d; --t3: #45c4d4; --t4: #8aa1ff; --tq: #8b949e;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5; padding: 28px; }
.container { max-width: 1400px; margin: 0 auto; }
header { border-bottom: 1px solid var(--border); padding-bottom: 20px; margin-bottom: 24px; }
h1 { font-size: 26px; font-weight: 600; letter-spacing: -0.01em; margin-bottom: 6px; }
.subtitle { color: var(--text-2); font-size: 13px; font-family: 'JetBrains Mono', Consolas, monospace; }
.summary { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin: 24px 0; }
.summary-card { background: var(--surface); border: 1px solid var(--border); padding: 16px; border-left: 3px solid var(--border); }
.summary-card.t1 { border-left-color: var(--t1); }
.summary-card.t2 { border-left-color: var(--t2); }
.summary-card.t3 { border-left-color: var(--t3); }
.summary-card.t4 { border-left-color: var(--t4); }
.summary-card.tq { border-left-color: var(--tq); }
.summary-num { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 26px; font-weight: 600; line-height: 1; }
.summary-label { font-size: 10px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.12em; margin-top: 8px; font-family: 'JetBrains Mono', Consolas, monospace; }
.sources-row { display: flex; gap: 16px; margin-bottom: 24px; flex-wrap: wrap; padding: 14px 16px; background: var(--surface); border: 1px solid var(--border); }
.source-stat { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 11px; color: var(--text-2); letter-spacing: 0.04em; }
.source-stat strong { color: var(--text); font-weight: 600; }
.filters { display: flex; gap: 6px; margin-bottom: 14px; flex-wrap: wrap; align-items: center; }
.filters-label { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 10px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.12em; margin-right: 8px; }
.filter-btn { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 11px; background: transparent; color: var(--text-2); border: 1px solid var(--border); padding: 6px 11px; cursor: pointer; letter-spacing: 0.04em; transition: all 0.12s; }
.filter-btn:hover { color: var(--text); border-color: var(--text-3); }
.filter-btn.active { background: var(--text); color: var(--bg); border-color: var(--text); }
.filter-btn[data-tier="T1"].active { background: var(--t1); border-color: var(--t1); color: var(--bg); }
.filter-btn[data-tier="T2"].active { background: var(--t2); border-color: var(--t2); color: var(--bg); }
.filter-btn[data-tier="T3"].active { background: var(--t3); border-color: var(--t3); color: var(--bg); }
.filter-btn[data-tier="T4"].active { background: var(--t4); border-color: var(--t4); color: var(--bg); }
.filter-btn .ct { opacity: 0.55; margin-left: 6px; }
.events { display: flex; flex-direction: column; gap: 2px; }
.event { display: grid; grid-template-columns: 158px 100px 56px 1fr; gap: 16px; padding: 11px 14px 11px 18px; background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--border); align-items: start; font-size: 12.5px; }
.event[data-tier="T1"] { border-left-color: var(--t1); }
.event[data-tier="T2"] { border-left-color: var(--t2); }
.event[data-tier="T3"] { border-left-color: var(--t3); }
.event[data-tier="T4"] { border-left-color: var(--t4); }
.event[data-tier="Tq"] { border-left-color: var(--tq); }
.ev-time { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 11px; color: var(--text-3); white-space: nowrap; }
.ev-source { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 10px; background: rgba(255,255,255,0.05); padding: 3px 7px; text-align: center; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-2); align-self: start; border: 1px solid var(--border-soft); }
.ev-tier { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 10px; font-weight: 700; padding: 3px 7px; text-align: center; letter-spacing: 0.08em; align-self: start; }
.event[data-tier="T1"] .ev-tier { color: var(--t1); background: rgba(255,92,92,0.1); border: 1px solid rgba(255,92,92,0.25); }
.event[data-tier="T2"] .ev-tier { color: var(--t2); background: rgba(255,169,77,0.1); border: 1px solid rgba(255,169,77,0.25); }
.event[data-tier="T3"] .ev-tier { color: var(--t3); background: rgba(69,196,212,0.1); border: 1px solid rgba(69,196,212,0.25); }
.event[data-tier="T4"] .ev-tier { color: var(--t4); background: rgba(138,161,255,0.1); border: 1px solid rgba(138,161,255,0.25); }
.event[data-tier="Tq"] .ev-tier { color: var(--tq); background: rgba(139,148,158,0.08); border: 1px solid rgba(139,148,158,0.25); }
.ev-content { min-width: 0; }
.ev-name { font-weight: 500; margin-bottom: 4px; color: var(--text); }
.ev-name .ev-id { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 10px; color: var(--text-3); margin-right: 8px; letter-spacing: 0.04em; }
.ev-name .ev-level { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 9px; padding: 1px 5px; margin-right: 8px; letter-spacing: 0.08em; }
.ev-name .ev-level.ERROR { background: rgba(255,92,92,0.15); color: var(--t1); }
.ev-name .ev-level.WARN { background: rgba(255,169,77,0.15); color: var(--t2); }
.ev-name .ev-level.INFO { background: rgba(69,196,212,0.12); color: var(--t3); }
.ev-body { font-family: 'JetBrains Mono', Consolas, monospace; font-size: 11px; color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; line-height: 1.5; }
.ev-body.expanded { white-space: pre-wrap; word-break: break-word; }
.empty { text-align: center; padding: 80px 20px; color: var(--text-3); font-style: italic; background: var(--surface); border: 1px dashed var(--border); }
footer { margin-top: 32px; padding-top: 20px; border-top: 1px solid var(--border); font-family: 'JetBrains Mono', Consolas, monospace; font-size: 11px; color: var(--text-3); letter-spacing: 0.05em; }
@media (max-width: 1000px) { .summary { grid-template-columns: repeat(2, 1fr); } .event { grid-template-columns: 1fr; gap: 6px; } }
'@

$sb = New-Object System.Text.StringBuilder
$genTime = Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz'
$hostName = $env:COMPUTERNAME
$userName = $env:USERNAME

[void]$sb.AppendLine('<!DOCTYPE html>')
[void]$sb.AppendLine('<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">')
[void]$sb.AppendLine("<title>TruGrid Log Report - $hostName - $(Get-Date -Format 'yyyy-MM-dd HH:mm')</title>")
[void]$sb.AppendLine('<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet">')
[void]$sb.AppendLine('<style>')
[void]$sb.AppendLine($css)
[void]$sb.AppendLine('</style></head><body><div class="container">')

# Header
$noiseTag = if ($IncludeNoise) { 'T3 noise included' } else { 'T3 noise filtered' }
[void]$sb.AppendLine("<header><h1>TruGrid Log Report</h1><div class='subtitle'>Host: <strong style='color:var(--text);'>$hostName</strong> &nbsp;//&nbsp; User: <strong style='color:var(--text);'>$userName</strong> &nbsp;//&nbsp; Generated $genTime &nbsp;//&nbsp; Last $HoursBack h &nbsp;//&nbsp; $($Events.Count) event(s) &nbsp;//&nbsp; $noiseTag</div></header>")

# Summary cards
[void]$sb.AppendLine('<div class="summary">')
foreach ($k in $TierCounts.Keys) {
$cssCls = switch ($k) { 'T1' {'t1'} 'T2' {'t2'} 'T3' {'t3'} 'T4' {'t4'} default {'tq'} }
$label = switch ($k) { 'T1' {'T1 Flag'} 'T2' {'T2 Correlate'} 'T3' {'T3 Ignore'} 'T4' {'T4 Track'} default {'Uncategorised'} }
[void]$sb.AppendLine("<div class='summary-card $cssCls'><div class='summary-num'>$($TierCounts[$k])</div><div class='summary-label'>$label</div></div>")
}
[void]$sb.AppendLine('</div>')

# Sources row
[void]$sb.AppendLine('<div class="sources-row">')
foreach ($k in $SourceCounts.Keys) {
[void]$sb.AppendLine("<div class='source-stat'>$($k.ToUpper()) <strong>$($SourceCounts[$k])</strong> event(s)</div>")
}
[void]$sb.AppendLine('</div>')

# Filter buttons
[void]$sb.AppendLine('<div class="filters">')
[void]$sb.AppendLine('<span class="filters-label">// filter</span>')
[void]$sb.AppendLine("<button class='filter-btn active' data-filter='all'>All <span class='ct'>$($Events.Count)</span></button>")
foreach ($k in @('T1','T2','T4','T3','T?')) {
if ($TierCounts[$k] -gt 0) {
$dataTier = if ($k -eq 'T?') { 'Tq' } else { $k }
$label = switch ($k) { 'T1' {'T1'} 'T2' {'T2'} 'T3' {'T3'} 'T4' {'T4'} default {'??'} }
[void]$sb.AppendLine("<button class='filter-btn' data-tier='$dataTier' data-filter='tier-$dataTier'>$label <span class='ct'>$($TierCounts[$k])</span></button>")
}
}
foreach ($k in $SourceCounts.Keys) {
if ($SourceCounts[$k] -gt 0) {
[void]$sb.AppendLine("<button class='filter-btn' data-filter='src-$k'>$($k.ToUpper()) <span class='ct'>$($SourceCounts[$k])</span></button>")
}
}
[void]$sb.AppendLine('</div>')

# Events
[void]$sb.AppendLine('<div class="events" id="events">')
if ($Events.Count -eq 0) {
$hint = if (-not $IncludeNoise) { ' Re-run with -IncludeNoise or answer Y to the noise prompt to see T3 events.' } else { '' }
[void]$sb.AppendLine("<div class='empty'>No events in the last $HoursBack hour(s).$hint</div>")
} else {
foreach ($e in $Events) {
$tierAttr = if ($e.Tier -eq 'T?') { 'Tq' } else { $e.Tier }
$tsLocal = $e.Timestamp.ToLocalTime().ToString('yyyy-MM-dd HH:mm:ss')
$bodySafe = ConvertTo-HtmlSafe $e.Body
$nameSafe = ConvertTo-HtmlSafe $e.Name
$idPart = if ($e.EventID) { "<span class='ev-id'>$($e.EventID)</span>" } else { '' }
[void]$sb.AppendLine("<div class='event' data-tier='$tierAttr' data-source='$($e.Source)'>")
[void]$sb.AppendLine("<div class='ev-time'>$tsLocal</div>")
[void]$sb.AppendLine("<div class='ev-source'>$($e.Source)</div>")
[void]$sb.AppendLine("<div class='ev-tier'>$($e.Tier)</div>")
[void]$sb.AppendLine("<div class='ev-content'><div class='ev-name'>$idPart<span class='ev-level $($e.Level)'>$($e.Level)</span>$nameSafe</div><div class='ev-body' title='Click to expand'>$bodySafe</div></div>")
[void]$sb.AppendLine('</div>')
}
}
[void]$sb.AppendLine('</div>')

[void]$sb.AppendLine("<footer>// TruGrid Log Report &nbsp; // &nbsp; Tier definitions: see Sentry Log Glossary &nbsp; // &nbsp; Click any log body to expand &nbsp; // &nbsp; Times shown in local time</footer>")
[void]$sb.AppendLine('</div>')

$js = @'
const buttons = document.querySelectorAll('.filter-btn');
const events = document.querySelectorAll('.event');
buttons.forEach(btn => btn.addEventListener('click', () => {
buttons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const filter = btn.dataset.filter;
events.forEach(ev => {
if (filter === 'all') { ev.style.display = ''; return; }
if (filter.startsWith('tier-')) {
ev.style.display = ev.dataset.tier === filter.substring(5) ? '' : 'none';
} else if (filter.startsWith('src-')) {
ev.style.display = ev.dataset.source === filter.substring(4) ? '' : 'none';
}
});
}));
document.querySelectorAll('.ev-body').forEach(el => {
el.addEventListener('click', () => el.classList.toggle('expanded'));
});
'@
[void]$sb.AppendLine('<script>')
[void]$sb.AppendLine($js)
[void]$sb.AppendLine('</script></body></html>')

# Write file
[System.IO.File]::WriteAllText($OutputPath, $sb.ToString(), [System.Text.Encoding]::UTF8)

# ============================================================================
# Console summary
# ============================================================================

Write-Host ""
Write-Host "Report written:" -ForegroundColor Green
Write-Host " $OutputPath" -ForegroundColor White
Write-Host ""
Write-Host (" T1 Flag {0,5:N0}" -f $TierCounts.T1) -ForegroundColor $(if ($TierCounts.T1 -gt 0) { 'Red' } else { 'DarkGray' })
Write-Host (" T2 Correlate {0,5:N0}" -f $TierCounts.T2) -ForegroundColor $(if ($TierCounts.T2 -gt 0) { 'Yellow' } else { 'DarkGray' })
Write-Host (" T4 Track {0,5:N0}" -f $TierCounts.T4) -ForegroundColor $(if ($TierCounts.T4 -gt 0) { 'Blue' } else { 'DarkGray' })
if ($IncludeNoise) {
Write-Host (" T3 Ignore {0,5:N0}" -f $TierCounts.T3) -ForegroundColor DarkGray
}
Write-Host (" Uncategorised {0,5:N0}" -f $TierCounts.'T?') -ForegroundColor DarkGray

if ($OpenAfter) {
Write-Host ""
Write-Host "Opening report..." -ForegroundColor Cyan
Start-Process $OutputPath
}


Host Check Script


Host Event Check Read-only inspection of the local Windows host's Security, TerminalServices, and TLS/NLA event logs to surface failed and successful logons, RDP session activity, and TLS handshake failures relevant to TruGrid auth troubleshooting. Also captures a posture snapshot (hotfixes, RDP listener config, NLA/CredSSP policy, RDS services, sessions, and resource state) and flags known-bad configurations such as the KB5074109 token broker regression or an active Remote Credential Guard policy.


# Check the Windows event logs on the host machine for evidence relevant
# to TruGrid RDP/auth troubleshooting. Read-only: queries Security,
# TerminalServices, RdpCoreTS, and System logs, decodes 4625 SubStatus and
# LogonType codes, surfaces common patterns, produces an HTML report in
# Desktop\TruGrid Reports\reports\.
#
# Inputs: -HoursBack <int>. When run without parameters, prompts interactively.
# Requires admin for the Security log portion. Without admin, those sections
# are skipped with a banner. Other logs work unprivileged.

[CmdletBinding()]
param(
[int]$HoursBack = 24
)

# --- config ---

$Script:OutputDir = Join-Path ([Environment]::GetFolderPath('Desktop')) 'TruGrid Reports\reports'
$Script:ScriptVersion = '1.0.0'

# TruGrid logo SVG (inline)
$Script:TruGridLogoSvg = @'
<svg style="width:140px;height:auto;" viewBox="0 0 979 293" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M538.332 110.814C586.777 110.814 624.152 134.713 631.715 169.262L605.376 171.382C596.936 150.34 568.364 133.354 538.552 133.354C500.519 133.354 462.253 159.045 462.253 201.994C462.253 245.116 500.3 270.46 539.867 270.46C575.817 270.46 609.106 246.247 609.873 217.673H534.512V195.593H633.25V215.767C633.25 260.534 594.34 293 539.867 293C479.037 293 437.825 258.104 437.825 201.994C437.825 145.364 480.242 110.814 538.332 110.814ZM979 222.415C979 222.466 979.001 222.516 979.001 222.567C979.001 222.618 979 222.668 979 222.719V291.122H952.693V276.9C940.185 286.957 924.146 293 906.656 293C866.701 293 834.312 261.466 834.312 222.567C834.312 183.668 866.702 152.135 906.656 152.135C924.146 152.135 940.185 158.177 952.693 168.234V110.814H979V222.415ZM161.601 137.109H93.0146V291.122H68.5859V137.109H0V112.693H161.601V137.109ZM253.683 180.307C253.515 180.313 210.676 181.787 199.182 217.472V291.122H172.875V161.526H199.182V170.495C205.118 166.285 221.148 157.769 253.683 157.769V180.307ZM308.169 246.05C309.737 254.503 318.319 270.836 340.112 268.583C365.438 265.964 378.586 260.099 383.332 238.169V161.525H409.64V291.122H383.332V281.224C369.615 287.529 357.22 291.122 341.052 291.122C287.494 291.122 282.351 256.451 281.896 247.923H281.861V246.976C281.848 246.377 281.86 246.047 281.86 246.045H281.861V161.525H308.169V246.05ZM749.76 180.307C749.76 180.307 706.775 181.717 695.259 217.472V291.122H668.952V161.526H695.259V170.495C701.196 166.285 717.225 157.769 749.76 157.769V180.307ZM806.125 291.122H779.817V155.891H806.125V291.122ZM908.535 174.673C882.072 174.673 860.619 195.695 860.619 221.628C860.619 247.56 882.072 268.583 908.535 268.583C928.389 268.583 945.423 256.75 952.693 239.888V203.366C945.423 186.505 928.389 174.673 908.535 174.673Z" fill="#4169B8"/>
<path d="M792.972 105.18C802.311 105.18 809.883 112.748 809.883 122.083C809.883 131.419 802.312 138.987 792.972 138.987C783.632 138.987 776.06 131.419 776.06 122.083C776.06 112.747 783.632 105.18 792.972 105.18ZM759.147 127.718H668.952V118.327H759.147V127.718ZM916.99 127.718H826.795V118.327H916.99V127.718ZM798.608 88.2754H789.213V0H798.608V88.2754Z" fill="#B8860B"/>
</svg>
'@

# SubStatus decoding for Security 4625
$Script:SubStatusMap = @{
'0xC000005E' = 'No logon servers available (DC briefly unavailable)'
'0xC0000064' = 'User name does not exist'
'0xC000006A' = 'User name correct but password is wrong'
'0xC000006D' = 'User name or password is wrong'
'0xC000006E' = 'Account restriction prevented logon'
'0xC000006F' = 'User cannot sign in outside permitted hours'
'0xC0000070' = 'User cannot sign in from this workstation'
'0xC0000071' = 'Password has expired'
'0xC0000072' = 'Account is disabled'
'0xC00000DC' = 'Server is in the wrong state to perform the operation'
'0xC0000133' = 'Time difference between client and server exceeds maximum allowed'
'0xC000015B' = 'Logon type not granted (user does not have permission for this logon type)'
'0xC000018B' = 'Mismatched domain trust on the credential'
'0xC000018C' = 'Trust relationship between primary and trusted domain failed'
'0xC0000193' = 'Account has expired'
'0xC0000022' = 'Access denied (RDS layer: not in Remote Desktop Users, license issue, or deny logon right)'
'0xC0000224' = 'User must change password before signing in'
'0xC0000234' = 'Account is locked out'
'0xC0000371' = 'Local account database does not contain the credential'
}

# LogonType decoding
$Script:LogonTypeMap = @{
2 = 'Interactive (console)'
3 = 'Network (SMB, RPC, NLA pre-auth)'
4 = 'Batch (scheduled task)'
5 = 'Service'
7 = 'Unlock (workstation unlock)'
8 = 'NetworkCleartext'
9 = 'NewCredentials (RunAs /netonly)'
10 = 'RemoteInteractive (RDP)'
11 = 'CachedInteractive (cached creds)'
}

# Event ID decoding for the various logs
$Script:LsmEventMap = @{
21 = 'Session logon'
22 = 'Shell start'
23 = 'Session logoff'
24 = 'Session disconnect'
25 = 'Session reconnect'
41 = 'Logoff complete'
}
$Script:RcmEventMap = @{
1149 = 'User authentication succeeded (pre-session)'
1158 = 'Session disconnected by user'
}
$Script:RdpCoreEventMap = @{
65 = 'Listener accepted connection'
98 = 'Handshake completed'
131 = 'TLS handshake started'
140 = 'X.224 connection established'
226 = 'Handshake failure'
}

# --- helpers ---

function Test-IsElevated {
$id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [System.Security.Principal.WindowsPrincipal]::new($id)
return $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
}

function Test-IsRdsHost {
# Best-effort detection: registry presence of Terminal Server config
try {
$rdsKey = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' -ErrorAction Stop
if ($null -ne $rdsKey.fDenyTSConnections -and $rdsKey.fDenyTSConnections -eq 0) {
return $true
}
} catch { }
# Fallback: check for RDSH role via WMI
try {
$os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
if ($os.ProductType -eq 3) { return $true } # 3 = server with RDS often
} catch { }
return $false
}

function Get-HostInventory {
$info = [pscustomobject]@{
Hostname = $env:COMPUTERNAME
OS = 'Unknown'
OSVersion = 'Unknown'
LastReboot = 'Unknown'
IsRdsHost = (Test-IsRdsHost)
Elevated = (Test-IsElevated)
ActiveSessions = 0
}
try {
$os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
$info.OS = $os.Caption
$info.OSVersion = $os.Version
$info.LastReboot = $os.LastBootUpTime.ToString('yyyy-MM-dd HH:mm:ss')
} catch { }
try {
$info.ActiveSessions = (qwinsta 2>$null | Where-Object { $_ -match '^\s+\S+\s+\S+\s+\d+\s+Active' }).Count
} catch { }
return $info
}

function Get-HostPosture {
# Comprehensive posture snapshot: hotfixes, RDP listener config, NLA/CredSSP
# policy registry values, RDS services, active sessions, port state, dynamic
# port range, time sync, LSASS pressure, memory. Most of this runs without admin.
$posture = [pscustomobject]@{
Hotfixes = @()
RdpListener = $null
NlaPolicy = @{}
RdsServices = @()
ActiveSessions = @()
Port3389State = @{}
DynamicPortRange = ''
TimeSync = ''
LsassStress = $null
MemoryAvailable = ''
}

# KBs we care about specifically (Jan 2026 token broker regression and its fix, plus subsequent cumulatives)
$kbsOfInterest = @('KB5074109','KB5077744','KB5078766','KB5079456','KB5081456','KB5082142','KB5082200','KB5083769','KB5073455','KB5073457')

# Hotfixes - last 180 days, sorted newest first
try {
$hf = Get-HotFix -ErrorAction Stop | Where-Object { $_.InstalledOn -and $_.InstalledOn -ge (Get-Date).AddDays(-180) } | Sort-Object InstalledOn -Descending
foreach ($h in $hf) {
$posture.Hotfixes += [pscustomobject]@{
HotFixID = $h.HotFixID
Description = $h.Description
InstalledOn = $h.InstalledOn
IsOfInterest = ($kbsOfInterest -contains $h.HotFixID)
}
}
} catch { }

# RDP listener config via WMI
try {
$ts = Get-CimInstance -Namespace 'root\CIMV2\TerminalServices' -ClassName 'Win32_TSGeneralSetting' -Filter "TerminalName='RDP-tcp'" -ErrorAction Stop
$posture.RdpListener = [pscustomobject]@{
SecurityLayer = $ts.SecurityLayer
SecurityLayerName = switch ($ts.SecurityLayer) { 0 {'RDP Security'} 1 {'Negotiate'} 2 {'SSL/TLS'} default {'Unknown'} }
UserAuthenticationRequired = [bool]$ts.UserAuthenticationRequired
MinEncryptionLevel = $ts.MinEncryptionLevel
EncryptionLevelName = switch ($ts.MinEncryptionLevel) { 1 {'Low'} 2 {'Client Compatible'} 3 {'High'} 4 {'FIPS'} default {'Unknown'} }
SSLCertificateSHA1Hash = $ts.SSLCertificateSHA1Hash
fEnableWinStation = [bool]$ts.fEnableWinStation
}
} catch { }

# NLA/CredSSP/Credential Guard registry policy
$regChecks = @(
@{ Path='HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server'; Name='fDenyTSConnections' }
@{ Path='HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp'; Name='UserAuthentication' }
@{ Path='HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp'; Name='SecurityLayer' }
@{ Path='HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp'; Name='MinEncryptionLevel' }
@{ Path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa'; Name='LsaCfgFlags' }
@{ Path='HKLM:\SYSTEM\CurrentControlSet\Control\Lsa'; Name='RestrictRemoteSAM' }
@{ Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\CredSSP\Parameters'; Name='AllowEncryptionOracle' }
@{ Path='HKLM:\SOFTWARE\Policies\Microsoft\Windows\CredentialsDelegation'; Name='AllowDefaultCredentials' }
@{ Path='HKLM:\SOFTWARE\Policies\Microsoft\Windows\CredentialsDelegation'; Name='AllowSavedCredentials' }
@{ Path='HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services'; Name='UserAuthentication' }
@{ Path='HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services'; Name='SecurityLayer' }
)
foreach ($rc in $regChecks) {
$key = "$($rc.Path)\$($rc.Name)"
try {
$val = Get-ItemProperty -Path $rc.Path -Name $rc.Name -ErrorAction Stop
$posture.NlaPolicy[$key] = $val.($rc.Name)
} catch {
$posture.NlaPolicy[$key] = '(not set)'
}
}

# RDS-related services
try {
$svcs = Get-Service -Name TermService, UmRdpService, SessionEnv, RdAgent, RDMS, Tssdis -ErrorAction SilentlyContinue
foreach ($s in $svcs) {
$posture.RdsServices += [pscustomobject]@{
Name = $s.Name
DisplayName = $s.DisplayName
Status = $s.Status.ToString()
StartType = $s.StartType.ToString()
}
}
} catch { }

# Active sessions via qwinsta (parse text output)
try {
$qOut = qwinsta 2>$null
if ($qOut -and $qOut.Count -gt 1) {
foreach ($line in ($qOut | Select-Object -Skip 1)) {
if ($line -match '^\s*(>?)\s*(\S+)\s+(\S+)?\s+(\d+)\s+(\S+)') {
$posture.ActiveSessions += [pscustomobject]@{
SessionName = $matches[2]
UserName = if ($matches[3]) { $matches[3] } else { '(none)' }
Id = $matches[4]
State = $matches[5]
}
}
}
}
} catch { }

# TCP 3389 listener state
try {
$grp = Get-NetTCPConnection -LocalPort 3389 -ErrorAction Stop | Group-Object State
foreach ($g in $grp) { $posture.Port3389State[$g.Name] = $g.Count }
} catch { }

# Dynamic port range (ephemeral exhaustion check)
try {
$out = (netsh int ipv4 show dynamicport tcp 2>$null) -join "`n"
if ($out -match 'Start Port\s*:\s*(\d+)' ) {
$startPort = [int]$matches[1]
if ($out -match 'Number of Ports\s*:\s*(\d+)') {
$count = [int]$matches[1]
$posture.DynamicPortRange = "$startPort - $($startPort + $count - 1) ($count ports)"
}
}
} catch { }

# Time sync status
try {
$w = (w32tm /query /status 2>$null) -join "`n"
$source = if ($w -match 'Source:\s*(.+)') { $matches[1].Trim() } else { 'unknown' }
$last = if ($w -match 'Last Successful Sync Time:\s*(.+)') { $matches[1].Trim() } else { 'unknown' }
$posture.TimeSync = "Source: $source | Last sync: $last"
} catch { }

# LSASS pressure
try {
$lsass = Get-Process lsass -ErrorAction Stop
$posture.LsassStress = [pscustomobject]@{
CPUSeconds = [math]::Round($lsass.CPU, 1)
Handles = $lsass.Handles
WorkingSetMB = [math]::Round($lsass.WS / 1MB, 1)
}
} catch { }

# Memory
try {
$os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
$freeMB = [math]::Round($os.FreePhysicalMemory / 1024, 0)
$totalMB = [math]::Round($os.TotalVisibleMemorySize / 1024, 0)
$pct = [math]::Round(($freeMB / $totalMB) * 100, 1)
$posture.MemoryAvailable = "$freeMB MB free of $totalMB MB ($pct% free)"
} catch { }

return $posture
}

function Decode-SubStatus {
param($Value)
if ($null -eq $Value) { return @{ Code = ''; Reason = '' } }
$hex = '0x{0:X8}' -f [int64]$Value
$reason = if ($Script:SubStatusMap.ContainsKey($hex)) { $Script:SubStatusMap[$hex] } else { 'Unknown SubStatus' }
return @{ Code = $hex; Reason = $reason }
}

function Decode-LogonType {
param([int]$Value)
if ($Script:LogonTypeMap.ContainsKey($Value)) {
return "$Value ($($Script:LogonTypeMap[$Value]))"
}
return "$Value (unknown)"
}

# --- event collectors ---

function Get-SecurityEvents {
param([datetime]$Since)

if (-not (Test-IsElevated)) { return @() }

$results = [System.Collections.Generic.List[object]]::new()

# 4625 failed logon, 4624 successful logon - filter to network/RDP relevant
try {
$events = Get-WinEvent -FilterHashtable @{
LogName = 'Security'
Id = 4625, 4624, 4776, 4771
StartTime = $Since
} -ErrorAction Stop -MaxEvents 5000
} catch {
Write-Host " Could not read Security log: $($_.Exception.Message)" -ForegroundColor DarkYellow
return @()
}

foreach ($e in $events) {
$obj = [pscustomobject]@{
Timestamp = $e.TimeCreated.ToUniversalTime()
EventId = $e.Id
Source = 'Security'
User = ''
LogonType = $null
LogonTypeStr = ''
SubStatusHex = ''
SubStatusReason = ''
AuthPackage = ''
LogonProcess = ''
Workstation = ''
SourceIP = ''
Description = ''
}
try {
switch ($e.Id) {
4625 {
$obj.User = $e.Properties[5].Value
$obj.LogonType = [int]$e.Properties[10].Value
$obj.LogonTypeStr = Decode-LogonType $obj.LogonType
$sub = Decode-SubStatus $e.Properties[9].Value
$obj.SubStatusHex = $sub.Code
$obj.SubStatusReason = $sub.Reason
$obj.AuthPackage = $e.Properties[12].Value
$obj.LogonProcess = $e.Properties[11].Value
$obj.Workstation = $e.Properties[13].Value
$obj.SourceIP = $e.Properties[19].Value
$obj.Description = 'Failed logon'
# filter to network and RDP logon types
if ($obj.LogonType -notin @(3, 10)) { continue }
[void]$results.Add($obj)
}
4624 {
$obj.User = $e.Properties[5].Value
$obj.LogonType = [int]$e.Properties[8].Value
$obj.LogonTypeStr = Decode-LogonType $obj.LogonType
$obj.AuthPackage = $e.Properties[10].Value
$obj.Workstation = $e.Properties[11].Value
$obj.SourceIP = $e.Properties[18].Value
$obj.Description = 'Successful logon'
if ($obj.LogonType -notin @(3, 10)) { continue }
[void]$results.Add($obj)
}
4776 {
$obj.User = $e.Properties[1].Value
$obj.Workstation = $e.Properties[2].Value
$sub = Decode-SubStatus $e.Properties[3].Value
$obj.SubStatusHex = $sub.Code
$obj.SubStatusReason = $sub.Reason
$obj.Description = 'NTLM credential validation'
[void]$results.Add($obj)
}
4771 {
$obj.User = $e.Properties[0].Value
$sub = Decode-SubStatus $e.Properties[6].Value
$obj.SubStatusHex = $sub.Code
$obj.SubStatusReason = $sub.Reason
$obj.Description = 'Kerberos pre-auth failure'
[void]$results.Add($obj)
}
}
} catch {
# malformed event, skip
}
}
return ,$results.ToArray()
}

function Get-SessionEvents {
param([datetime]$Since)
$results = [System.Collections.Generic.List[object]]::new()
foreach ($logName in @('Microsoft-Windows-TerminalServices-LocalSessionManager/Operational',
'Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational')) {
try {
$events = Get-WinEvent -FilterHashtable @{ LogName = $logName; StartTime = $Since } -ErrorAction Stop -MaxEvents 2000
} catch { continue }
foreach ($e in $events) {
$sourceLog = if ($logName -like '*LocalSessionManager*') { 'LSM' } else { 'RCM' }
$map = if ($sourceLog -eq 'LSM') { $Script:LsmEventMap } else { $Script:RcmEventMap }
$desc = if ($map.ContainsKey([int]$e.Id)) { $map[[int]$e.Id] } else { "Event $($e.Id)" }
[void]$results.Add([pscustomobject]@{
Timestamp = $e.TimeCreated.ToUniversalTime()
EventId = $e.Id
Source = $sourceLog
Description = $desc
Message = ($e.Message -replace '\s+', ' ').Substring(0, [Math]::Min(300, ($e.Message -replace '\s+', ' ').Length))
})
}
}
return ,$results.ToArray()
}

function Get-TlsAndNlaEvents {
param([datetime]$Since)
$results = [System.Collections.Generic.List[object]]::new()
# RdpCoreTS for handshake-level events
try {
$events = Get-WinEvent -FilterHashtable @{
LogName = 'Microsoft-Windows-RemoteDesktopServices-RdpCoreTS/Operational'
StartTime = $Since
} -ErrorAction Stop -MaxEvents 2000
foreach ($e in $events) {
$desc = if ($Script:RdpCoreEventMap.ContainsKey([int]$e.Id)) { $Script:RdpCoreEventMap[[int]$e.Id] } else { "Event $($e.Id)" }
[void]$results.Add([pscustomobject]@{
Timestamp = $e.TimeCreated.ToUniversalTime()
EventId = $e.Id
Source = 'RdpCoreTS'
Description = $desc
Message = ($e.Message -replace '\s+', ' ').Substring(0, [Math]::Min(300, ($e.Message -replace '\s+', ' ').Length))
})
}
} catch { }
# System log filtered to Schannel, LsaSrv, TermDD
foreach ($prov in @('Schannel', 'LsaSrv', 'TermDD')) {
try {
$events = Get-WinEvent -FilterHashtable @{
LogName = 'System'
ProviderName = $prov
StartTime = $Since
} -ErrorAction Stop -MaxEvents 500
foreach ($e in $events) {
[void]$results.Add([pscustomobject]@{
Timestamp = $e.TimeCreated.ToUniversalTime()
EventId = $e.Id
Source = $prov
Description = "$prov event $($e.Id) (level: $($e.LevelDisplayName))"
Message = ($e.Message -replace '\s+', ' ').Substring(0, [Math]::Min(300, ($e.Message -replace '\s+', ' ').Length))
})
}
} catch { }
}
return ,$results.ToArray()
}

# --- pattern analyzers ---

function Find-AuthPatterns {
param([array]$SecurityEvents, [array]$TlsEvents, $Posture)
$cats = @()

# === Posture-derived patterns (config findings, no event activity needed) ===

# KB5074109 (token broker regression) installed without its fix KB5077744
if ($Posture -and $Posture.Hotfixes) {
$hasBroken = $Posture.Hotfixes | Where-Object { $_.HotFixID -eq 'KB5074109' }
$hasFix = $Posture.Hotfixes | Where-Object { $_.HotFixID -in 'KB5077744','KB5078766','KB5079456','KB5081456','KB5082142','KB5082200','KB5083769' }
if ($hasBroken -and -not $hasFix) {
$cats += [pscustomobject]@{
Name = 'Token Broker Regression (KB5074109)'
Confidence = 'high'
Description = "KB5074109 (January 2026 cumulative) is installed but no subsequent cumulative that includes the fix (KB5077744 or later) is present. This combination causes 0xcea20002 NLA errors on hybrid-joined VMs via Trusted Location flows."
Action = "Install KB5077744 or the latest cumulative update. Confirm absence: 'DISM /online /get-packages | findstr 5077744'."
EventCount = 0
SupportingEvents = @()
}
}
}

# NLA disabled on the RDP listener
if ($Posture -and $Posture.RdpListener -and -not $Posture.RdpListener.UserAuthenticationRequired) {
$cats += [pscustomobject]@{
Name = 'NLA Disabled on RDP Listener'
Confidence = 'medium'
Description = "The RDP listener has UserAuthenticationRequired=0 (Network Level Authentication disabled). Sessions will work but credentials are exchanged after the RDP session establishes, which is less secure and breaks TruGrid's expected auth flow."
Action = "Enable NLA via System Properties > Remote tab > 'Allow connections only from computers running Remote Desktop with NLA', or via GPO 'Require user authentication for remote connections by using Network Level Authentication'."
EventCount = 0
SupportingEvents = @()
}
}

# Remote Credential Guard active
$cgFlag = if ($Posture -and $Posture.NlaPolicy) { $Posture.NlaPolicy['HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\LsaCfgFlags'] } else { $null }
if ($cgFlag -and $cgFlag -ne '(not set)' -and [int]$cgFlag -eq 1) {
$cats += [pscustomobject]@{
Name = 'Remote Credential Guard Active'
Confidence = 'medium'
Description = "Remote Credential Guard is enabled (LsaCfgFlags=1). This blocks NTLM-based saved credentials for RDP. Customers reporting 'I save the password but it keeps asking' against this host will see exactly this symptom."
Action = "If credential persistence is needed: 'New-ItemProperty HKLM:\System\CurrentControlSet\Control\LSA -Name LsaCfgFlags -PropertyType DWORD -Value 0 -Force', then reboot."
EventCount = 0
SupportingEvents = @()
}
}

# === Event-derived patterns ===

$failed4625 = $SecurityEvents | Where-Object { $_.EventId -eq 4625 }
$successful4624 = $SecurityEvents | Where-Object { $_.EventId -eq 4624 }

# Pattern 1: Brute force suspicion - many failures from few sources
if ($failed4625.Count -ge 10) {
$bySource = $failed4625 | Where-Object { $_.SourceIP -and $_.SourceIP -ne '-' } | Group-Object SourceIP | Sort-Object Count -Descending
if ($bySource.Count -gt 0 -and $bySource[0].Count -ge 10) {
$cats += [pscustomobject]@{
Name = 'Possible Brute Force'
Confidence = 'high'
Description = "$($bySource[0].Count) failed logons from a single source ($($bySource[0].Name)) within the analyzed window. Investigate this source IP immediately."
Action = "Block the source IP at the firewall or RDP listener. Review for credential compromise indicators."
EventCount = $bySource[0].Count
SupportingEvents = $failed4625 | Where-Object { $_.SourceIP -eq $bySource[0].Name } | Sort-Object Timestamp
}
}
}

# Pattern 2: Account lockouts
$lockouts = $failed4625 | Where-Object { $_.SubStatusHex -eq '0xC0000234' }
if ($lockouts.Count -gt 0) {
$usersLocked = ($lockouts | Select-Object -ExpandProperty User -Unique).Count
$cats += [pscustomobject]@{
Name = 'Account Lockouts'
Confidence = 'high'
Description = "$($lockouts.Count) lockout-related 4625 event(s) affecting $usersLocked unique user(s). The account(s) are locked due to repeated bad password attempts."
Action = "Identify the source of bad password attempts. Reset the user(s) and review whether a cached credential on another machine is causing the lockout."
EventCount = $lockouts.Count
SupportingEvents = $lockouts | Sort-Object Timestamp
}
}

# Pattern 3: DC unavailable (0xC000005E)
$dcDown = $failed4625 | Where-Object { $_.SubStatusHex -eq '0xC000005E' }
if ($dcDown.Count -ge 3) {
$cats += [pscustomobject]@{
Name = 'Domain Controller Unavailable Episodes'
Confidence = 'high'
Description = "$($dcDown.Count) failed logon(s) with SubStatus 0xC000005E (no logon servers available). The host briefly lost DC connectivity during these attempts."
Action = "Check DC health and network path from this host to DCs. Intermittent DC contact often correlates with brief WAN/VPN drops or DC service flaps."
EventCount = $dcDown.Count
SupportingEvents = $dcDown | Sort-Object Timestamp
}
}

# Pattern 4: Wrong password cluster
$wrongPw = $failed4625 | Where-Object { $_.SubStatusHex -in '0xC000006A','0xC000006D' }
if ($wrongPw.Count -ge 5) {
$usersAffected = ($wrongPw | Select-Object -ExpandProperty User -Unique).Count
$cats += [pscustomobject]@{
Name = 'Repeated Wrong-Password Failures'
Confidence = 'medium'
Description = "$($wrongPw.Count) wrong-password failure(s) affecting $usersAffected user(s). Could indicate cached-credential staleness, password change not propagated, or genuine user error."
Action = "Verify whether affected users recently changed their password. Check for stale credential entries in Credential Manager on source machines."
EventCount = $wrongPw.Count
SupportingEvents = $wrongPw | Sort-Object Timestamp
}
}

# Pattern 5: NLA / CredSSP handshake failures
$handshakeFail = $TlsEvents | Where-Object { $_.EventId -eq 226 }
if ($handshakeFail.Count -ge 3) {
$cats += [pscustomobject]@{
Name = 'TLS/CredSSP Handshake Failures'
Confidence = 'high'
Description = "$($handshakeFail.Count) RdpCoreTS handshake failure event(s) (ID 226). NLA/CredSSP negotiation broke mid-handshake. Common with cert mismatches, TLS-inspecting proxies, or time skew."
Action = "Verify the RDP listener cert is valid and unexpired. Check for TLS-inspecting proxies between client and host. Confirm time sync between source and target is within 5 minutes."
EventCount = $handshakeFail.Count
SupportingEvents = $handshakeFail | Sort-Object Timestamp
}
}

# Pattern 6: Schannel errors
$schannelErrors = $TlsEvents | Where-Object { $_.Source -eq 'Schannel' }
if ($schannelErrors.Count -ge 3) {
$cats += [pscustomobject]@{
Name = 'Schannel TLS Errors'
Confidence = 'medium'
Description = "$($schannelErrors.Count) Schannel event(s) in the System log. Often indicates TLS handshake failures, cipher suite mismatches, or certificate issues."
Action = "Review the specific Schannel error codes in the supporting lines. Common ones: 36874 (cipher mismatch), 36888 (fatal alert), 36870 (private key inaccessible)."
EventCount = $schannelErrors.Count
SupportingEvents = $schannelErrors | Sort-Object Timestamp
}
}

# Pattern 7: Healthy auth (success without significant failures)
if ($successful4624.Count -gt 0 -and $failed4625.Count -lt 5 -and $handshakeFail.Count -eq 0) {
$cats += [pscustomobject]@{
Name = 'Authentication Healthy'
Confidence = 'info'
Description = "$($successful4624.Count) successful logon(s) (network/RDP) with $($failed4625.Count) failure(s) in the analyzed window. No significant auth anomalies detected on this host."
Action = "No action required. If a customer is reporting issues, the problem likely lives on the source side or in the path, not on this host."
EventCount = $successful4624.Count
SupportingEvents = @($successful4624 | Sort-Object Timestamp | Select-Object -First 20)
}
}

return ,$cats
}

# --- HTML rendering ---

function ConvertTo-HtmlEncoded {
param([string]$Text)
if ($null -eq $Text) { return '' }
[System.Net.WebUtility]::HtmlEncode($Text)
}

function ConvertTo-Slug {
param([string]$Text)
if ([string]::IsNullOrWhiteSpace($Text)) { return 'anchor' }
$slug = $Text.ToLower() -replace '[^a-z0-9]+', '-' -replace '^-|-$', ''
if ([string]::IsNullOrWhiteSpace($slug)) { $slug = 'anchor' }
return $slug
}

function Write-HtmlReport {
param(
[Parameter(Mandatory)] $Inventory,
[Parameter(Mandatory)] $Posture,
[Parameter(Mandatory)] [AllowEmptyCollection()] [array]$SecurityEvents,
[Parameter(Mandatory)] [AllowEmptyCollection()] [array]$SessionEvents,
[Parameter(Mandatory)] [AllowEmptyCollection()] [array]$TlsEvents,
[Parameter(Mandatory)] [AllowNull()] [AllowEmptyCollection()] [array]$Categories,
[Parameter(Mandatory)] [int]$HoursBack,
[Parameter(Mandatory)] [string]$OutputPath
)

if ($null -eq $Categories) { $Categories = @() }

$sb = [System.Text.StringBuilder]::new()
[void]$sb.AppendLine('<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">')
[void]$sb.AppendLine("<title>TruGrid Host Event Check - $(ConvertTo-HtmlEncoded $Inventory.Hostname)</title>")
[void]$sb.AppendLine(@'
<style>
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap');
:root {
--bg:#142038; --panel:#1F2536; --text:#F4F5F8; --muted:#9aa0a6;
--pass:#83A848; --fail:#ef5350; --warn:#ffa726; --info:#4169B8;
--border:#294274; --accent:#4169B8;
}
*{box-sizing:border-box}
body{background:var(--bg);color:var(--text);font-family:'Montserrat','Segoe UI',sans-serif;margin:0;padding:24px;line-height:1.5}
.header-row{display:flex;justify-content:space-between;align-items:flex-start;gap:24px;margin-bottom:8px}
.header-title{flex:1}
.header-logo{flex-shrink:0;padding-top:4px}
h1{margin:0 0 8px;font-size:22px;color:var(--accent)}
h2{margin:24px 0 12px;font-size:16px;color:var(--accent);border-bottom:1px solid var(--border);padding-bottom:6px}
.meta{color:var(--muted);font-size:12px;margin-bottom:16px}
.banner{padding:14px 18px;border-radius:6px;margin:16px 0;font-weight:600;font-size:14px;background:rgba(65,105,184,0.15);border-left:4px solid var(--accent)}
.banner.warn{background:rgba(255,167,38,0.15);border-left-color:var(--warn)}
.banner.fail{background:rgba(239,83,80,0.15);border-left-color:var(--fail)}
.banner.pass{background:rgba(131,168,72,0.15);border-left-color:var(--pass)}
table{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--border);font-size:13px;margin-bottom:16px}
th,td{padding:8px 12px;text-align:left;border-bottom:1px solid var(--border);vertical-align:top}
th{background:rgba(65,105,184,0.12);font-weight:600;color:var(--accent)}
tr:last-child td{border-bottom:none}
.mono{font-family:Consolas,Menlo,monospace;font-size:12px}
.tier-error{color:var(--fail)}
.tier-warn{color:var(--warn)}
.tier-info{color:var(--muted)}
.env-grid{display:grid;grid-template-columns:200px 1fr;gap:4px 12px;background:var(--panel);padding:12px 16px;border:1px solid var(--border);border-radius:4px;font-size:13px;margin-bottom:16px}
.env-grid .k{color:var(--muted)}
.env-grid .v{font-family:Consolas,Menlo,monospace;word-break:break-all}
.footer{color:var(--muted);font-size:11px;margin-top:32px;border-top:1px solid var(--border);padding-top:12px}
.empty{color:var(--muted);font-style:italic;padding:12px}
.tab-bar{display:flex;gap:4px;margin:24px 0 0;border-bottom:1px solid var(--border)}
.tab-btn{background:transparent;color:var(--muted);border:none;padding:10px 18px;cursor:pointer;font-family:inherit;font-size:13px;font-weight:600;border-bottom:2px solid transparent;transition:all 0.15s}
.tab-btn:hover{color:var(--text)}
.tab-btn.active{color:var(--accent);border-bottom-color:var(--accent)}
.tab-content{display:none;padding-top:16px}
.tab-content.active{display:block}
.category-card{display:flex;gap:14px;background:var(--panel);border-left:4px solid var(--accent);padding:14px 18px;border-radius:4px;margin-bottom:12px}
.category-card.cat-high{border-left-color:var(--fail)}
.category-card.cat-medium{border-left-color:var(--warn)}
.category-card.cat-info{border-left-color:var(--accent)}
.cat-icon{color:var(--accent);font-size:22px;font-weight:bold;flex-shrink:0;width:28px;text-align:center}
.cat-high .cat-icon{color:var(--fail)}
.cat-medium .cat-icon{color:var(--warn)}
.cat-info .cat-icon{color:var(--accent)}
.cat-body{flex:1}
.cat-title{font-weight:600;font-size:14px;color:var(--text);margin-bottom:4px}
.cat-desc{color:var(--text);font-size:13px;margin-bottom:6px}
.cat-action{color:var(--muted);font-size:12px;font-style:italic}
.cat-meta{color:var(--muted);font-size:11px;margin-top:8px;font-family:Consolas,monospace}
.cat-details{margin-top:10px;border-top:1px solid var(--border);padding-top:8px}
.cat-details summary{cursor:pointer;color:var(--accent);font-size:12px;font-weight:600;list-style:none}
.cat-details summary::-webkit-details-marker{display:none}
.cat-details summary::before{content:'▸ ';display:inline-block;transition:transform 0.15s}
.cat-details[open] summary::before{transform:rotate(90deg)}
.copy-btn{background:rgba(65,105,184,0.2);color:var(--accent);border:1px solid var(--border);padding:4px 10px;border-radius:3px;font-family:inherit;font-size:11px;cursor:pointer;margin-bottom:6px}
.copy-btn:hover{background:rgba(65,105,184,0.35)}
.copy-btn.copied{background:rgba(131,168,72,0.25);color:var(--pass)}
.raw-lines{background:#0d1626;border:1px solid var(--border);padding:10px;border-radius:3px;font-family:Consolas,monospace;font-size:11px;color:var(--text);max-height:300px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0}
.anchor-link{color:var(--muted);text-decoration:none;opacity:0.35;margin-left:8px;font-size:0.85em;font-weight:normal}
.anchor-link:hover{opacity:1;color:var(--accent)}
.section-anchor{scroll-margin-top:16px}
</style>
'@)
[void]$sb.AppendLine('</head><body>')

# header
[void]$sb.AppendLine("<div class='header-row'>")
[void]$sb.AppendLine("<div class='header-title'>")
[void]$sb.AppendLine("<h1>TruGrid Host Event Check</h1>")
[void]$sb.AppendLine("<div class='meta'>Generated $((Get-Date).ToString('yyyy-MM-dd HH:mm:ss zzz')) | Script v$($Script:ScriptVersion)</div>")
[void]$sb.AppendLine("</div>")
[void]$sb.AppendLine("<div class='header-logo'>$($Script:TruGridLogoSvg)</div>")
[void]$sb.AppendLine("</div>")

# banner
$totalCritical = ($Categories | Where-Object { $_.Confidence -eq 'high' }).Count
$bannerClass = if ($totalCritical -gt 0) { 'fail' } elseif ($Categories.Count -gt 0) { 'warn' } else { 'pass' }
$bannerMsg = "Analyzed $($SecurityEvents.Count) auth events, $($SessionEvents.Count) session events, $($TlsEvents.Count) TLS/system events. Detected $($Categories.Count) pattern(s) ($totalCritical high-confidence)."
if (-not $Inventory.Elevated) {
$bannerMsg = "NOT RUNNING ELEVATED: Security log (4625/4624/4776/4771) was skipped. Run as administrator for full coverage. " + $bannerMsg
$bannerClass = 'warn'
}
[void]$sb.AppendLine("<div class='banner $bannerClass'>$(ConvertTo-HtmlEncoded $bannerMsg)</div>")

# host inventory
[void]$sb.AppendLine("<h2 class='section-anchor' id='host-inventory'>Host Inventory <a class='anchor-link' href='#host-inventory'>#</a></h2>")
[void]$sb.AppendLine("<div class='env-grid'>")
[void]$sb.AppendLine("<div class='k'>Hostname</div><div class='v'>$(ConvertTo-HtmlEncoded $Inventory.Hostname)</div>")
[void]$sb.AppendLine("<div class='k'>OS</div><div class='v'>$(ConvertTo-HtmlEncoded $Inventory.OS) ($(ConvertTo-HtmlEncoded $Inventory.OSVersion))</div>")
[void]$sb.AppendLine("<div class='k'>Last Reboot</div><div class='v'>$(ConvertTo-HtmlEncoded $Inventory.LastReboot)</div>")
[void]$sb.AppendLine("<div class='k'>RDS Host</div><div class='v'>$(if ($Inventory.IsRdsHost) { 'Yes (RDP enabled)' } else { 'No (RDP disabled or not configured)' })</div>")
[void]$sb.AppendLine("<div class='k'>Active Sessions</div><div class='v'>$($Inventory.ActiveSessions)</div>")
[void]$sb.AppendLine("<div class='k'>Time Window</div><div class='v'>Last $HoursBack hours (from $((Get-Date).AddHours(-$HoursBack).ToString('yyyy-MM-dd HH:mm:ss')) to now)</div>")
[void]$sb.AppendLine("<div class='k'>Elevated</div><div class='v'>$(if ($Inventory.Elevated) { 'Yes' } else { 'No (Security log skipped)' })</div>")
[void]$sb.AppendLine("</div>")

# tab bar
[void]$sb.AppendLine("<div class='tab-bar'>")
[void]$sb.AppendLine("<button class='tab-btn active' id='btn-summary' onclick=`"showTab('summary')`">Summary</button>")
[void]$sb.AppendLine("<button class='tab-btn' id='btn-posture' onclick=`"showTab('posture')`">Host Posture</button>")
[void]$sb.AppendLine("<button class='tab-btn' id='btn-auth' onclick=`"showTab('auth')`">Authentication ($($SecurityEvents.Count))</button>")
[void]$sb.AppendLine("<button class='tab-btn' id='btn-sessions' onclick=`"showTab('sessions')`">Sessions ($($SessionEvents.Count))</button>")
[void]$sb.AppendLine("<button class='tab-btn' id='btn-tls' onclick=`"showTab('tls')`">TLS / NLA / System ($($TlsEvents.Count))</button>")
[void]$sb.AppendLine("</div>")

# === TAB: SUMMARY ===
[void]$sb.AppendLine("<div class='tab-content active' id='content-summary'>")

[void]$sb.AppendLine("<h2 class='section-anchor' id='detected-patterns'>Detected Patterns <a class='anchor-link' href='#detected-patterns'>#</a></h2>")
if ($Categories.Count -eq 0) {
[void]$sb.AppendLine("<div class='empty'>No notable patterns detected in the analyzed window.</div>")
}
else {
$catIdx = 0
foreach ($cat in $Categories) {
$catIdx++
$cardClass = "cat-$($cat.Confidence)"
$icon = switch ($cat.Confidence) { 'high' { '!' } 'medium' { '?' } 'info' { 'i' } default { 'i' } }
$anchorId = "pattern-$(ConvertTo-Slug $cat.Name)"
$rawId = "raw-$catIdx"
[void]$sb.AppendLine("<div class='category-card $cardClass section-anchor' id='$anchorId'>")
[void]$sb.AppendLine("<div class='cat-icon'>$icon</div>")
[void]$sb.AppendLine("<div class='cat-body'>")
[void]$sb.AppendLine("<div class='cat-title'>$(ConvertTo-HtmlEncoded $cat.Name) <a class='anchor-link' href='#$anchorId'>#</a></div>")
[void]$sb.AppendLine("<div class='cat-desc'>$(ConvertTo-HtmlEncoded $cat.Description)</div>")
[void]$sb.AppendLine("<div class='cat-action'>Suggested next step: $(ConvertTo-HtmlEncoded $cat.Action)</div>")
[void]$sb.AppendLine("<div class='cat-meta'>Confidence: $($cat.Confidence) | Events: $($cat.EventCount)</div>")
if ($cat.PSObject.Properties['SupportingEvents'] -and $cat.SupportingEvents -and $cat.SupportingEvents.Count -gt 0) {
[void]$sb.AppendLine("<details class='cat-details'>")
[void]$sb.AppendLine("<summary>Show $($cat.SupportingEvents.Count) supporting event(s)</summary>")
[void]$sb.AppendLine("<button class='copy-btn' onclick=`"copyLogLines('$rawId', this)`">Copy to clipboard</button>")
[void]$sb.AppendLine("<pre class='raw-lines' id='$rawId'>")
foreach ($evt in $cat.SupportingEvents) {
$ts = $evt.Timestamp.ToString('yyyy-MM-dd HH:mm:ss')
$line = "[$ts] [$($evt.Source) $($evt.EventId)] $($evt.Description) | user=$($evt.User) substatus=$($evt.SubStatusHex) source=$($evt.SourceIP)"
[void]$sb.AppendLine((ConvertTo-HtmlEncoded $line))
}
[void]$sb.AppendLine("</pre>")
[void]$sb.AppendLine("</details>")
}
[void]$sb.AppendLine("</div></div>")
}
}

# top distributions
if ($SecurityEvents.Count -gt 0) {
$failed = $SecurityEvents | Where-Object { $_.EventId -eq 4625 }
if ($failed.Count -gt 0) {
[void]$sb.AppendLine("<h2 class='section-anchor' id='top-substatus'>Top SubStatus Codes <a class='anchor-link' href='#top-substatus'>#</a></h2>")
$bySub = $failed | Group-Object SubStatusHex | Sort-Object Count -Descending | Select-Object -First 10
[void]$sb.AppendLine("<table><thead><tr><th>SubStatus</th><th>Count</th><th>Decoded Reason</th></tr></thead><tbody>")
foreach ($g in $bySub) {
$reason = if ($Script:SubStatusMap.ContainsKey($g.Name)) { $Script:SubStatusMap[$g.Name] } else { 'Unknown' }
[void]$sb.AppendLine("<tr><td class='mono'>$(ConvertTo-HtmlEncoded $g.Name)</td><td>$($g.Count)</td><td>$(ConvertTo-HtmlEncoded $reason)</td></tr>")
}
[void]$sb.AppendLine("</tbody></table>")

[void]$sb.AppendLine("<h2 class='section-anchor' id='top-sources'>Top Source Workstations <a class='anchor-link' href='#top-sources'>#</a></h2>")
$bySrc = $failed | Where-Object { $_.SourceIP -and $_.SourceIP -ne '-' } | Group-Object SourceIP | Sort-Object Count -Descending | Select-Object -First 10
if ($bySrc.Count -eq 0) {
[void]$sb.AppendLine("<div class='empty'>No source IPs recorded in failed events.</div>")
} else {
[void]$sb.AppendLine("<table><thead><tr><th>Source IP</th><th>Failure Count</th></tr></thead><tbody>")
foreach ($g in $bySrc) {
[void]$sb.AppendLine("<tr><td class='mono'>$(ConvertTo-HtmlEncoded $g.Name)</td><td>$($g.Count)</td></tr>")
}
[void]$sb.AppendLine("</tbody></table>")
}
}
}

[void]$sb.AppendLine("</div>") # end summary tab

# === TAB: HOST POSTURE ===
[void]$sb.AppendLine("<div class='tab-content' id='content-posture'>")
[void]$sb.AppendLine("<p class='meta'>Host configuration snapshot at run time. Independent of any time window. Most sections work without admin.</p>")

# Hotfixes
[void]$sb.AppendLine("<h2 class='section-anchor' id='hotfixes'>Hotfixes (last 180 days) <a class='anchor-link' href='#hotfixes'>#</a></h2>")
if ($Posture.Hotfixes.Count -eq 0) {
[void]$sb.AppendLine("<div class='empty'>No hotfixes within the last 180 days or Get-HotFix returned nothing.</div>")
}
else {
$flaggedCount = ($Posture.Hotfixes | Where-Object { $_.IsOfInterest }).Count
if ($flaggedCount -gt 0) {
[void]$sb.AppendLine("<div class='banner warn'>$flaggedCount hotfix(es) of interest installed. These are KBs relevant to RDP/NLA/auth investigations. See the KB-of-interest column.</div>")
}
[void]$sb.AppendLine("<table><thead><tr><th>HotFix ID</th><th>Description</th><th>Installed On</th><th>Of Interest</th></tr></thead><tbody>")
foreach ($h in $Posture.Hotfixes) {
$rowStyle = if ($h.IsOfInterest) { "style='background:rgba(255,167,38,0.08)'" } else { '' }
$flag = if ($h.IsOfInterest) { "<strong style='color:var(--warn)'>Yes</strong>" } else { 'No' }
[void]$sb.AppendLine("<tr $rowStyle><td class='mono'>$(ConvertTo-HtmlEncoded $h.HotFixID)</td><td>$(ConvertTo-HtmlEncoded $h.Description)</td><td class='mono'>$($h.InstalledOn.ToString('yyyy-MM-dd'))</td><td>$flag</td></tr>")
}
[void]$sb.AppendLine("</tbody></table>")
}

# RDP listener
[void]$sb.AppendLine("<h2 class='section-anchor' id='rdp-listener'>RDP Listener Configuration <a class='anchor-link' href='#rdp-listener'>#</a></h2>")
if ($null -eq $Posture.RdpListener) {
[void]$sb.AppendLine("<div class='empty'>Could not read RDP listener configuration (Win32_TSGeneralSetting). The host may not have RDP configured.</div>")
}
else {
$l = $Posture.RdpListener
[void]$sb.AppendLine("<div class='env-grid'>")
[void]$sb.AppendLine("<div class='k'>Security Layer</div><div class='v'>$($l.SecurityLayer) ($($l.SecurityLayerName))</div>")
[void]$sb.AppendLine("<div class='k'>NLA Required</div><div class='v'>$(if ($l.UserAuthenticationRequired) { 'Yes (enforced)' } else { '<span style=color:var(--warn)>No (NLA disabled)</span>' })</div>")
[void]$sb.AppendLine("<div class='k'>Min Encryption</div><div class='v'>$($l.MinEncryptionLevel) ($($l.EncryptionLevelName))</div>")
[void]$sb.AppendLine("<div class='k'>Cert Thumbprint</div><div class='v'>$(if ($l.SSLCertificateSHA1Hash) { $l.SSLCertificateSHA1Hash } else { '(none bound)' })</div>")
[void]$sb.AppendLine("<div class='k'>WinStation Enabled</div><div class='v'>$($l.fEnableWinStation)</div>")
[void]$sb.AppendLine("</div>")
}

# NLA/CredSSP policy
[void]$sb.AppendLine("<h2 class='section-anchor' id='nla-policy'>NLA / CredSSP / Credential Guard Policy <a class='anchor-link' href='#nla-policy'>#</a></h2>")
[void]$sb.AppendLine("<p class='meta'>Registry values from RDP, LSA, CredSSP, and CredentialsDelegation keys. '(not set)' means the OS default applies.</p>")
[void]$sb.AppendLine("<table><thead><tr><th>Registry Path</th><th>Value Name</th><th>Current Value</th></tr></thead><tbody>")
foreach ($key in $Posture.NlaPolicy.Keys | Sort-Object) {
$parts = $key -split '\'
$valueName = $parts[-1]
$path = ($parts[0..($parts.Length - 2)]) -join '\'
$val = $Posture.NlaPolicy[$key]
$valDisplay = if ($null -eq $val) { '(null)' } elseif ($val -is [string]) { $val } else { "$val" }
[void]$sb.AppendLine("<tr><td class='mono' style='font-size:11px'>$(ConvertTo-HtmlEncoded $path)</td><td class='mono'>$(ConvertTo-HtmlEncoded $valueName)</td><td class='mono'>$(ConvertTo-HtmlEncoded $valDisplay)</td></tr>")
}
[void]$sb.AppendLine("</tbody></table>")

# RDS Services
[void]$sb.AppendLine("<h2 class='section-anchor' id='rds-services'>RDS-Related Services <a class='anchor-link' href='#rds-services'>#</a></h2>")
if ($Posture.RdsServices.Count -eq 0) {
[void]$sb.AppendLine("<div class='empty'>No RDS-related services found.</div>")
}
else {
[void]$sb.AppendLine("<table><thead><tr><th>Service</th><th>Display Name</th><th>Status</th><th>Start Type</th></tr></thead><tbody>")
foreach ($s in $Posture.RdsServices) {
$statCls = switch ($s.Status) { 'Running' { 'tier-info' } 'Stopped' { 'tier-error' } default { 'tier-warn' } }
[void]$sb.AppendLine("<tr><td class='mono'>$(ConvertTo-HtmlEncoded $s.Name)</td><td>$(ConvertTo-HtmlEncoded $s.DisplayName)</td><td class='$statCls'>$(ConvertTo-HtmlEncoded $s.Status)</td><td>$(ConvertTo-HtmlEncoded $s.StartType)</td></tr>")
}
[void]$sb.AppendLine("</tbody></table>")
}

# Active sessions
[void]$sb.AppendLine("<h2 class='section-anchor' id='active-sessions'>Active Sessions <a class='anchor-link' href='#active-sessions'>#</a></h2>")
if ($Posture.ActiveSessions.Count -eq 0) {
[void]$sb.AppendLine("<div class='empty'>No active sessions (or qwinsta returned nothing parseable).</div>")
}
else {
[void]$sb.AppendLine("<table><thead><tr><th>Session Name</th><th>User</th><th>ID</th><th>State</th></tr></thead><tbody>")
foreach ($s in $Posture.ActiveSessions) {
[void]$sb.AppendLine("<tr><td class='mono'>$(ConvertTo-HtmlEncoded $s.SessionName)</td><td>$(ConvertTo-HtmlEncoded $s.UserName)</td><td class='mono'>$($s.Id)</td><td>$(ConvertTo-HtmlEncoded $s.State)</td></tr>")
}
[void]$sb.AppendLine("</tbody></table>")
}

# Network/system snapshot
[void]$sb.AppendLine("<h2 class='section-anchor' id='network-resources'>Network and Resource Snapshot <a class='anchor-link' href='#network-resources'>#</a></h2>")
[void]$sb.AppendLine("<div class='env-grid'>")
$port3389 = if ($Posture.Port3389State.Count -gt 0) { ($Posture.Port3389State.GetEnumerator() | ForEach-Object { "$($_.Name)=$($_.Value)" }) -join ', ' } else { 'No active TCP/3389 connections' }
[void]$sb.AppendLine("<div class='k'>TCP 3389 State</div><div class='v'>$(ConvertTo-HtmlEncoded $port3389)</div>")
[void]$sb.AppendLine("<div class='k'>Dynamic Port Range</div><div class='v'>$(ConvertTo-HtmlEncoded $Posture.DynamicPortRange)</div>")
[void]$sb.AppendLine("<div class='k'>Time Sync</div><div class='v'>$(ConvertTo-HtmlEncoded $Posture.TimeSync)</div>")
[void]$sb.AppendLine("<div class='k'>Memory Available</div><div class='v'>$(ConvertTo-HtmlEncoded $Posture.MemoryAvailable)</div>")
if ($Posture.LsassStress) {
$lsass = "$($Posture.LsassStress.CPUSeconds)s CPU, $($Posture.LsassStress.Handles) handles, $($Posture.LsassStress.WorkingSetMB) MB working set"
[void]$sb.AppendLine("<div class='k'>LSASS</div><div class='v'>$(ConvertTo-HtmlEncoded $lsass)</div>")
}
[void]$sb.AppendLine("</div>")

[void]$sb.AppendLine("</div>") # end posture tab

# === TAB: AUTHENTICATION ===
[void]$sb.AppendLine("<div class='tab-content' id='content-auth'>")
if (-not $Inventory.Elevated) {
[void]$sb.AppendLine("<div class='banner warn'>Security log access requires administrator privileges. Re-run elevated for this tab to populate.</div>")
}
elseif ($SecurityEvents.Count -eq 0) {
[void]$sb.AppendLine("<div class='empty'>No authentication events in the window.</div>")
}
else {
[void]$sb.AppendLine("<p class='meta'>Failed logons (4625), successful network/RDP logons (4624), NTLM credential validation (4776), and Kerberos pre-auth failures (4771).</p>")
[void]$sb.AppendLine("<table><thead><tr><th>Time (UTC)</th><th>Event</th><th>User</th><th>Logon Type</th><th>SubStatus</th><th>Source</th></tr></thead><tbody>")
foreach ($e in ($SecurityEvents | Sort-Object Timestamp)) {
$cls = if ($e.EventId -eq 4625 -or $e.EventId -eq 4771) { 'tier-error' } elseif ($e.EventId -eq 4776) { 'tier-warn' } else { 'tier-info' }
$subDisplay = if ($e.SubStatusHex) { "$($e.SubStatusHex) - $(ConvertTo-HtmlEncoded $e.SubStatusReason)" } else { '' }
$srcDisplay = if ($e.SourceIP -and $e.SourceIP -ne '-') { $e.SourceIP } else { $e.Workstation }
[void]$sb.AppendLine("<tr><td class='mono'>$($e.Timestamp.ToString('HH:mm:ss'))</td><td class='$cls'>$($e.EventId) $(ConvertTo-HtmlEncoded $e.Description)</td><td>$(ConvertTo-HtmlEncoded $e.User)</td><td>$(ConvertTo-HtmlEncoded $e.LogonTypeStr)</td><td class='mono' style='font-size:11px'>$subDisplay</td><td class='mono'>$(ConvertTo-HtmlEncoded $srcDisplay)</td></tr>")
}
[void]$sb.AppendLine("</tbody></table>")
}
[void]$sb.AppendLine("</div>")

# === TAB: SESSIONS ===
[void]$sb.AppendLine("<div class='tab-content' id='content-sessions'>")
if ($SessionEvents.Count -eq 0) {
[void]$sb.AppendLine("<div class='empty'>No session events in the window.</div>")
}
else {
[void]$sb.AppendLine("<p class='meta'>TerminalServices LocalSessionManager and RemoteConnectionManager events. Tracks RDP session lifecycle: logon, disconnect, reconnect, logoff.</p>")
[void]$sb.AppendLine("<table><thead><tr><th>Time (UTC)</th><th>Source</th><th>Event ID</th><th>Description</th><th>Detail</th></tr></thead><tbody>")
foreach ($e in ($SessionEvents | Sort-Object Timestamp)) {
[void]$sb.AppendLine("<tr><td class='mono'>$($e.Timestamp.ToString('HH:mm:ss'))</td><td>$(ConvertTo-HtmlEncoded $e.Source)</td><td class='mono'>$($e.EventId)</td><td>$(ConvertTo-HtmlEncoded $e.Description)</td><td class='mono' style='font-size:11px'>$(ConvertTo-HtmlEncoded $e.Message)</td></tr>")
}
[void]$sb.AppendLine("</tbody></table>")
}
[void]$sb.AppendLine("</div>")

# === TAB: TLS / NLA / SYSTEM ===
[void]$sb.AppendLine("<div class='tab-content' id='content-tls'>")
if ($TlsEvents.Count -eq 0) {
[void]$sb.AppendLine("<div class='empty'>No TLS, NLA, or relevant system events in the window.</div>")
}
else {
[void]$sb.AppendLine("<p class='meta'>RdpCoreTS handshake events, Schannel TLS messages, LsaSrv credential validation, and TermDD RDP driver events.</p>")
[void]$sb.AppendLine("<table><thead><tr><th>Time (UTC)</th><th>Source</th><th>Event ID</th><th>Description</th><th>Detail</th></tr></thead><tbody>")
foreach ($e in ($TlsEvents | Sort-Object Timestamp)) {
$cls = if ($e.EventId -eq 226) { 'tier-error' } else { 'tier-info' }
[void]$sb.AppendLine("<tr><td class='mono'>$($e.Timestamp.ToString('HH:mm:ss'))</td><td>$(ConvertTo-HtmlEncoded $e.Source)</td><td class='mono $cls'>$($e.EventId)</td><td>$(ConvertTo-HtmlEncoded $e.Description)</td><td class='mono' style='font-size:11px'>$(ConvertTo-HtmlEncoded $e.Message)</td></tr>")
}
[void]$sb.AppendLine("</tbody></table>")
}
[void]$sb.AppendLine("</div>")

# footer
[void]$sb.AppendLine("<div class='footer'>Generated by Test-TruGridHostEventCheck.ps1 v$($Script:ScriptVersion). Read-only event-log queries. No configuration was changed. Source: Windows Security, TerminalServices-LSM, TerminalServices-RCM, RdpCoreTS, System (Schannel/LsaSrv/TermDD).</div>")

# JS
[void]$sb.AppendLine(@'
<script>
function showTab(name) {
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.getElementById('content-' + name).classList.add('active');
document.getElementById('btn-' + name).classList.add('active');
}
function copyLogLines(elemId, btn) {
const el = document.getElementById(elemId);
if (!el) return;
const text = el.innerText;
const done = () => {
const orig = btn.textContent;
btn.textContent = 'Copied';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = orig; btn.classList.remove('copied'); }, 1500);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done).catch(() => fallbackCopy(text, done));
} else { fallbackCopy(text, done); }
}
function fallbackCopy(text, cb) {
const ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); cb(); } catch (e) {}
document.body.removeChild(ta);
}
</script>
'@)
[void]$sb.AppendLine("</body></html>")

Set-Content -Path $OutputPath -Value $sb.ToString() -Encoding UTF8
}

# --- main ---

Write-Host "TruGrid Host Event Check v$($Script:ScriptVersion)" -ForegroundColor Cyan
Write-Host ("=" * 60) -ForegroundColor DarkGray

$hoursBackProvided = $PSBoundParameters.ContainsKey('HoursBack')

if (-not $hoursBackProvided) {
Write-Host "`nTime window options:" -ForegroundColor Cyan
Write-Host " [1] Last 1 hour" -ForegroundColor Gray
Write-Host " [2] Last 6 hours" -ForegroundColor Gray
Write-Host " [3] Last 24 hours" -ForegroundColor Gray
Write-Host " [4] Last 48 hours" -ForegroundColor Gray
Write-Host " [5] Last 7 days" -ForegroundColor Gray
Write-Host " [6] Custom" -ForegroundColor Gray
Write-Host ""
$choice = Read-Host "Select time window [1-6, default: 3]"
switch ($choice.Trim()) {
'1' { $HoursBack = 1 }
'2' { $HoursBack = 6 }
'3' { $HoursBack = 24 }
'4' { $HoursBack = 48 }
'5' { $HoursBack = 168 }
'6' {
$c = Read-Host "Enter hours"
if ($c -match '^\d+$' -and [int]$c -gt 0) { $HoursBack = [int]$c } else { $HoursBack = 24 }
}
default { $HoursBack = 24 }
}
Write-Host "`nTime window: last $HoursBack hour(s)." -ForegroundColor Green
}

$elevated = Test-IsElevated
if (-not $elevated) {
Write-Host "`nWARNING: Not running elevated. The Security log (4625/4624/4776/4771) requires admin privileges and will be skipped." -ForegroundColor Yellow
Write-Host " All other event sources are still accessible." -ForegroundColor Yellow
}

Write-Host "`n[1/6] Gathering host inventory..." -ForegroundColor Yellow
$inventory = Get-HostInventory
Write-Host " Host: $($inventory.Hostname) | OS: $($inventory.OS) | RDS: $($inventory.IsRdsHost) | Elevated: $($inventory.Elevated)" -ForegroundColor Gray

Write-Host "`n[2/6] Gathering host posture (hotfixes, RDP config, services, policy)..." -ForegroundColor Yellow
$posture = Get-HostPosture
Write-Host " Hotfixes: $($posture.Hotfixes.Count) (last 180 days) | Services: $($posture.RdsServices.Count) | Sessions: $($posture.ActiveSessions.Count)" -ForegroundColor Gray

$since = (Get-Date).AddHours(-$HoursBack)

Write-Host "`n[3/6] Reading Security log (4625/4624/4776/4771)..." -ForegroundColor Yellow
$secEvents = Get-SecurityEvents -Since $since
Write-Host " Collected $($secEvents.Count) authentication events." -ForegroundColor Gray

Write-Host "`n[4/6] Reading TerminalServices logs (LSM, RCM)..." -ForegroundColor Yellow
$sessionEvents = Get-SessionEvents -Since $since
Write-Host " Collected $($sessionEvents.Count) session events." -ForegroundColor Gray

Write-Host "`n[5/6] Reading TLS/NLA logs (RdpCoreTS, Schannel, LsaSrv, TermDD)..." -ForegroundColor Yellow
$tlsEvents = Get-TlsAndNlaEvents -Since $since
Write-Host " Collected $($tlsEvents.Count) TLS/system events." -ForegroundColor Gray

Write-Host "`n[6/6] Running pattern analysis..." -ForegroundColor Yellow
$categories = Find-AuthPatterns -SecurityEvents $secEvents -TlsEvents $tlsEvents -Posture $posture
Write-Host " Detected $($categories.Count) pattern(s)." -ForegroundColor Gray

if (-not (Test-Path $Script:OutputDir)) {
New-Item -Path $Script:OutputDir -ItemType Directory -Force | Out-Null
}
$stamp = (Get-Date).ToString('yyyyMMdd_HHmmss')
$outPath = Join-Path $Script:OutputDir "TruGridHostEventCheck_$($env:COMPUTERNAME)_$stamp.html"

Write-Host "`nWriting report..." -ForegroundColor Yellow
Write-HtmlReport -Inventory $inventory -Posture $posture -SecurityEvents $secEvents -SessionEvents $sessionEvents -TlsEvents $tlsEvents -Categories $categories -HoursBack $HoursBack -OutputPath $outPath

Write-Host " HTML report: $outPath" -ForegroundColor Green
Write-Host "`nDone." -ForegroundColor Cyan


Cross Reference Logs


Cross-Reference Logs Loads selected TruGrid Sentry, Secure Connect, and Windows Connector logs across a chosen time window, then correlates events to surface session pairings, failure cascades, reconnect storms, and known issue signatures across components. Output is an HTML report with three tabs (Cross-Reference, Host Errors, Client Errors) that highlights auto-categorized patterns such as authentication failures, client internet outages, async disposal races, and network instability.


 # Cross-Reference TruGrid Logs. Takes Sentry, Secure Connect, and/or Connector
# logs within a time window, parses each format, normalizes events to a common
# structure, runs correlation passes, and produces an HTML report.
#
# Inputs: -LogFiles "path1,path2,..." -HoursBack <int>
# When run without parameters, auto-discovers logs from known native locations
# and from %USERPROFILE%\Desktop\TruGrid Reports\imported-logs\, prompts for
# selection and time window.
#
# PS 5.1+. No modules. Edit constants below to tweak.

[CmdletBinding()]
param(
[string[]]$LogFiles,
[int]$HoursBack = 6
)

# --- config ---

$Script:OutputDir = Join-Path ([Environment]::GetFolderPath('Desktop')) 'TruGrid Reports\reports'
$Script:ImportDir = Join-Path ([Environment]::GetFolderPath('Desktop')) 'TruGrid Reports\imported-logs'
$Script:ScriptVersion = '1.0.0'

# native log locations
$Script:NativeSentryLog = 'C:\Program Files\TruGrid\Sentry\Agent.log'
$Script:NativeSCLog = 'C:\Program Files\TruGrid\Secure Connect\Secure Connect.log'
$Script:NativeConnectorDir = Join-Path $env:APPDATA 'TruGrid Connector'

# TruGrid logo SVG (inline so the report works when the script is run from
# %TEMP% by the launcher, without needing asset files on disk)
$Script:TruGridLogoSvg = @'
<svg style="width:140px;height:auto;" viewBox="0 0 979 293" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M538.332 110.814C586.777 110.814 624.152 134.713 631.715 169.262L605.376 171.382C596.936 150.34 568.364 133.354 538.552 133.354C500.519 133.354 462.253 159.045 462.253 201.994C462.253 245.116 500.3 270.46 539.867 270.46C575.817 270.46 609.106 246.247 609.873 217.673H534.512V195.593H633.25V215.767C633.25 260.534 594.34 293 539.867 293C479.037 293 437.825 258.104 437.825 201.994C437.825 145.364 480.242 110.814 538.332 110.814ZM979 222.415C979 222.466 979.001 222.516 979.001 222.567C979.001 222.618 979 222.668 979 222.719V291.122H952.693V276.9C940.185 286.957 924.146 293 906.656 293C866.701 293 834.312 261.466 834.312 222.567C834.312 183.668 866.702 152.135 906.656 152.135C924.146 152.135 940.185 158.177 952.693 168.234V110.814H979V222.415ZM161.601 137.109H93.0146V291.122H68.5859V137.109H0V112.693H161.601V137.109ZM253.683 180.307C253.515 180.313 210.676 181.787 199.182 217.472V291.122H172.875V161.526H199.182V170.495C205.118 166.285 221.148 157.769 253.683 157.769V180.307ZM308.169 246.05C309.737 254.503 318.319 270.836 340.112 268.583C365.438 265.964 378.586 260.099 383.332 238.169V161.525H409.64V291.122H383.332V281.224C369.615 287.529 357.22 291.122 341.052 291.122C287.494 291.122 282.351 256.451 281.896 247.923H281.861V246.976C281.848 246.377 281.86 246.047 281.86 246.045H281.861V161.525H308.169V246.05ZM749.76 180.307C749.76 180.307 706.775 181.717 695.259 217.472V291.122H668.952V161.526H695.259V170.495C701.196 166.285 717.225 157.769 749.76 157.769V180.307ZM806.125 291.122H779.817V155.891H806.125V291.122ZM908.535 174.673C882.072 174.673 860.619 195.695 860.619 221.628C860.619 247.56 882.072 268.583 908.535 268.583C928.389 268.583 945.423 256.75 952.693 239.888V203.366C945.423 186.505 928.389 174.673 908.535 174.673Z" fill="#4169B8"/>
<path d="M792.972 105.18C802.311 105.18 809.883 112.748 809.883 122.083C809.883 131.419 802.312 138.987 792.972 138.987C783.632 138.987 776.06 131.419 776.06 122.083C776.06 112.747 783.632 105.18 792.972 105.18ZM759.147 127.718H668.952V118.327H759.147V127.718ZM916.99 127.718H826.795V118.327H916.99V127.718ZM798.608 88.2754H789.213V0H798.608V88.2754Z" fill="#B8860B"/>
</svg>
'@

# correlation thresholds
$Script:CascadeWindowSeconds = 5 # errors in different components within this window are a cascade
$Script:ReconnectStormCount = 3 # this many reconnects in
$Script:ReconnectStormSeconds = 60 # this many seconds is a storm
$Script:SessionPairWindowSeconds = 30 # max gap to pair connect/disconnect

# event type taxonomy
$Script:EventTypes = @{
'RelayConnectAttempt' = 'Relay connect attempt'
'RelayConnectSuccess' = 'Relay connect success'
'RelayDisconnect' = 'Relay disconnect'
'TunnelCreate' = 'Tunnel created'
'TunnelTerminate' = 'Tunnel terminated'
'SessionEstablish' = 'Session established'
'SessionEnd' = 'Session ended'
'KnownErrorSocket' = 'Known error: SocketException burst'
'KnownErrorObjDisposed' = 'Known error: ObjectDisposed race'
'KnownErrorInternet' = 'Known error: Internet unavailable'
'KnownErrorAuth' = 'Known error: Authentication failure'
'ServiceStateChange' = 'Service state change'
'IncomingCommand' = 'Incoming command'
'Reconnect' = 'Reconnect attempt'
'OtherWarn' = 'Other WARN'
'OtherError' = 'Other ERROR'
}

# --- parsers ---

function ConvertTo-DateTime {
# Parse the various timestamp formats TruGrid uses across components.
# Handles "2026-05-13 16:51:48.7338UTC" (4-digit fraction) and
# "2026-05-13 16:51:48.733 +02:00" (offset form). Fractional precision
# is normalized to 3 digits since ParseExact requires exact format match.
param([string]$TimestampStr)

if ([string]::IsNullOrWhiteSpace($TimestampStr)) { return $null }

# UTC suffix form
if ($TimestampStr -match '^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\.(\d+)UTC$') {
$datePart = $matches[1]
$fracPart = if ($matches[2].Length -ge 3) { $matches[2].Substring(0, 3) } else { $matches[2].PadRight(3, '0') }
try {
$dt = [DateTime]::ParseExact("$datePart.$fracPart", 'yyyy-MM-dd HH:mm:ss.fff', $null, [System.Globalization.DateTimeStyles]::AssumeUniversal)
return $dt.ToUniversalTime()
} catch { return $null }
}
# Offset form
if ($TimestampStr -match '^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\.(\d+) ([+-]\d{2}:\d{2})$') {
$datePart = $matches[1]
$fracPart = if ($matches[2].Length -ge 3) { $matches[2].Substring(0, 3) } else { $matches[2].PadRight(3, '0') }
$offset = $matches[3]
try {
$dt = [DateTimeOffset]::Parse("$datePart.$fracPart$offset")
return $dt.UtcDateTime
} catch { return $null }
}
# No fractional seconds variant
if ($TimestampStr -match '^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})UTC$') {
try {
$dt = [DateTime]::ParseExact($matches[1], 'yyyy-MM-dd HH:mm:ss', $null, [System.Globalization.DateTimeStyles]::AssumeUniversal)
return $dt.ToUniversalTime()
} catch { return $null }
}
# Fallback: generic parse
try {
return [DateTime]::Parse($TimestampStr, [System.Globalization.CultureInfo]::InvariantCulture).ToUniversalTime()
} catch {
return $null
}
}

function Parse-SentryOrConnectorLine {
# Common format: <ts>|<level>|<source>|<message>
# Sentry Agent.log and Connector win_headless_logs both use this shape.
param(
[string]$Line,
[string]$Component # 'Sentry' or 'Connector'
)

if ($Line -notmatch '^(\S+ \S+)\|(\w+)\|([^|]+)\|(.+)$') { return $null }
$ts = ConvertTo-DateTime $matches[1]
if (-not $ts) { return $null }

$level = $matches[2]
$source = $matches[3]
$msg = $matches[4]

$event = [pscustomobject]@{
Timestamp = $ts
Component = $Component
Level = $level
Source = $source
Message = $msg
EventType = $null
TunnelId = $null
RelayIp = $null
UserId = $null
TargetMachine = $null
RawLine = $Line
}

# classify
if ($msg -match 'Attempting to connect to TruGrid Relay via TCP:(\d{1,3}(?:\.\d{1,3}){3}):443') {
$event.EventType = 'RelayConnectAttempt'
$event.RelayIp = $matches[1]
}
elseif ($msg -match 'Successfully connected to TruGrid Relay via TCP\.') {
$event.EventType = 'RelayConnectSuccess'
}
elseif ($msg -match 'Connection has been established to the destination machine\s+(\S+):3389') {
$event.EventType = 'SessionEstablish'
$event.TargetMachine = $matches[1]
}
elseif ($msg -match 'Forward termination|Stream was already disposed') {
$event.EventType = 'RelayDisconnect'
}
elseif ($msg -match 'Attempting to create a new RDP tunnel for user:"([^"]+)"') {
$event.EventType = 'TunnelCreate'
$event.UserId = $matches[1]
}
elseif ($msg -match 'TCP forwarding has started|UDP forwarding') {
# informational, no separate type
$event.EventType = $null
}
elseif ($msg -match 'System\.Net\.Sockets\.SocketException') {
$event.EventType = 'KnownErrorSocket'
}
elseif ($msg -match 'ObjectDisposedException') {
$event.EventType = 'KnownErrorObjDisposed'
}
elseif ($msg -match 'Internet is not available\. Waiting for network') {
$event.EventType = 'KnownErrorInternet'
}
elseif ($msg -match 'authentication failed|access denied|invalid credentials|0xcea20002') {
$event.EventType = 'KnownErrorAuth'
}
elseif ($msg -match 'service session changed to (\w+)') {
$event.EventType = 'ServiceStateChange'
}
elseif ($msg -match 'Incoming command:\s+([0-9a-f-]+)') {
$event.EventType = 'IncomingCommand'
}
elseif ($msg -match 'Need to reconnect') {
$event.EventType = 'Reconnect'
}
elseif ($level -eq 'ERROR') {
$event.EventType = 'OtherError'
}
elseif ($level -eq 'WARN') {
$event.EventType = 'OtherWarn'
}

# extract tunnel GUID if present anywhere in message
if ($msg -match 'Tunnel:"?([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"?') {
$event.TunnelId = $matches[1]
}
elseif ($msg -match '(?:Connection Id|connectionId):\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})') {
$event.TunnelId = $matches[1]
}

return $event
}

function Parse-SecureConnectLine {
# SC format: <ts>|<level>|<message> (no source field)
param([string]$Line)

if ($Line -notmatch '^(\S+ \S+)\|(\w+)\|(.+)$') { return $null }
$ts = ConvertTo-DateTime $matches[1]
if (-not $ts) { return $null }

$level = $matches[2]
$msg = $matches[3]

$event = [pscustomobject]@{
Timestamp = $ts
Component = 'SecureConnect'
Level = $level
Source = ''
Message = $msg
EventType = $null
TunnelId = $null
RelayIp = $null
UserId = $null
TargetMachine = $null
RawLine = $Line
}

if ($msg -match 'TruGrid Relay: Connecting to Instance:?\s+(\d{1,3}(?:\.\d{1,3}){3}):443') {
$event.EventType = 'RelayConnectAttempt'
$event.RelayIp = $matches[1]
}
elseif ($msg -match 'TruGrid Relay: Connection to Instance\s+(\d{1,3}(?:\.\d{1,3}){3}):443 established') {
$event.EventType = 'RelayConnectSuccess'
$event.RelayIp = $matches[1]
}
elseif ($msg -match 'TruGrid Relay: Connection to target machine established') {
$event.EventType = 'SessionEstablish'
}
elseif ($msg -match 'service session changed to (\w+)') {
$event.EventType = 'ServiceStateChange'
}
elseif ($msg -match 'Request to establish RDP Session.*Connection Id:\s*([0-9a-f-]+)') {
$event.EventType = 'IncomingCommand'
}
elseif ($msg -match 'Incoming command:\s*EstablishRdpConnection') {
$event.EventType = 'IncomingCommand'
}
elseif ($level -eq 'ERROR') {
$event.EventType = 'OtherError'
}
elseif ($level -eq 'WARN') {
$event.EventType = 'OtherWarn'
}

# extract Connection Id as a tunnel-equivalent identifier
if ($msg -match '\(([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\)') {
$event.TunnelId = $matches[1]
}
elseif ($msg -match 'Connection Id:\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})') {
$event.TunnelId = $matches[1]
}

return $event
}

function Get-LogFormat {
# Detect log format. Filename-based first (reliable), content sniffing as
# fallback for files that have been renamed (common with imported logs
# from other machines that get prefixed with customer names, etc.)
param([string]$Path)

$name = (Split-Path $Path -Leaf).ToLower()

# filename-based detection
if ($name -eq 'agent.log' -or $name -like '*agent*.log') { return 'Sentry' }
if ($name -eq 'secure connect.log' -or $name -like '*secure*connect*.log') { return 'SecureConnect' }
if ($name -like '*win_headless*' -or $name -like '*mac_headless*' -or $name -like '*mac_native*') { return 'Connector' }

# content sniffing fallback. Scan further into the file since Connector logs
# have a multi-line system info header before actual entries.
try {
$line = Get-Content -Path $Path -TotalCount 500 -ErrorAction Stop |
Where-Object { $_ -match '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}' } |
Select-Object -First 1
if (-not $line) { return 'Unknown' }

# SC format: <ts>|<level>|<message> (3 pipe-separated fields)
# Sentry/Connector format: <ts>|<level>|<source>|<message> (4+ fields)
$pipeCount = ($line -split '\|').Count
if ($pipeCount -eq 3) { return 'SecureConnect' }
if ($pipeCount -ge 4) {
if ($line -match '\|TruGrid_Sentry_') { return 'Sentry' }
return 'Connector'
}
} catch { }
return 'Unknown'
}

function Parse-LogFile {
param(
[string]$Path,
[datetime]$WindowStartUtc
)

if (-not (Test-Path $Path)) {
Write-Host " Skipping: $Path (not found)" -ForegroundColor DarkYellow
return @()
}

$format = Get-LogFormat $Path
if ($format -eq 'Unknown') {
Write-Host " Skipping: $Path (unknown format)" -ForegroundColor DarkYellow
return @()
}

Write-Host " Reading $($format): $(Split-Path $Path -Leaf)" -ForegroundColor Gray

$events = [System.Collections.Generic.List[object]]::new()
try {
# Open with FileShare.ReadWrite so we can read files held open by the
# TruGrid Connector or other processes (default StreamReader uses
# FileShare.Read which fails on a log being actively written to).
$stream = [System.IO.FileStream]::new($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
$reader = [System.IO.StreamReader]::new($stream)
try {
while ($null -ne ($line = $reader.ReadLine())) {
$event = switch ($format) {
'Sentry' { Parse-SentryOrConnectorLine -Line $line -Component 'Sentry' }
'Connector' { Parse-SentryOrConnectorLine -Line $line -Component 'Connector' }
'SecureConnect' { Parse-SecureConnectLine -Line $line }
}
if ($event -and $event.Timestamp -ge $WindowStartUtc) {
[void]$events.Add($event)
}
}
} finally {
$reader.Dispose()
$stream.Dispose()
}
} catch {
Write-Host " Error parsing: $($_.Exception.Message)" -ForegroundColor Red
}

Write-Host " Parsed $($events.Count) events within window" -ForegroundColor Gray
return $events.ToArray()
}

# --- correlation passes ---

function Find-SessionPairs {
# Pair RelayConnectAttempt/Success with subsequent RelayDisconnect on same TunnelId.
param([array]$Events)

$sessions = @()
$eventsByTunnel = $Events | Where-Object { $_.TunnelId } | Group-Object TunnelId

foreach ($group in $eventsByTunnel) {
$sorted = $group.Group | Sort-Object Timestamp
$start = $sorted | Where-Object { $_.EventType -in 'RelayConnectAttempt','SessionEstablish','TunnelCreate','IncomingCommand' } | Select-Object -First 1
$end = $sorted | Where-Object { $_.EventType -eq 'RelayDisconnect' } | Select-Object -Last 1
if ($start) {
$sessions += [pscustomobject]@{
TunnelId = $group.Name
StartTime = $start.Timestamp
EndTime = if ($end) { $end.Timestamp } else { $null }
DurationSec = if ($end) { [int]($end.Timestamp - $start.Timestamp).TotalSeconds } else { $null }
StartComponent = $start.Component
EndComponent = if ($end) { $end.Component } else { $null }
UserId = $start.UserId
RelayIp = ($sorted | Where-Object { $_.RelayIp } | Select-Object -First 1).RelayIp
TargetMachine = ($sorted | Where-Object { $_.TargetMachine } | Select-Object -First 1).TargetMachine
EventCount = $sorted.Count
}
}
}
return ,@($sessions | Sort-Object StartTime)
}

function Find-FailureCascades {
# Errors in different components within $CascadeWindowSeconds of each other.
param([array]$Events)

$errors = $Events | Where-Object { $_.Level -in 'ERROR','WARN' -or $_.EventType -like 'KnownError*' } | Sort-Object Timestamp
$cascades = @()
$i = 0
while ($i -lt $errors.Count) {
$cluster = @($errors[$i])
$j = $i + 1
while ($j -lt $errors.Count -and ($errors[$j].Timestamp - $cluster[-1].Timestamp).TotalSeconds -le $Script:CascadeWindowSeconds) {
$cluster += $errors[$j]
$j++
}
$distinctComponents = ($cluster | Select-Object -ExpandProperty Component -Unique).Count
if ($distinctComponents -ge 2) {
$cascades += [pscustomobject]@{
StartTime = $cluster[0].Timestamp
EndTime = $cluster[-1].Timestamp
Components = ($cluster | Select-Object -ExpandProperty Component -Unique) -join ', '
EventCount = $cluster.Count
Sample = ($cluster | Select-Object -First 3 -ExpandProperty Message) -join ' | '
}
}
$i = $j
}
return ,$cascades
}

function Find-ReconnectStorms {
# >= $ReconnectStormCount Reconnect/KnownErrorInternet events within $ReconnectStormSeconds.
param([array]$Events)

$candidates = $Events | Where-Object { $_.EventType -in 'Reconnect','KnownErrorInternet','RelayConnectAttempt' } | Sort-Object Timestamp
$storms = @()
for ($i = 0; $i -lt $candidates.Count; $i++) {
$window = @($candidates[$i])
for ($j = $i + 1; $j -lt $candidates.Count; $j++) {
if (($candidates[$j].Timestamp - $candidates[$i].Timestamp).TotalSeconds -le $Script:ReconnectStormSeconds) {
$window += $candidates[$j]
} else { break }
}
if ($window.Count -ge $Script:ReconnectStormCount) {
$storms += [pscustomobject]@{
StartTime = $window[0].Timestamp
EndTime = $window[-1].Timestamp
Count = $window.Count
Component = ($window | Select-Object -ExpandProperty Component -Unique) -join ', '
}
$i = $i + $window.Count - 1
}
}
return ,$storms
}

function Get-KnownIssueSignatures {
param([array]$Events)

$signatures = @()
$socketBursts = $Events | Where-Object { $_.EventType -eq 'KnownErrorSocket' }
if ($socketBursts.Count -gt 0) {
$signatures += [pscustomobject]@{
Pattern = 'SocketException burst'
Count = $socketBursts.Count
FirstSeen = ($socketBursts | Sort-Object Timestamp | Select-Object -First 1).Timestamp
LastSeen = ($socketBursts | Sort-Object Timestamp | Select-Object -Last 1).Timestamp
Component = ($socketBursts | Select-Object -ExpandProperty Component -Unique) -join ', '
Note = 'Indicates relay tunnel disruption. Common during network instability or relay rotation.'
}
}
$objDisposed = $Events | Where-Object { $_.EventType -eq 'KnownErrorObjDisposed' }
if ($objDisposed.Count -gt 0) {
$signatures += [pscustomobject]@{
Pattern = 'ObjectDisposed race'
Count = $objDisposed.Count
FirstSeen = ($objDisposed | Sort-Object Timestamp | Select-Object -First 1).Timestamp
LastSeen = ($objDisposed | Sort-Object Timestamp | Select-Object -Last 1).Timestamp
Component = ($objDisposed | Select-Object -ExpandProperty Component -Unique) -join ', '
Note = 'Async disposal race condition in the relay tunnel client. Known issue, recovery usually automatic.'
}
}
$internet = $Events | Where-Object { $_.EventType -eq 'KnownErrorInternet' }
if ($internet.Count -gt 0) {
$signatures += [pscustomobject]@{
Pattern = 'Internet unavailable'
Count = $internet.Count
FirstSeen = ($internet | Sort-Object Timestamp | Select-Object -First 1).Timestamp
LastSeen = ($internet | Sort-Object Timestamp | Select-Object -Last 1).Timestamp
Component = ($internet | Select-Object -ExpandProperty Component -Unique) -join ', '
Note = 'Client lost internet connectivity. Investigate upstream LAN/ISP, not TruGrid.'
}
}
$auth = $Events | Where-Object { $_.EventType -eq 'KnownErrorAuth' }
if ($auth.Count -gt 0) {
$signatures += [pscustomobject]@{
Pattern = 'Authentication failure'
Count = $auth.Count
FirstSeen = ($auth | Sort-Object Timestamp | Select-Object -First 1).Timestamp
LastSeen = ($auth | Sort-Object Timestamp | Select-Object -Last 1).Timestamp
Component = ($auth | Select-Object -ExpandProperty Component -Unique) -join ', '
Note = 'Credential or NLA failure. Verify target machine credentials, Entra ID state, and PKU2U policy on hybrid-joined targets.'
}
}
return ,$signatures
}

function Find-AutoCategorizations {
# Deterministic pattern detectors for common combinations we know how to recognize.
# Each returns a category card with name, confidence, description, suggested action.
# Confidence is high/medium/info, affects the colored left border in the report.
param([array]$Events, [array]$Sessions, [array]$Storms, [array]$Signatures)

$categories = @()

# Pattern 1: Authentication failure
$authEvents = $Events | Where-Object { $_.EventType -eq 'KnownErrorAuth' }
if ($authEvents.Count -ge 2) {
$components = ($authEvents | Select-Object -ExpandProperty Component -Unique).Count
$firstAuth = ($authEvents | Sort-Object Timestamp | Select-Object -First 1).Timestamp
$lastAuth = ($authEvents | Sort-Object Timestamp | Select-Object -Last 1).Timestamp
$categories += [pscustomobject]@{
Name = 'Authentication Failure'
Confidence = if ($components -ge 2) { 'high' } else { 'medium' }
Description = "$($authEvents.Count) authentication-related error(s) detected across $components component(s). Likely credential issue, NLA/CredSSP rejection, or hybrid-join policy mismatch on the target machine."
Action = "Verify user credentials. Check NLA settings on the target. For hybrid-joined targets, review Entra ID conditional access and PKU2U policy."
EventCount = $authEvents.Count
TimeRange = "$($firstAuth.ToString('HH:mm:ss')) - $($lastAuth.ToString('HH:mm:ss'))"
SupportingEvents = @($authEvents | Sort-Object Timestamp)
}
}

# Pattern 2: Client lost internet
$internetEvents = $Events | Where-Object { $_.EventType -eq 'KnownErrorInternet' }
if ($internetEvents.Count -gt 0) {
$firstNet = ($internetEvents | Sort-Object Timestamp | Select-Object -First 1).Timestamp
$lastNet = ($internetEvents | Sort-Object Timestamp | Select-Object -Last 1).Timestamp
$categories += [pscustomobject]@{
Name = 'Client Internet Outage'
Confidence = 'high'
Description = "Client logged $($internetEvents.Count) 'Internet is not available' event(s). The Connector lost upstream connectivity. Broker side is not at fault."
Action = "Investigate the customer LAN, ISP, or WiFi connectivity. Not a TruGrid backend issue."
EventCount = $internetEvents.Count
TimeRange = "$($firstNet.ToString('HH:mm:ss')) - $($lastNet.ToString('HH:mm:ss'))"
SupportingEvents = @($internetEvents | Sort-Object Timestamp)
}
}

# Pattern 3: Async disposal race (ObjectDisposedException)
$disposedEvents = $Events | Where-Object { $_.EventType -eq 'KnownErrorObjDisposed' }
if ($disposedEvents.Count -gt 0) {
$firstDisp = ($disposedEvents | Sort-Object Timestamp | Select-Object -First 1).Timestamp
$lastDisp = ($disposedEvents | Sort-Object Timestamp | Select-Object -Last 1).Timestamp
$categories += [pscustomobject]@{
Name = 'Known Async Disposal Race'
Confidence = 'info'
Description = "$($disposedEvents.Count) ObjectDisposedException event(s) detected. This is a known async race condition in the relay tunnel client during reconnect."
Action = "Usually self-recovers without customer impact. If recurring frequently, capture full logs for product team review."
EventCount = $disposedEvents.Count
TimeRange = "$($firstDisp.ToString('HH:mm:ss')) - $($lastDisp.ToString('HH:mm:ss'))"
SupportingEvents = @($disposedEvents | Sort-Object Timestamp)
}
}

# Pattern 4: Network instability
$socketEvents = $Events | Where-Object { $_.EventType -eq 'KnownErrorSocket' }
if ($socketEvents.Count -ge 5 -or $Storms.Count -ge 2) {
$allEv = @($socketEvents) + ($Storms | ForEach-Object { [pscustomobject]@{ Timestamp = $_.StartTime } })
$first = ($allEv | Sort-Object Timestamp | Select-Object -First 1).Timestamp
$last = ($allEv | Sort-Object Timestamp | Select-Object -Last 1).Timestamp
$totalEvents = $socketEvents.Count + ($Storms | Measure-Object -Property Count -Sum).Sum
$categories += [pscustomobject]@{
Name = 'Network Instability'
Confidence = 'medium'
Description = "$($socketEvents.Count) SocketException event(s) and $($Storms.Count) reconnect storm(s) detected. Pattern suggests sustained network instability rather than a single clean failure."
Action = "Investigate the customer's network path. Run the Network Monitor diagnostic to identify the failing layer (ISP, LAN, or TruGrid path)."
EventCount = $totalEvents
TimeRange = "$($first.ToString('HH:mm:ss')) - $($last.ToString('HH:mm:ss'))"
SupportingEvents = @($socketEvents | Sort-Object Timestamp)
}
}

# Pattern 5: Sessions completed normally (clean run)
if ($Sessions.Count -gt 0 -and $authEvents.Count -eq 0 -and $internetEvents.Count -eq 0 -and $socketEvents.Count -lt 3) {
$completed = ($Sessions | Where-Object { $_.EndTime }).Count
$sortedSessions = $Sessions | Sort-Object StartTime
$first = $sortedSessions[0].StartTime
$last = $sortedSessions[-1].StartTime
$sessionEvents = $Events | Where-Object { $_.EventType -in 'SessionEstablish','TunnelCreate','RelayConnectSuccess' } | Sort-Object Timestamp
$categories += [pscustomobject]@{
Name = 'Sessions Completed Normally'
Confidence = 'info'
Description = "$completed RDP session(s) completed without critical errors. Connection flow appears healthy in the analyzed window."
Action = "No action required. Review individual session durations below if the customer is reporting performance concerns."
EventCount = $Sessions.Count
TimeRange = "$($first.ToString('HH:mm:ss')) - $($last.ToString('HH:mm:ss'))"
SupportingEvents = @($sessionEvents)
}
}

return ,$categories
}

# --- HTML rendering ---

function ConvertTo-HtmlEncoded {
param([string]$Text)
if ($null -eq $Text) { return '' }
[System.Net.WebUtility]::HtmlEncode($Text)
}

function ConvertTo-Slug {
# Convert a display name into a URL-safe anchor id
param([string]$Text)
if ([string]::IsNullOrWhiteSpace($Text)) { return 'anchor' }
$slug = $Text.ToLower() -replace '[^a-z0-9]+', '-' -replace '^-|-$', ''
if ([string]::IsNullOrWhiteSpace($slug)) { $slug = 'anchor' }
return $slug
}

function New-TimeOfDayChart {
# Build an SVG bar chart of event counts by hour-of-day (24 buckets).
# Bars are colored by predominant severity in that hour: error > warn > info.
# If logs span multiple days, hours collapse together to surface daily patterns
# (e.g. "every day at 14:00 something breaks").
param([array]$Events)

$byHour = @{}
for ($h = 0; $h -lt 24; $h++) { $byHour[$h] = @{ Total = 0; Info = 0; Warn = 0; Error = 0 } }

foreach ($e in $Events) {
$h = $e.Timestamp.Hour
$byHour[$h].Total++
switch ($e.Level) {
'ERROR' { $byHour[$h].Error++ }
'WARN' { $byHour[$h].Warn++ }
default { $byHour[$h].Info++ }
}
}

$maxCount = ($byHour.Values | ForEach-Object { $_.Total } | Measure-Object -Maximum).Maximum
if (-not $maxCount -or $maxCount -eq 0) { $maxCount = 1 }

$chartW = 760
$chartH = 180
$padL = 36; $padR = 12; $padT = 12; $padB = 28
$plotW = $chartW - $padL - $padR
$plotH = $chartH - $padT - $padB
$barGap = 2
$barW = ($plotW / 24) - $barGap

$sb = [System.Text.StringBuilder]::new()
[void]$sb.AppendLine("<svg width='$chartW' height='$chartH' viewBox='0 0 $chartW $chartH' xmlns='http://www.w3.org/2000/svg' style='background:transparent'>")

# Y axis gridlines and labels (at 0, 50%, 100%)
foreach ($frac in @(0.0, 0.5, 1.0)) {
$y = $padT + ($plotH * (1.0 - $frac))
$val = [int]([math]::Round($maxCount * $frac))
[void]$sb.AppendLine("<line x1='$padL' y1='$y' x2='$($chartW - $padR)' y2='$y' stroke='#294274' stroke-dasharray='2,3' stroke-width='1'/>")
[void]$sb.AppendLine("<text x='$($padL - 6)' y='$($y + 4)' fill='#9aa0a6' font-size='10' text-anchor='end' font-family='Consolas,monospace'>$val</text>")
}

# bars
for ($h = 0; $h -lt 24; $h++) {
$count = $byHour[$h].Total
if ($count -eq 0) { continue }
$barH = ($count / $maxCount) * $plotH
$x = $padL + ($h * ($barW + $barGap))
$y = $padT + ($plotH - $barH)
$color = if ($byHour[$h].Error -gt 0) { '#ef5350' } elseif ($byHour[$h].Warn -gt 0) { '#ffa726' } else { '#4169B8' }
[void]$sb.AppendLine("<rect x='$x' y='$y' width='$barW' height='$barH' fill='$color' rx='1'>")
[void]$sb.AppendLine("<title>Hour $('{0:D2}' -f $h):00 - $count events ($($byHour[$h].Error) error, $($byHour[$h].Warn) warn, $($byHour[$h].Info) info)</title>")
[void]$sb.AppendLine("</rect>")
# value label above bar
$labelX = $x + ($barW / 2)
[void]$sb.AppendLine("<text x='$labelX' y='$($y - 3)' fill='#9aa0a6' font-size='9' text-anchor='middle' font-family='Consolas,monospace'>$count</text>")
}

# X axis labels (every 3 hours)
for ($h = 0; $h -lt 24; $h += 3) {
$x = $padL + ($h * ($barW + $barGap)) + ($barW / 2)
$y = $chartH - 10
[void]$sb.AppendLine("<text x='$x' y='$y' fill='#9aa0a6' font-size='10' text-anchor='middle' font-family='Consolas,monospace'>$('{0:D2}' -f $h):00</text>")
}

[void]$sb.AppendLine("</svg>")
return $sb.ToString()
}

function Write-HtmlReport {
param(
[Parameter(Mandatory)] [AllowEmptyCollection()] [array]$Events,
[Parameter(Mandatory)] [AllowNull()] [AllowEmptyCollection()] [array]$Sessions,
[Parameter(Mandatory)] [AllowNull()] [AllowEmptyCollection()] [array]$Cascades,
[Parameter(Mandatory)] [AllowNull()] [AllowEmptyCollection()] [array]$Storms,
[Parameter(Mandatory)] [AllowNull()] [AllowEmptyCollection()] [array]$Signatures,
[Parameter(Mandatory)] [AllowNull()] [AllowEmptyCollection()] [array]$Categories,
[Parameter(Mandatory)] [string[]]$SourceFiles,
[Parameter(Mandatory)] [int]$HoursBack,
[Parameter(Mandatory)] [string]$OutputPath
)

# Normalize null inputs
if ($null -eq $Sessions) { $Sessions = @() }
if ($null -eq $Cascades) { $Cascades = @() }
if ($null -eq $Storms) { $Storms = @() }
if ($null -eq $Signatures) { $Signatures = @() }
if ($null -eq $Categories) { $Categories = @() }

# Partition events by component side for the per-tab views
$hostEvents = $Events | Where-Object { $_.Component -in 'Sentry','SecureConnect' }
$clientEvents = $Events | Where-Object { $_.Component -eq 'Connector' }

$sb = [System.Text.StringBuilder]::new()
[void]$sb.AppendLine('<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">')
[void]$sb.AppendLine("<title>TruGrid Log Cross-Reference - $(ConvertTo-HtmlEncoded $env:COMPUTERNAME)</title>")
[void]$sb.AppendLine(@'
<style>
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap');
:root {
--bg:#142038; --panel:#1F2536; --text:#F4F5F8; --muted:#9aa0a6;
--pass:#83A848; --fail:#ef5350; --warn:#ffa726; --info:#4169B8;
--border:#294274; --accent:#4169B8;
}
*{box-sizing:border-box}
body{background:var(--bg);color:var(--text);font-family:'Montserrat','Segoe UI',sans-serif;margin:0;padding:24px;line-height:1.5}
.header-row{display:flex;justify-content:space-between;align-items:flex-start;gap:24px;margin-bottom:8px}
.header-title{flex:1}
.header-logo{flex-shrink:0;padding-top:4px}
h1{margin:0 0 8px;font-size:22px;color:var(--accent)}
h2{margin:24px 0 12px;font-size:16px;color:var(--accent);border-bottom:1px solid var(--border);padding-bottom:6px}
.meta{color:var(--muted);font-size:12px;margin-bottom:16px}
.banner{padding:14px 18px;border-radius:6px;margin:16px 0;font-weight:600;font-size:14px;background:rgba(65,105,184,0.15);border-left:4px solid var(--accent)}
.banner.warn{background:rgba(255,167,38,0.15);border-left-color:var(--warn)}
.banner.fail{background:rgba(239,83,80,0.15);border-left-color:var(--fail)}
.banner.pass{background:rgba(131,168,72,0.15);border-left-color:var(--pass)}
table{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--border);font-size:13px;margin-bottom:16px}
th,td{padding:8px 12px;text-align:left;border-bottom:1px solid var(--border);vertical-align:top}
th{background:rgba(65,105,184,0.12);font-weight:600;color:var(--accent)}
tr:last-child td{border-bottom:none}
.mono{font-family:Consolas,Menlo,monospace;font-size:12px}
.tier-error{color:var(--fail)}
.tier-warn{color:var(--warn)}
.tier-info{color:var(--muted)}
.env-grid{display:grid;grid-template-columns:200px 1fr;gap:4px 12px;background:var(--panel);padding:12px 16px;border:1px solid var(--border);border-radius:4px;font-size:13px;margin-bottom:16px}
.env-grid .k{color:var(--muted)}
.env-grid .v{font-family:Consolas,Menlo,monospace;word-break:break-all}
.footer{color:var(--muted);font-size:11px;margin-top:32px;border-top:1px solid var(--border);padding-top:12px}
.empty{color:var(--muted);font-style:italic;padding:12px}
.tab-bar{display:flex;gap:4px;margin:24px 0 0;border-bottom:1px solid var(--border)}
.tab-btn{background:transparent;color:var(--muted);border:none;padding:10px 18px;cursor:pointer;font-family:inherit;font-size:13px;font-weight:600;border-bottom:2px solid transparent;transition:all 0.15s}
.tab-btn:hover{color:var(--text)}
.tab-btn.active{color:var(--accent);border-bottom-color:var(--accent)}
.tab-content{display:none;padding-top:16px}
.tab-content.active{display:block}
.category-card{display:flex;gap:14px;background:var(--panel);border-left:4px solid var(--accent);padding:14px 18px;border-radius:4px;margin-bottom:12px}
.category-card.cat-high{border-left-color:var(--fail)}
.category-card.cat-medium{border-left-color:var(--warn)}
.category-card.cat-info{border-left-color:var(--accent)}
.cat-icon{color:var(--accent);font-size:22px;font-weight:bold;flex-shrink:0;width:28px;text-align:center}
.cat-high .cat-icon{color:var(--fail)}
.cat-medium .cat-icon{color:var(--warn)}
.cat-info .cat-icon{color:var(--accent)}
.cat-body{flex:1}
.cat-title{font-weight:600;font-size:14px;color:var(--text);margin-bottom:4px}
.cat-desc{color:var(--text);font-size:13px;margin-bottom:6px}
.cat-action{color:var(--muted);font-size:12px;font-style:italic}
.cat-meta{color:var(--muted);font-size:11px;margin-top:8px;font-family:Consolas,monospace}
.cat-details{margin-top:10px;border-top:1px solid var(--border);padding-top:8px}
.cat-details summary{cursor:pointer;color:var(--accent);font-size:12px;font-weight:600;list-style:none}
.cat-details summary::-webkit-details-marker{display:none}
.cat-details summary::before{content:'▸ ';display:inline-block;transition:transform 0.15s}
.cat-details[open] summary::before{transform:rotate(90deg)}
.cat-details-body{margin-top:8px}
.copy-btn{background:rgba(65,105,184,0.2);color:var(--accent);border:1px solid var(--border);padding:4px 10px;border-radius:3px;font-family:inherit;font-size:11px;cursor:pointer;margin-bottom:6px}
.copy-btn:hover{background:rgba(65,105,184,0.35)}
.copy-btn.copied{background:rgba(131,168,72,0.25);color:var(--pass)}
.raw-lines{background:#0d1626;border:1px solid var(--border);padding:10px;border-radius:3px;font-family:Consolas,monospace;font-size:11px;color:var(--text);max-height:300px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0}
.anchor-link{color:var(--muted);text-decoration:none;opacity:0.35;margin-left:8px;font-size:0.85em;font-weight:normal}
.anchor-link:hover{opacity:1;color:var(--accent)}
.category-card[id],h2[id],tr[id]{scroll-margin-top:16px}
.chart-wrap{background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:14px 16px;margin-bottom:16px}
.chart-wrap-meta{color:var(--muted);font-size:11px;margin-bottom:8px}
.target-summary{background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:10px 14px;margin-bottom:12px;font-size:13px}
.target-summary .label{color:var(--muted);margin-right:8px}
.target-summary .target-pill{display:inline-block;background:rgba(65,105,184,0.15);border:1px solid var(--border);padding:2px 8px;border-radius:10px;margin:2px 4px 2px 0;font-family:Consolas,monospace;font-size:12px}
</style>
'@)
[void]$sb.AppendLine('</head><body>')

# header (always visible) - title left, logo right
[void]$sb.AppendLine("<div class='header-row'>")
[void]$sb.AppendLine("<div class='header-title'>")
[void]$sb.AppendLine("<h1>TruGrid Log Cross-Reference Report</h1>")
[void]$sb.AppendLine("<div class='meta'>Generated $((Get-Date).ToString('yyyy-MM-dd HH:mm:ss zzz')) | Script v$($Script:ScriptVersion)</div>")
[void]$sb.AppendLine("</div>")
[void]$sb.AppendLine("<div class='header-logo'>$($Script:TruGridLogoSvg)</div>")
[void]$sb.AppendLine("</div>")

# banner
$totalAnomalies = $Cascades.Count + $Storms.Count + $Signatures.Count
$bannerClass = if ($totalAnomalies -eq 0) { 'pass' } elseif ($totalAnomalies -lt 3) { 'warn' } else { 'fail' }
$bannerMsg = "Analyzed $($Events.Count) events across $($SourceFiles.Count) log source(s). Detected $($Sessions.Count) session(s), $($Categories.Count) auto-categorized pattern(s), $($Cascades.Count) failure cascade(s), $($Storms.Count) reconnect storm(s)."
[void]$sb.AppendLine("<div class='banner $bannerClass'>$(ConvertTo-HtmlEncoded $bannerMsg)</div>")

# scope (always visible)
[void]$sb.AppendLine("<h2>Scope</h2>")
[void]$sb.AppendLine("<div class='env-grid'>")
[void]$sb.AppendLine("<div class='k'>Hostname</div><div class='v'>$(ConvertTo-HtmlEncoded $env:COMPUTERNAME)</div>")
[void]$sb.AppendLine("<div class='k'>Time Window</div><div class='v'>Last $HoursBack hours (from $((Get-Date).AddHours(-$HoursBack).ToString('yyyy-MM-dd HH:mm:ss')) to now)</div>")
[void]$sb.AppendLine("<div class='k'>Sources</div><div class='v'>$(ConvertTo-HtmlEncoded ($SourceFiles -join '; '))</div>")
[void]$sb.AppendLine("<div class='k'>Total Events</div><div class='v'>$($Events.Count) ($($hostEvents.Count) host / $($clientEvents.Count) client)</div>")
[void]$sb.AppendLine("</div>")

# tab bar
[void]$sb.AppendLine("<div class='tab-bar'>")
[void]$sb.AppendLine("<button class='tab-btn active' id='btn-cross' onclick=`"showTab('cross')`">Cross-Reference</button>")
[void]$sb.AppendLine("<button class='tab-btn' id='btn-host' onclick=`"showTab('host')`">Host Errors ($(($hostEvents | Where-Object { $_.Level -in 'WARN','ERROR' }).Count))</button>")
[void]$sb.AppendLine("<button class='tab-btn' id='btn-client' onclick=`"showTab('client')`">Client Errors ($(($clientEvents | Where-Object { $_.Level -in 'WARN','ERROR' }).Count))</button>")
[void]$sb.AppendLine("</div>")

# === TAB 1: CROSS-REFERENCE ===
[void]$sb.AppendLine("<div class='tab-content active' id='content-cross'>")

# auto-categorizations FIRST (the headline finding)
[void]$sb.AppendLine("<h2 id='auto-categorized-patterns'>Auto-Categorized Patterns <a class='anchor-link' href='#auto-categorized-patterns'>#</a></h2>")
if ($Categories.Count -eq 0) {
[void]$sb.AppendLine("<div class='empty'>No known patterns auto-detected in the analyzed window. Review the detail sections below.</div>")
}
else {
$catIdx = 0
foreach ($cat in $Categories) {
$catIdx++
$cardClass = "cat-$($cat.Confidence)"
$icon = switch ($cat.Confidence) { 'high' { '!' } 'medium' { '?' } 'info' { 'i' } default { 'i' } }
$anchorId = "auto-cat-$(ConvertTo-Slug $cat.Name)"
$rawId = "raw-lines-$catIdx"
[void]$sb.AppendLine("<div class='category-card $cardClass' id='$anchorId'>")
[void]$sb.AppendLine("<div class='cat-icon'>$icon</div>")
[void]$sb.AppendLine("<div class='cat-body'>")
[void]$sb.AppendLine("<div class='cat-title'>$(ConvertTo-HtmlEncoded $cat.Name) <a class='anchor-link' href='#$anchorId'>#</a></div>")
[void]$sb.AppendLine("<div class='cat-desc'>$(ConvertTo-HtmlEncoded $cat.Description)</div>")
[void]$sb.AppendLine("<div class='cat-action'>Suggested next step: $(ConvertTo-HtmlEncoded $cat.Action)</div>")
[void]$sb.AppendLine("<div class='cat-meta'>Confidence: $($cat.Confidence) | Events: $($cat.EventCount) | Time: $(ConvertTo-HtmlEncoded $cat.TimeRange)</div>")

# Supporting raw log lines (collapsed by default)
if ($cat.PSObject.Properties['SupportingEvents'] -and $cat.SupportingEvents -and $cat.SupportingEvents.Count -gt 0) {
$supportingCount = $cat.SupportingEvents.Count
[void]$sb.AppendLine("<details class='cat-details'>")
[void]$sb.AppendLine("<summary>Show $supportingCount supporting log line(s)</summary>")
[void]$sb.AppendLine("<div class='cat-details-body'>")
[void]$sb.AppendLine("<button class='copy-btn' onclick=`"copyLogLines('$rawId', this)`">Copy lines to clipboard</button>")
[void]$sb.AppendLine("<pre class='raw-lines' id='$rawId'>")
foreach ($evt in $cat.SupportingEvents) {
$tsStr = $evt.Timestamp.ToString('yyyy-MM-dd HH:mm:ss')
$line = "[$tsStr] [$($evt.Component)] [$($evt.Level)] $($evt.Message)"
[void]$sb.AppendLine((ConvertTo-HtmlEncoded $line))
}
[void]$sb.AppendLine("</pre>")
[void]$sb.AppendLine("</div>")
[void]$sb.AppendLine("</details>")
}

[void]$sb.AppendLine("</div></div>")
}
}

# time-of-day distribution chart
[void]$sb.AppendLine("<h2 id='time-of-day'>Event Distribution by Hour-of-Day <a class='anchor-link' href='#time-of-day'>#</a></h2>")
[void]$sb.AppendLine("<div class='chart-wrap'>")
[void]$sb.AppendLine("<div class='chart-wrap-meta'>Events collapsed into 24 hour-of-day buckets across the analyzed window. Bar color reflects the highest severity in that hour: blue=info, amber=warn, red=error. Hover a bar for the breakdown.</div>")
[void]$sb.AppendLine((New-TimeOfDayChart -Events $Events))
[void]$sb.AppendLine("</div>")

# sessions
[void]$sb.AppendLine("<h2 id='detected-sessions'>Detected Sessions <a class='anchor-link' href='#detected-sessions'>#</a></h2>")
if ($Sessions.Count -eq 0) {
[void]$sb.AppendLine("<div class='empty'>No paired session activity detected in the window.</div>")
}
else {
# per-target summary if any session has a known target
$sessionsWithTarget = $Sessions | Where-Object { $_.TargetMachine }
if ($sessionsWithTarget.Count -gt 0) {
$targetCounts = $sessionsWithTarget | Group-Object TargetMachine | Sort-Object Count -Descending
[void]$sb.AppendLine("<div class='target-summary'>")
[void]$sb.AppendLine("<span class='label'>Sessions by target machine:</span>")
foreach ($tg in $targetCounts) {
[void]$sb.AppendLine("<span class='target-pill'>$(ConvertTo-HtmlEncoded $tg.Name) ($($tg.Count))</span>")
}
[void]$sb.AppendLine("</div>")
}

[void]$sb.AppendLine("<table><thead><tr><th>Start</th><th>End</th><th>Duration</th><th>Target</th><th>Tunnel/Conn Id</th><th>User</th><th>Relay IP</th><th>Events</th></tr></thead><tbody>")
foreach ($s in $Sessions) {
$durationStr = if ($null -ne $s.DurationSec) { "$($s.DurationSec)s" } else { 'open' }
$endStr = if ($s.EndTime) { $s.EndTime.ToString('HH:mm:ss') } else { '(still open)' }
$targetStr = if ($s.TargetMachine) { ConvertTo-HtmlEncoded $s.TargetMachine } else { '<span style="color:var(--muted)">unknown</span>' }
$sessAnchor = "session-$(($s.TunnelId -replace '[^a-zA-Z0-9]','').Substring(0, [Math]::Min(12, ($s.TunnelId -replace '[^a-zA-Z0-9]','').Length)))"
[void]$sb.AppendLine("<tr id='$sessAnchor'><td class='mono'>$($s.StartTime.ToString('HH:mm:ss')) <a class='anchor-link' href='#$sessAnchor'>#</a></td><td class='mono'>$endStr</td><td>$durationStr</td><td class='mono'>$targetStr</td><td class='mono'>$(ConvertTo-HtmlEncoded $s.TunnelId)</td><td>$(ConvertTo-HtmlEncoded $s.UserId)</td><td class='mono'>$(ConvertTo-HtmlEncoded $s.RelayIp)</td><td>$($s.EventCount)</td></tr>")
}
[void]$sb.AppendLine("</tbody></table>")
}

# known-issue signatures
[void]$sb.AppendLine("<h2 id='known-issue-signatures'>Known-Issue Signatures <a class='anchor-link' href='#known-issue-signatures'>#</a></h2>")
if ($Signatures.Count -eq 0) {
[void]$sb.AppendLine("<div class='empty'>No known-issue patterns detected.</div>")
}
else {
[void]$sb.AppendLine("<table><thead><tr><th>Pattern</th><th>Count</th><th>First Seen</th><th>Last Seen</th><th>Component(s)</th><th>Note</th></tr></thead><tbody>")
foreach ($sig in $Signatures) {
$sigAnchor = "sig-$(ConvertTo-Slug $sig.Pattern)"
[void]$sb.AppendLine("<tr id='$sigAnchor'><td><strong>$(ConvertTo-HtmlEncoded $sig.Pattern)</strong> <a class='anchor-link' href='#$sigAnchor'>#</a></td><td>$($sig.Count)</td><td class='mono'>$($sig.FirstSeen.ToString('HH:mm:ss'))</td><td class='mono'>$($sig.LastSeen.ToString('HH:mm:ss'))</td><td>$(ConvertTo-HtmlEncoded $sig.Component)</td><td>$(ConvertTo-HtmlEncoded $sig.Note)</td></tr>")
}
[void]$sb.AppendLine("</tbody></table>")
}

# cascades
[void]$sb.AppendLine("<h2 id='failure-cascades'>Failure Cascades <a class='anchor-link' href='#failure-cascades'>#</a></h2>")
[void]$sb.AppendLine("<p class='meta'>Errors or warnings in two or more components within $($Script:CascadeWindowSeconds) seconds of each other. Often indicates a single root cause rippling through the stack.</p>")
if ($Cascades.Count -eq 0) {
[void]$sb.AppendLine("<div class='empty'>No multi-component error cascades detected.</div>")
}
else {
[void]$sb.AppendLine("<table><thead><tr><th>Start</th><th>End</th><th>Components</th><th>Events</th><th>Sample Messages</th></tr></thead><tbody>")
foreach ($c in $Cascades) {
[void]$sb.AppendLine("<tr><td class='mono'>$($c.StartTime.ToString('HH:mm:ss'))</td><td class='mono'>$($c.EndTime.ToString('HH:mm:ss'))</td><td>$(ConvertTo-HtmlEncoded $c.Components)</td><td>$($c.EventCount)</td><td class='mono' style='font-size:11px'>$(ConvertTo-HtmlEncoded $c.Sample)</td></tr>")
}
[void]$sb.AppendLine("</tbody></table>")
}

# storms
[void]$sb.AppendLine("<h2>Reconnect Storms</h2>")
[void]$sb.AppendLine("<p class='meta'>$($Script:ReconnectStormCount) or more reconnect attempts within $($Script:ReconnectStormSeconds) seconds. Suggests sustained network instability.</p>")
if ($Storms.Count -eq 0) {
[void]$sb.AppendLine("<div class='empty'>No reconnect storms detected.</div>")
}
else {
[void]$sb.AppendLine("<table><thead><tr><th>Start</th><th>End</th><th>Attempts</th><th>Component(s)</th></tr></thead><tbody>")
foreach ($s in $Storms) {
[void]$sb.AppendLine("<tr><td class='mono'>$($s.StartTime.ToString('HH:mm:ss'))</td><td class='mono'>$($s.EndTime.ToString('HH:mm:ss'))</td><td>$($s.Count)</td><td>$(ConvertTo-HtmlEncoded $s.Component)</td></tr>")
}
[void]$sb.AppendLine("</tbody></table>")
}

[void]$sb.AppendLine("</div>") # end cross-reference tab

# === TAB 2: HOST ERRORS ===
[void]$sb.AppendLine("<div class='tab-content' id='content-host'>")
[void]$sb.AppendLine("<p class='meta'>Events from Sentry and Secure Connect logs. WARN level and above shown by default.</p>")

$hostByComp = $hostEvents | Group-Object Component
if ($hostByComp.Count -gt 0) {
[void]$sb.AppendLine("<h2>Host Event Counts</h2>")
[void]$sb.AppendLine("<table><thead><tr><th>Component</th><th>Total</th><th>INFO</th><th>WARN</th><th>ERROR</th><th>Top Event Types</th></tr></thead><tbody>")
foreach ($comp in $hostByComp) {
$info = ($comp.Group | Where-Object { $_.Level -eq 'INFO' }).Count
$warn = ($comp.Group | Where-Object { $_.Level -eq 'WARN' }).Count
$error = ($comp.Group | Where-Object { $_.Level -eq 'ERROR' }).Count
$topTypes = $comp.Group | Where-Object { $_.EventType } | Group-Object EventType | Sort-Object Count -Descending | Select-Object -First 3 |
ForEach-Object { "$($Script:EventTypes[$_.Name]) ($($_.Count))" }
[void]$sb.AppendLine("<tr><td><strong>$(ConvertTo-HtmlEncoded $comp.Name)</strong></td><td>$($comp.Count)</td><td class='tier-info'>$info</td><td class='tier-warn'>$warn</td><td class='tier-error'>$error</td><td>$(ConvertTo-HtmlEncoded ($topTypes -join '; '))</td></tr>")
}
[void]$sb.AppendLine("</tbody></table>")
}

[void]$sb.AppendLine("<h2>Host Event Timeline (WARN+)</h2>")
$hostTimeline = $hostEvents | Where-Object { $_.Level -in 'WARN','ERROR' -or $_.EventType -like 'KnownError*' } | Sort-Object Timestamp
if ($hostTimeline.Count -eq 0) {
[void]$sb.AppendLine("<div class='empty'>No WARN or ERROR events from host components in the window.</div>")
}
else {
[void]$sb.AppendLine("<table><thead><tr><th>Time</th><th>Component</th><th>Level</th><th>Event Type</th><th>Message</th></tr></thead><tbody>")
foreach ($e in $hostTimeline) {
$lvlClass = switch ($e.Level) { 'ERROR' { 'tier-error' } 'WARN' { 'tier-warn' } default { 'tier-info' } }
$eventLabel = if ($e.EventType -and $Script:EventTypes[$e.EventType]) { $Script:EventTypes[$e.EventType] } else { '' }
$msgShort = if ($e.Message.Length -gt 200) { $e.Message.Substring(0, 200) + '...' } else { $e.Message }
[void]$sb.AppendLine("<tr><td class='mono'>$($e.Timestamp.ToString('HH:mm:ss'))</td><td>$(ConvertTo-HtmlEncoded $e.Component)</td><td class='$lvlClass'>$($e.Level)</td><td>$(ConvertTo-HtmlEncoded $eventLabel)</td><td class='mono' style='font-size:11px'>$(ConvertTo-HtmlEncoded $msgShort)</td></tr>")
}
[void]$sb.AppendLine("</tbody></table>")
}

[void]$sb.AppendLine("</div>") # end host tab

# === TAB 3: CLIENT ERRORS ===
[void]$sb.AppendLine("<div class='tab-content' id='content-client'>")
[void]$sb.AppendLine("<p class='meta'>Events from the TruGrid Connector logs. WARN level and above shown by default.</p>")

if ($clientEvents.Count -gt 0) {
$clientByComp = $clientEvents | Group-Object Component
[void]$sb.AppendLine("<h2>Client Event Counts</h2>")
[void]$sb.AppendLine("<table><thead><tr><th>Component</th><th>Total</th><th>INFO</th><th>WARN</th><th>ERROR</th><th>Top Event Types</th></tr></thead><tbody>")
foreach ($comp in $clientByComp) {
$info = ($comp.Group | Where-Object { $_.Level -eq 'INFO' }).Count
$warn = ($comp.Group | Where-Object { $_.Level -eq 'WARN' }).Count
$error = ($comp.Group | Where-Object { $_.Level -eq 'ERROR' }).Count
$topTypes = $comp.Group | Where-Object { $_.EventType } | Group-Object EventType | Sort-Object Count -Descending | Select-Object -First 3 |
ForEach-Object { "$($Script:EventTypes[$_.Name]) ($($_.Count))" }
[void]$sb.AppendLine("<tr><td><strong>$(ConvertTo-HtmlEncoded $comp.Name)</strong></td><td>$($comp.Count)</td><td class='tier-info'>$info</td><td class='tier-warn'>$warn</td><td class='tier-error'>$error</td><td>$(ConvertTo-HtmlEncoded ($topTypes -join '; '))</td></tr>")
}
[void]$sb.AppendLine("</tbody></table>")
}

[void]$sb.AppendLine("<h2>Client Event Timeline (WARN+)</h2>")
$clientTimeline = $clientEvents | Where-Object { $_.Level -in 'WARN','ERROR' -or $_.EventType -like 'KnownError*' } | Sort-Object Timestamp
if ($clientTimeline.Count -eq 0) {
[void]$sb.AppendLine("<div class='empty'>No WARN or ERROR events from the Connector in the window.</div>")
}
else {
[void]$sb.AppendLine("<table><thead><tr><th>Time</th><th>Level</th><th>Event Type</th><th>Message</th></tr></thead><tbody>")
foreach ($e in $clientTimeline) {
$lvlClass = switch ($e.Level) { 'ERROR' { 'tier-error' } 'WARN' { 'tier-warn' } default { 'tier-info' } }
$eventLabel = if ($e.EventType -and $Script:EventTypes[$e.EventType]) { $Script:EventTypes[$e.EventType] } else { '' }
$msgShort = if ($e.Message.Length -gt 200) { $e.Message.Substring(0, 200) + '...' } else { $e.Message }
[void]$sb.AppendLine("<tr><td class='mono'>$($e.Timestamp.ToString('HH:mm:ss'))</td><td class='$lvlClass'>$($e.Level)</td><td>$(ConvertTo-HtmlEncoded $eventLabel)</td><td class='mono' style='font-size:11px'>$(ConvertTo-HtmlEncoded $msgShort)</td></tr>")
}
[void]$sb.AppendLine("</tbody></table>")
}

[void]$sb.AppendLine("</div>") # end client tab

# footer (always visible)
[void]$sb.AppendLine("<div class='footer'>Generated by CrossReference-TruGridLogs.ps1 v$($Script:ScriptVersion). Correlation thresholds: cascade window $($Script:CascadeWindowSeconds)s, reconnect storm $($Script:ReconnectStormCount) attempts in $($Script:ReconnectStormSeconds)s.</div>")

# tab switching JS
[void]$sb.AppendLine(@'
<script>
function showTab(name) {
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.getElementById('content-' + name).classList.add('active');
document.getElementById('btn-' + name).classList.add('active');
}
function copyLogLines(elemId, btn) {
const el = document.getElementById(elemId);
if (!el) return;
const text = el.innerText;
const done = () => {
const orig = btn.textContent;
btn.textContent = 'Copied';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = orig; btn.classList.remove('copied'); }, 1500);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done).catch(() => fallbackCopy(text, done));
} else {
fallbackCopy(text, done);
}
}
function fallbackCopy(text, cb) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed'; ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); cb(); } catch (e) {}
document.body.removeChild(ta);
}
</script>
'@)
[void]$sb.AppendLine("</body></html>")

Set-Content -Path $OutputPath -Value $sb.ToString() -Encoding UTF8
}

# --- discovery (used when -LogFiles not provided) ---

function Find-AvailableLogs {
$candidates = @()
if (Test-Path $Script:NativeSentryLog) {
$candidates += $Script:NativeSentryLog
}
if (Test-Path $Script:NativeSCLog) {
$candidates += $Script:NativeSCLog
}
if (Test-Path $Script:NativeConnectorDir) {
Get-ChildItem -Path $Script:NativeConnectorDir -Filter 'win_headless_logs_*.txt' -File -ErrorAction SilentlyContinue |
Sort-Object Name -Descending |
Select-Object -First 7 |
ForEach-Object { $candidates += $_.FullName }
}
if (Test-Path $Script:ImportDir) {
Get-ChildItem -Path $Script:ImportDir -File -Include '*.log','*.txt' -Recurse -ErrorAction SilentlyContinue |
ForEach-Object { $candidates += $_.FullName }
}
return $candidates
}

# --- main ---

Write-Host "TruGrid Log Cross-Reference v$($Script:ScriptVersion)" -ForegroundColor Cyan
Write-Host ("=" * 60) -ForegroundColor DarkGray

# track whether params were explicitly passed (launcher) or we're interactive
$logFilesProvided = $PSBoundParameters.ContainsKey('LogFiles') -and $LogFiles -and $LogFiles.Count -gt 0
$hoursBackProvided = $PSBoundParameters.ContainsKey('HoursBack')

if (-not $logFilesProvided) {
Write-Host "`nAuto-discovering log files..." -ForegroundColor Yellow
$discovered = Find-AvailableLogs
if ($discovered.Count -eq 0) {
Write-Host " No logs found in native locations or imported-logs folder." -ForegroundColor DarkYellow
Write-Host " You can still browse to pick files manually." -ForegroundColor DarkYellow
}
else {
Write-Host "`nDiscovered logs:" -ForegroundColor Cyan
for ($i = 0; $i -lt $discovered.Count; $i++) {
$label = $discovered[$i]
if ($label.Length -gt 90) { $label = "..." + $label.Substring($label.Length - 87) }
Write-Host (" [{0,2}] {1}" -f ($i + 1), $label) -ForegroundColor Gray
}
Write-Host " [ A] All of the above" -ForegroundColor Gray
}
Write-Host " [ B] Browse for files..." -ForegroundColor Gray
Write-Host ""

$defaultHint = if ($discovered.Count -gt 0) { 'default: A' } else { 'no default, must choose' }
$selection = Read-Host "Select logs (numbers, 'A' for all, 'B' to browse, comma-separated) [$defaultHint]"

$LogFiles = @()

# default to all when discovered logs exist and user just hits Enter
if ([string]::IsNullOrWhiteSpace($selection)) {
if ($discovered.Count -gt 0) {
$LogFiles = $discovered
}
}
else {
$tokens = ($selection -split ',') | ForEach-Object { $_.Trim().ToUpper() } | Where-Object { $_ }
foreach ($tok in $tokens) {
if ($tok -eq 'A') {
$LogFiles += $discovered
}
elseif ($tok -eq 'B') {
try {
Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
$dialog = [System.Windows.Forms.OpenFileDialog]::new()
$dialog.Multiselect = $true
$dialog.Filter = "Log files (*.log;*.txt)|*.log;*.txt|All files (*.*)|*.*"
$dialog.Title = "Select TruGrid log files to include"
$dialog.InitialDirectory = if (Test-Path $Script:ImportDir) { $Script:ImportDir } else { $env:USERPROFILE }
if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
$LogFiles += $dialog.FileNames
}
} catch {
Write-Host " File picker unavailable: $($_.Exception.Message)" -ForegroundColor Red
}
}
elseif ($tok -match '^\d+$') {
$idx = [int]$tok - 1
if ($idx -ge 0 -and $idx -lt $discovered.Count) {
$LogFiles += $discovered[$idx]
}
}
}
$LogFiles = $LogFiles | Select-Object -Unique
}

if ($LogFiles.Count -eq 0) {
Write-Host "`n No files selected. Exiting." -ForegroundColor Red
exit 1
}

Write-Host "`nSelected $($LogFiles.Count) log file(s)." -ForegroundColor Green
}

if (-not $hoursBackProvided) {
Write-Host "`nTime window options:" -ForegroundColor Cyan
Write-Host " [1] Last 1 hour" -ForegroundColor Gray
Write-Host " [2] Last 6 hours" -ForegroundColor Gray
Write-Host " [3] Last 24 hours" -ForegroundColor Gray
Write-Host " [4] Last 7 days" -ForegroundColor Gray
Write-Host " [5] Custom (you enter hours)" -ForegroundColor Gray
Write-Host ""
$timeChoice = Read-Host "Select time window [1-5, default: 2]"
switch ($timeChoice.Trim()) {
'1' { $HoursBack = 1 }
'2' { $HoursBack = 6 }
'3' { $HoursBack = 24 }
'4' { $HoursBack = 168 }
'5' {
$custom = Read-Host "Enter number of hours to look back"
if ($custom -match '^\d+$' -and [int]$custom -gt 0) {
$HoursBack = [int]$custom
} else {
Write-Host " Invalid, defaulting to 6 hours." -ForegroundColor DarkYellow
$HoursBack = 6
}
}
default { $HoursBack = 6 }
}
Write-Host "`nTime window set to last $HoursBack hour(s)." -ForegroundColor Green
}

$windowStart = (Get-Date).ToUniversalTime().AddHours(-$HoursBack)
Write-Host "`n[1/3] Parsing logs (last $HoursBack hours)..." -ForegroundColor Yellow

$allEvents = [System.Collections.Generic.List[object]]::new()
foreach ($path in $LogFiles) {
$events = Parse-LogFile -Path $path -WindowStartUtc $windowStart
foreach ($e in $events) { [void]$allEvents.Add($e) }
}

if ($allEvents.Count -eq 0) {
Write-Host "`nNo events found in the window. Exiting." -ForegroundColor Yellow
exit 0
}

Write-Host "`n[2/3] Running correlation passes..." -ForegroundColor Yellow
$events = $allEvents.ToArray()
$sessions = Find-SessionPairs -Events $events
$cascades = Find-FailureCascades -Events $events
$storms = Find-ReconnectStorms -Events $events
$signatures = Get-KnownIssueSignatures -Events $events
$categories = Find-AutoCategorizations -Events $events -Sessions $sessions -Storms $storms -Signatures $signatures
Write-Host " Sessions: $($sessions.Count) | Cascades: $($cascades.Count) | Storms: $($storms.Count) | Signatures: $($signatures.Count) | Auto-Categories: $($categories.Count)" -ForegroundColor Gray

Write-Host "`n[3/3] Writing report..." -ForegroundColor Yellow
if (-not (Test-Path $Script:OutputDir)) {
New-Item -Path $Script:OutputDir -ItemType Directory -Force | Out-Null
}
$stamp = (Get-Date).ToString('yyyyMMdd_HHmmss')
$outPath = Join-Path $Script:OutputDir "TruGridLogCrossReference_$($env:COMPUTERNAME)_$stamp.html"
Write-HtmlReport -Events $events -Sessions $sessions -Cascades $cascades -Storms $storms -Signatures $signatures -Categories $categories -SourceFiles $LogFiles -HoursBack $HoursBack -OutputPath $outPath

Write-Host " HTML report: $outPath" -ForegroundColor Green
Write-Host "`nDone." -ForegroundColor Cyan

Updated on: 19/05/2026

Was this article helpful?

Share your feedback

Cancel

Thank you!