Public/Invoke-FslDiagnostic.ps1
|
function Invoke-FslDiagnostic { <# .SYNOPSIS Runs the full FSLogixDoctor diagnostic on a session host and answers the question 'why are profiles failing here?' in one command. .DESCRIPTION Combines every FSLogixDoctor check into one read-only run: 1. Environment - install state, service, version 2. Configuration - Test-FslConfiguration rule set 3. SessionState - per-session Status/Reason/Error, translated 4. LogFile - recent errors from the FSLogix text logs, decoded 5. EventLog - FSLogix event channels, bucketed and explained 6. ContextEvents - curated events from the surrounding Windows logs (User Profile Service, NTFS, disk), correlated with the FSLogix findings Returns FSLogixDoctor.Finding objects and optionally renders them into a self-contained HTML report (-ReportPath). Two safeguards keep noise from drowning out real problems: messages that match the known-benign noise database (Data\BenignPatterns.psd1) are reported as Info instead of Critical, and when every recorded session attached cleanly, remaining Critical log/event findings are downgraded to Warning because no user-visible impact exists yet. .PARAMETER Hours Look-back window for log and event analysis. Defaults to 24. .PARAMETER LogPath Root of the FSLogix log directory. Defaults to %ProgramData%\FSLogix\Logs. .PARAMETER IncludeWarnings Also surface WARN-level log lines, not only errors. .PARAMETER ReportPath Where to write the HTML report. When omitted, a report is still written automatically to %ProgramData%\FSLogixDoctor\Reports (timestamped); the findings stay the pipeline output and the path travels in the summary and verbose stream. Disable the automatic report with -NoReport. .PARAMETER PassThru Emit the finding objects even when -ReportPath is used. .PARAMETER ComputerName Fleet mode: run the diagnostic on one or more session hosts via PowerShell remoting and aggregate the results. FSLogixDoctor must be installed on each target (Install-Module FSLogixDoctor). Findings that are identical across hosts (typically configuration drift) are merged into one finding listing every affected host; host-specific findings stay separate. Unreachable hosts become Critical 'Fleet connectivity' findings instead of aborting the run. .PARAMETER AsSummary Return one FSLogixDoctor.Summary object (severity counts, worst severity, monitoring-friendly ExitCode, plus the findings) instead of the raw finding stream. .PARAMETER AsJson Like -AsSummary, but returns the summary serialized as JSON - made for RMM/monitoring sensors (PRTG, Zabbix, scheduled tasks). .PARAMETER HistoryPath Run-history folder; defaults to %ProgramData%\FSLogixDoctor\History and is active by default (disable with -NoHistory; an explicitly bound -HistoryPath always wins). Each run is written as JSON and diffed against the previous run of the same scope: findings get a ChangeStatus (New/Persisting/Resolved), alert-worthy messages are annotated, vanished Critical/Warning findings come back as 'Resolved' Info findings, and the summary/report gain change counts. .PARAMETER IncludeProfileStore Force the profile store scan. Normally unnecessary: when neither this switch nor -ProfileStorePath is given, the store path is auto-detected from VHDLocations (locally, or from the first reachable fleet host) and scanned automatically. Containers at >=85% of their maximum size become Warnings (>=95% Critical), structural anomalies become findings. The scan runs once from the coordinating machine, not per host. .PARAMETER ProfileStorePath Explicit profile share path(s) for the store scan; overrides the auto-detection. .PARAMETER NoProfileStore Skip the automatic profile store scan (e.g. on very large shares). .PARAMETER NoHistory Skip the automatic run history. .PARAMETER NoReport Skip the automatic HTML report. .EXAMPLE Invoke-FslDiagnostic Full diagnostic of the local session host, findings on the pipeline. .EXAMPLE Invoke-FslDiagnostic -Hours 4 -ReportPath C:\Temp\fslogix-report.html .EXAMPLE Invoke-FslDiagnostic -ComputerName avd-0, avd-1 -ReportPath .\fleet.html Fleet diagnostic of two session hosts, merged into one report. .EXAMPLE $r = Invoke-FslDiagnostic -AsSummary; exit $r.ExitCode Monitoring wrapper: exit 0 = healthy, 1 = warnings, 2 = critical. .EXAMPLE Invoke-FslDiagnostic -ComputerName avd-0, avd-1 The daily driver needs nothing else: the store path is auto-detected, the run history diffs against yesterday, and the HTML report lands in %ProgramData%\FSLogixDoctor\Reports - all without further parameters. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [ValidateRange(1, 8760)] [int]$Hours = 24, [string]$LogPath = (Join-Path $env:ProgramData 'FSLogix\Logs'), [switch]$IncludeWarnings, [string]$ReportPath, [switch]$PassThru, [string[]]$ComputerName = @(), [switch]$AsSummary, [switch]$AsJson, [string]$HistoryPath = (Join-Path $env:ProgramData 'FSLogixDoctor\History'), [switch]$IncludeProfileStore, [string[]]$ProfileStorePath = @(), [switch]$NoProfileStore, [switch]$NoHistory, [switch]$NoReport ) $findings = New-Object System.Collections.Generic.List[object] # --- Fleet mode: fan out via PowerShell remoting, merge, then fall through # to the shared output section. Each host runs the normal single-host path. if (@($ComputerName).Count -gt 0) { foreach ($computer in $ComputerName) { $hostFindings = @() if ($computer -in @($env:COMPUTERNAME, 'localhost', '.')) { Write-Verbose ("Fleet: running locally on {0}..." -f $env:COMPUTERNAME) # Per-host runs collect findings only - store scan, history and # report happen once at the fleet level, never per host. $hostFindings = @(Invoke-FslDiagnostic -Hours $Hours -LogPath $LogPath -IncludeWarnings:$IncludeWarnings -NoProfileStore -NoHistory -NoReport) } else { Write-Verbose ("Fleet: running remotely on {0}..." -f $computer) try { $remoteFindings = @(Invoke-Command -ComputerName $computer -ErrorAction Stop -ScriptBlock { Import-Module FSLogixDoctor -ErrorAction Stop $diagCommand = Get-Command Invoke-FslDiagnostic if ($diagCommand.Parameters.ContainsKey('NoReport')) { Invoke-FslDiagnostic -Hours $using:Hours -IncludeWarnings:$using:IncludeWarnings -NoProfileStore -NoHistory -NoReport } else { # Older module version on the target: no auto behaviors to suppress. Invoke-FslDiagnostic -Hours $using:Hours -IncludeWarnings:$using:IncludeWarnings } }) # Rebuild as clean local finding objects - Invoke-Command # tacks PSComputerName/RunspaceId metadata onto everything # it returns, which pollutes the merged output. $hostFindings = @($remoteFindings | ForEach-Object { New-FslFinding -Category ([string]$_.Category) -Check ([string]$_.Check) -Severity ([string]$_.Severity) ` -Target ([string]$_.Target) -Message ([string]$_.Message) -Evidence ([string]$_.Evidence) ` -Recommendation ([string]$_.Recommendation) -HelpUri ([string]$_.HelpUri) }) } catch { $hostFindings = @(New-FslFinding -Category Environment -Check 'Fleet connectivity' -Severity Critical -Target $computer ` -Message ("Could not run the diagnostic on '{0}': {1}" -f $computer, $_.Exception.Message) ` -Recommendation 'Verify PowerShell remoting is enabled on the target (Enable-PSRemoting) and that FSLogixDoctor is installed there: Install-Module FSLogixDoctor.') } } foreach ($hostFinding in $hostFindings) { $findings.Add($hostFinding) } } $merged = @(Merge-FslFleetFinding -Finding $findings.ToArray()) $findings = New-Object System.Collections.Generic.List[object] foreach ($mergedFinding in $merged) { $findings.Add($mergedFinding) } # Fleet mode compares findings; two hosts with different raw settings, # each unremarkable on its own, only surface via the drift check. if (@($ComputerName).Count -gt 1) { Write-Verbose 'Comparing core FSLogix settings across the fleet...' foreach ($driftFinding in @(Get-FslFleetConfigDrift -ComputerName $ComputerName)) { $findings.Add($driftFinding) } } } else { foreach ($localFinding in @(Invoke-FslLocalDiagnostic -Hours $Hours -LogPath $LogPath -IncludeWarnings:$IncludeWarnings)) { $findings.Add($localFinding) } } # Profile store scan - runs once (on the coordinating machine in fleet # mode), not per host. Explicit paths win; otherwise the store path is # auto-detected from VHDLocations (locally, or from the first reachable # fleet host) unless -NoProfileStore opts out. if ($IncludeProfileStore -or @($ProfileStorePath | Where-Object { $_ }).Count -gt 0) { Write-Verbose 'Scanning the profile store...' foreach ($storeFinding in @(Get-FslProfileStoreFinding -Path $ProfileStorePath)) { $findings.Add($storeFinding) } } elseif (-not $NoProfileStore) { $autoStorePaths = @(Resolve-FslProfileStorePath -ComputerName $ComputerName) if ($autoStorePaths.Count -gt 0) { Write-Verbose ("Auto-detected profile store: {0}" -f ($autoStorePaths -join ', ')) foreach ($storeFinding in @(Get-FslProfileStoreFinding -Path $autoStorePaths -SkipUnreachable)) { $findings.Add($storeFinding) } } } # Run history: on by default (opt out with -NoHistory); an explicitly # bound -HistoryPath always wins. Tags New/Persisting, resurfaces vanished # alerts as Resolved, persists this run for the next diff. $historyEnabled = ($PSBoundParameters.ContainsKey('HistoryPath') -or (-not $NoHistory)) if ($historyEnabled) { $historyScope = $env:COMPUTERNAME if (@($ComputerName).Count -gt 0) { $historyScope = ((@($ComputerName) | Sort-Object) -join ',') } try { $tracked = @(Update-FslRunHistory -Finding $findings.ToArray() -HistoryPath $HistoryPath -Scope $historyScope) $findings = New-Object System.Collections.Generic.List[object] foreach ($trackedFinding in $tracked) { $findings.Add($trackedFinding) } } catch { Write-Warning ("Run history could not be updated ({0}) - continuing without a diff." -f $_.Exception.Message) } } # Output: report, summary/JSON and/or pipeline. $severityOrder = @{ 'Critical' = 0; 'Warning' = 1; 'Info' = 2; 'Pass' = 3 } $sorted = @($findings | Sort-Object -Property @{ Expression = { $severityOrder[$_.Severity] } }, Category, Check) # Report: on by default (opt out with -NoReport). An explicit -ReportPath # keeps the classic behavior (returns the FileInfo); the automatic report # goes to %ProgramData%\FSLogixDoctor\Reports and the findings stay the # pipeline output - the path is carried in the summary and verbose stream. $reportEnabled = ($PSBoundParameters.ContainsKey('ReportPath') -or (-not $NoReport)) $reportWritten = '' if ($reportEnabled) { $autoReport = (-not $PSBoundParameters.ContainsKey('ReportPath')) $effectiveReportPath = $ReportPath try { if ($autoReport) { $reportDir = Join-Path $env:ProgramData 'FSLogixDoctor\Reports' if (-not (Test-Path -LiteralPath $reportDir)) { New-Item -ItemType Directory -Path $reportDir -Force | Out-Null } $effectiveReportPath = Join-Path $reportDir ('fslogix-report-{0}.html' -f (Get-Date -Format 'yyyyMMdd-HHmmss')) } $report = $sorted | New-FslReport -Path $effectiveReportPath -LookbackHours $Hours $reportWritten = [string]$report.FullName Write-Verbose ("Report written to {0}" -f $reportWritten) if (-not $autoReport -and -not $PassThru -and -not $AsSummary -and -not $AsJson) { return $report } } catch { Write-Warning ("Report could not be written ({0})." -f $_.Exception.Message) } } if ($AsSummary -or $AsJson) { $counts = @{} foreach ($severityName in @('Critical', 'Warning', 'Info', 'Pass')) { $counts[$severityName] = @($sorted | Where-Object Severity -eq $severityName).Count } # Worst severity maps to a monitoring-friendly exit code: # 0 = healthy (Pass/Info only), 1 = warnings, 2 = critical. $worst = 'None' $exitCode = 0 if ($counts['Critical'] -gt 0) { $worst = 'Critical'; $exitCode = 2 } elseif ($counts['Warning'] -gt 0) { $worst = 'Warning'; $exitCode = 1 } elseif ($counts['Info'] -gt 0) { $worst = 'Info' } elseif ($counts['Pass'] -gt 0) { $worst = 'Pass' } $summaryObject = [pscustomobject]@{ PSTypeName = 'FSLogixDoctor.Summary' Target = ((@($sorted | ForEach-Object { [string]$_.Target } | Select-Object -Unique | Sort-Object)) -join ', ') GeneratedAt = Get-Date CriticalCount = $counts['Critical'] WarningCount = $counts['Warning'] InfoCount = $counts['Info'] PassCount = $counts['Pass'] WorstSeverity = $worst ExitCode = $exitCode NewCount = @($sorted | Where-Object { $null -ne $_.PSObject.Properties['ChangeStatus'] -and $_.ChangeStatus -eq 'New' -and $_.Severity -in @('Critical', 'Warning') }).Count ResolvedCount = @($sorted | Where-Object { $null -ne $_.PSObject.Properties['ChangeStatus'] -and $_.ChangeStatus -eq 'Resolved' }).Count ReportPath = $reportWritten Findings = $sorted } if ($AsJson) { return ($summaryObject | ConvertTo-Json -Depth 5) } return $summaryObject } $sorted } |