<# .SYNOPSIS Remote maintenance toolkit for shopfloor PCs via WinRM. .DESCRIPTION Executes maintenance tasks on remote shopfloor PCs using WinRM. Supports system repair, disk optimization, cleanup, and reboots. Can target PCs by name, file list, PC type, business unit, or all. .PARAMETER ComputerName Single computer name, IP address, or array of computers to target. .PARAMETER ComputerListFile Path to text file containing computer names/IPs (one per line). .PARAMETER All Target all shopfloor PCs from ShopDB database. .PARAMETER PcType Target PCs by type (e.g., Dashboard, Lobby Display, CMM, Shopfloor). Valid values: Standard, Engineer, Shopfloor, CMM, Wax / Trace, Keyence, Genspect, Heat Treat, Inspection, Dashboard, Lobby Display, Uncategorized .PARAMETER BusinessUnit Target PCs by business unit (e.g., Blisk, HPT, Spools). Valid values: TBD, Blisk, HPT, Spools, Inspection, Venture, Turn/Burn, DT .PARAMETER Task Maintenance task to execute. Available tasks: REPAIR: - DISM : Run DISM /Online /Cleanup-Image /RestoreHealth - SFC : Run SFC /scannow (System File Checker) OPTIMIZATION: - OptimizeDisk : TRIM for SSD, Defrag for HDD - DiskCleanup : Windows Disk Cleanup (temp files, updates) - ClearUpdateCache : Clear Windows Update cache (fixes stuck updates) - ClearBrowserCache: Clear Chrome/Edge cache files SERVICES: - RestartSpooler : Restart Print Spooler service - FlushDNS : Clear DNS resolver cache - RestartWinRM : Restart WinRM service TIME/DATE: - SetTimezone : Set timezone to Eastern Standard Time - SyncTime : Force time sync with domain controller DNC: - UpdateEMxAuthToken : Update eMx auth token (eMxInfo.txt) from network share (backs up old file first) - DeployUDCWebServerConfig : Deploy UDC web server settings to PCs with UDC installed SYSTEM: - Reboot : Restart the computer (30 second delay) SOFTWARE DEPLOYMENT: - InstallDashboard : Install GE Aerospace Dashboard kiosk app - InstallLobbyDisplay : Install GE Aerospace Lobby Display kiosk app - UninstallDashboard : Uninstall GE Aerospace Dashboard - UninstallLobbyDisplay : Uninstall GE Aerospace Lobby Display .PARAMETER Credential PSCredential for remote authentication. Prompts if not provided. .PARAMETER ApiUrl ShopDB API URL for database updates. .PARAMETER ThrottleLimit Maximum concurrent remote sessions (default: 5). .EXAMPLE # Run DISM on a single PC .\Invoke-RemoteMaintenance.ps1 -ComputerName "G1ZTNCX3ESF" -Task DISM .EXAMPLE # Optimize disks on multiple PCs .\Invoke-RemoteMaintenance.ps1 -ComputerName "PC01","PC02" -Task OptimizeDisk .EXAMPLE # Run disk cleanup on all shopfloor PCs .\Invoke-RemoteMaintenance.ps1 -All -Task DiskCleanup .EXAMPLE # Reboot all Dashboard PCs .\Invoke-RemoteMaintenance.ps1 -PcType Dashboard -Task Reboot .EXAMPLE # Reboot all Lobby Display PCs .\Invoke-RemoteMaintenance.ps1 -PcType "Lobby Display" -Task Reboot .EXAMPLE # Flush DNS on all PCs in Blisk business unit .\Invoke-RemoteMaintenance.ps1 -BusinessUnit Blisk -Task FlushDNS .EXAMPLE # Clear browser cache on all CMM PCs .\Invoke-RemoteMaintenance.ps1 -PcType CMM -Task ClearBrowserCache .EXAMPLE # Install Dashboard on specific PCs .\Invoke-RemoteMaintenance.ps1 -ComputerName "PC001","PC002" -Task InstallDashboard .EXAMPLE # Install Lobby Display from a list file .\Invoke-RemoteMaintenance.ps1 -ComputerListFile ".\lobby-pcs.txt" -Task InstallLobbyDisplay .EXAMPLE # Uninstall Dashboard from a PC .\Invoke-RemoteMaintenance.ps1 -ComputerName "PC001" -Task UninstallDashboard .NOTES Author: Shop Floor Tools Date: 2025-12-26 Requires: PowerShell 5.1+, WinRM enabled on targets, Admin credentials #> [CmdletBinding(DefaultParameterSetName='ByName')] param( [Parameter(ParameterSetName='ByName', Position=0)] [string[]]$ComputerName, [Parameter(ParameterSetName='ByFile')] [string]$ComputerListFile, [Parameter(ParameterSetName='All')] [switch]$All, [Parameter(ParameterSetName='ByPcType')] [ValidateSet('Standard', 'Engineer', 'Shopfloor', 'CMM', 'Wax / Trace', 'Keyence', 'Genspect', 'Heat Treat', 'Inspection', 'Dashboard', 'Lobby Display', 'Uncategorized')] [string]$PcType, [Parameter(ParameterSetName='ByBusinessUnit')] [ValidateSet('TBD', 'Blisk', 'HPT', 'Spools', 'Inspection', 'Venture', 'Turn/Burn', 'DT')] [string]$BusinessUnit, [Parameter(Mandatory=$true)] [ValidateSet( 'DISM', 'SFC', 'OptimizeDisk', 'DiskCleanup', 'ClearUpdateCache', 'RestartSpooler', 'FlushDNS', 'ClearBrowserCache', 'RestartWinRM', 'SetTimezone', 'SyncTime', 'UpdateEMxAuthToken', 'DeployUDCWebServerConfig', 'Reboot', 'InstallDashboard', 'InstallLobbyDisplay', 'UninstallDashboard', 'UninstallLobbyDisplay' )] [string]$Task, [Parameter()] [PSCredential]$Credential, [Parameter()] [string]$ApiUrl = "https://tsgwp00525.rd.ds.ge.com/shopdb/api.asp", [Parameter()] [string]$DnsSuffix = "logon.ds.ge.com", [Parameter()] [int]$ThrottleLimit = 5 ) # ============================================================================= # SSL/TLS Configuration # ============================================================================= # Enable all modern TLS versions [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls11 -bor [Net.SecurityProtocolType]::Tls # Bypass SSL certificate validation (for self-signed certs) [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { param($sender, $cert, $chain, $errors) return $true } # ============================================================================= # Helper Functions # ============================================================================= function Write-Log { param([string]$Message, [string]$Level = "INFO") $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $color = switch ($Level) { "ERROR" { "Red" } "WARNING" { "Yellow" } "SUCCESS" { "Green" } "TASK" { "Cyan" } default { "White" } } Write-Host "[$timestamp] [$Level] $Message" -ForegroundColor $color } function Get-ShopfloorPCsFromApi { param( [string]$ApiUrl, [int]$PcTypeId = 0, [int]$BusinessUnitId = 0 ) try { # Force TLS 1.2 immediately before request [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $queryParams = "action=getShopfloorPCs" if ($PcTypeId -gt 0) { $queryParams += "&pctypeid=$PcTypeId" } if ($BusinessUnitId -gt 0) { $queryParams += "&businessunitid=$BusinessUnitId" } $fullUrl = "$ApiUrl`?$queryParams" # Use WebClient - more reliable with TLS in script context $webClient = New-Object System.Net.WebClient $json = $webClient.DownloadString($fullUrl) $response = $json | ConvertFrom-Json if ($response.success -and $response.data) { return $response.data } return @() } catch { Write-Log "Failed to query API: $_" -Level "ERROR" return @() } } # Lookup tables for filtering $PcTypeLookup = @{ 'Standard' = 1; 'Engineer' = 2; 'Shopfloor' = 3; 'Uncategorized' = 4; 'CMM' = 5; 'Wax / Trace' = 6; 'Keyence' = 7; 'Genspect' = 8; 'Heat Treat' = 9; 'Inspection' = 10; 'Dashboard' = 11; 'Lobby Display' = 12 } $BusinessUnitLookup = @{ 'TBD' = 1; 'Blisk' = 2; 'HPT' = 3; 'Spools' = 4; 'Inspection' = 5; 'Venture' = 6; 'Turn/Burn' = 7; 'DT' = 8 } # ============================================================================= # Maintenance Task Scriptblocks # ============================================================================= $TaskScripts = @{ # ------------------------------------------------------------------------- # DISM - Deployment Image Servicing and Management # ------------------------------------------------------------------------- 'DISM' = { $result = @{ Success = $false Task = 'DISM' Hostname = $env:COMPUTERNAME Output = "" Error = $null StartTime = Get-Date Duration = 0 } try { Write-Output "Starting DISM /Online /Cleanup-Image /RestoreHealth..." Write-Output "This may take 10-30 minutes..." $dismResult = & dism.exe /Online /Cleanup-Image /RestoreHealth 2>&1 $result.Output = $dismResult -join "`n" $result.ExitCode = $LASTEXITCODE $result.Success = ($LASTEXITCODE -eq 0) if ($result.Success) { Write-Output "DISM completed successfully." } else { Write-Output "DISM completed with exit code: $LASTEXITCODE" } } catch { $result.Error = $_.Exception.Message } $result.EndTime = Get-Date $result.Duration = [math]::Round(((Get-Date) - $result.StartTime).TotalMinutes, 2) return $result } # ------------------------------------------------------------------------- # SFC - System File Checker # ------------------------------------------------------------------------- 'SFC' = { $result = @{ Success = $false Task = 'SFC' Hostname = $env:COMPUTERNAME Output = "" Error = $null StartTime = Get-Date Duration = 0 } try { Write-Output "Starting SFC /scannow..." Write-Output "This may take 10-20 minutes..." $sfcResult = & sfc.exe /scannow 2>&1 $result.Output = $sfcResult -join "`n" $result.ExitCode = $LASTEXITCODE # SFC exit codes: 0 = no issues, 1 = issues found and fixed $result.Success = ($LASTEXITCODE -eq 0 -or $LASTEXITCODE -eq 1) # Parse output for summary if ($result.Output -match "found corrupt files and successfully repaired") { $result.Summary = "Corrupt files found and repaired" } elseif ($result.Output -match "did not find any integrity violations") { $result.Summary = "No integrity violations found" } elseif ($result.Output -match "found corrupt files but was unable to fix") { $result.Summary = "Corrupt files found but could not be repaired" $result.Success = $false } else { $result.Summary = "Scan completed" } } catch { $result.Error = $_.Exception.Message } $result.EndTime = Get-Date $result.Duration = [math]::Round(((Get-Date) - $result.StartTime).TotalMinutes, 2) return $result } # ------------------------------------------------------------------------- # OptimizeDisk - TRIM for SSD, Defrag for HDD # ------------------------------------------------------------------------- 'OptimizeDisk' = { $result = @{ Success = $false Task = 'OptimizeDisk' Hostname = $env:COMPUTERNAME Output = "" Error = $null Drives = @() } try { # Get all fixed drives $volumes = Get-Volume | Where-Object { $_.DriveType -eq 'Fixed' -and $_.DriveLetter } foreach ($vol in $volumes) { $driveLetter = $vol.DriveLetter $driveResult = @{ DriveLetter = $driveLetter Success = $false MediaType = "Unknown" Action = "" } # Detect if SSD or HDD $physicalDisk = Get-PhysicalDisk | Where-Object { $diskNum = (Get-Partition -DriveLetter $driveLetter -ErrorAction SilentlyContinue).DiskNumber $_.DeviceId -eq $diskNum } if ($physicalDisk) { $driveResult.MediaType = $physicalDisk.MediaType } Write-Output "Optimizing drive ${driveLetter}: ($($driveResult.MediaType))..." try { if ($driveResult.MediaType -eq 'SSD') { # TRIM for SSD Optimize-Volume -DriveLetter $driveLetter -ReTrim -ErrorAction Stop $driveResult.Action = "TRIM" } else { # Defrag for HDD Optimize-Volume -DriveLetter $driveLetter -Defrag -ErrorAction Stop $driveResult.Action = "Defrag" } $driveResult.Success = $true Write-Output " ${driveLetter}: $($driveResult.Action) completed" } catch { $driveResult.Error = $_.Exception.Message Write-Output " ${driveLetter}: Failed - $($_.Exception.Message)" } $result.Drives += $driveResult } $result.Success = ($result.Drives | Where-Object { $_.Success }).Count -gt 0 $result.Output = "Optimized $($result.Drives.Count) drive(s)" } catch { $result.Error = $_.Exception.Message } return $result } # ------------------------------------------------------------------------- # DiskCleanup - Windows Disk Cleanup # ------------------------------------------------------------------------- 'DiskCleanup' = { $result = @{ Success = $false Task = 'DiskCleanup' Hostname = $env:COMPUTERNAME Output = "" Error = $null SpaceFreed = 0 } try { # Get initial free space $initialFree = (Get-PSDrive C).Free Write-Output "Running Disk Cleanup..." # Set cleanup flags in registry for automated cleanup $cleanupPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VolumeCaches" $categories = @( "Temporary Files", "Temporary Setup Files", "Old ChkDsk Files", "Setup Log Files", "Windows Update Cleanup", "Windows Defender", "Thumbnail Cache", "Recycle Bin" ) foreach ($cat in $categories) { $catPath = Join-Path $cleanupPath $cat if (Test-Path $catPath) { Set-ItemProperty -Path $catPath -Name "StateFlags0100" -Value 2 -ErrorAction SilentlyContinue } } # Run cleanmgr with sagerun $cleanupProcess = Start-Process -FilePath "cleanmgr.exe" -ArgumentList "/sagerun:100" -Wait -PassThru -WindowStyle Hidden # Also clear temp folders directly $tempPaths = @( "$env:TEMP", "$env:SystemRoot\Temp", "$env:SystemRoot\Prefetch" ) $filesDeleted = 0 foreach ($path in $tempPaths) { if (Test-Path $path) { $files = Get-ChildItem -Path $path -Recurse -Force -ErrorAction SilentlyContinue foreach ($file in $files) { try { Remove-Item $file.FullName -Force -Recurse -ErrorAction SilentlyContinue $filesDeleted++ } catch { } } } } # Calculate space freed Start-Sleep -Seconds 2 $finalFree = (Get-PSDrive C).Free $result.SpaceFreed = [math]::Round(($finalFree - $initialFree) / 1GB, 2) $result.Success = $true $result.Output = "Cleanup completed. Space freed: $($result.SpaceFreed) GB. Temp files deleted: $filesDeleted" Write-Output $result.Output } catch { $result.Error = $_.Exception.Message } return $result } # ------------------------------------------------------------------------- # ClearUpdateCache - Clear Windows Update cache # ------------------------------------------------------------------------- 'ClearUpdateCache' = { $result = @{ Success = $false Task = 'ClearUpdateCache' Hostname = $env:COMPUTERNAME Output = "" Error = $null } try { Write-Output "Stopping Windows Update service..." Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue Stop-Service -Name bits -Force -ErrorAction SilentlyContinue Start-Sleep -Seconds 2 Write-Output "Clearing SoftwareDistribution folder..." $swDistPath = "$env:SystemRoot\SoftwareDistribution" if (Test-Path $swDistPath) { Remove-Item "$swDistPath\*" -Recurse -Force -ErrorAction SilentlyContinue } Write-Output "Clearing catroot2 folder..." $catroot2Path = "$env:SystemRoot\System32\catroot2" if (Test-Path $catroot2Path) { Remove-Item "$catroot2Path\*" -Recurse -Force -ErrorAction SilentlyContinue } Write-Output "Starting Windows Update service..." Start-Service -Name wuauserv -ErrorAction SilentlyContinue Start-Service -Name bits -ErrorAction SilentlyContinue $result.Success = $true $result.Output = "Windows Update cache cleared successfully" Write-Output $result.Output } catch { $result.Error = $_.Exception.Message } return $result } # ------------------------------------------------------------------------- # RestartSpooler - Restart Print Spooler service # ------------------------------------------------------------------------- 'RestartSpooler' = { $result = @{ Success = $false Task = 'RestartSpooler' Hostname = $env:COMPUTERNAME Output = "" Error = $null } try { Write-Output "Stopping Print Spooler..." Stop-Service -Name Spooler -Force -ErrorAction Stop # Clear print queue $printQueuePath = "$env:SystemRoot\System32\spool\PRINTERS" if (Test-Path $printQueuePath) { Remove-Item "$printQueuePath\*" -Force -ErrorAction SilentlyContinue } Write-Output "Starting Print Spooler..." Start-Service -Name Spooler -ErrorAction Stop $spoolerStatus = (Get-Service -Name Spooler).Status $result.Success = ($spoolerStatus -eq 'Running') $result.Output = "Print Spooler restarted. Status: $spoolerStatus" Write-Output $result.Output } catch { $result.Error = $_.Exception.Message } return $result } # ------------------------------------------------------------------------- # FlushDNS - Clear DNS resolver cache # ------------------------------------------------------------------------- 'FlushDNS' = { $result = @{ Success = $false Task = 'FlushDNS' Hostname = $env:COMPUTERNAME Output = "" Error = $null } try { Write-Output "Flushing DNS cache..." $flushResult = & ipconfig /flushdns 2>&1 $result.Output = $flushResult -join "`n" $result.Success = ($LASTEXITCODE -eq 0) Write-Output "DNS cache flushed successfully" } catch { $result.Error = $_.Exception.Message } return $result } # ------------------------------------------------------------------------- # ClearBrowserCache - Clear Chrome/Edge cache # ------------------------------------------------------------------------- 'ClearBrowserCache' = { $result = @{ Success = $false Task = 'ClearBrowserCache' Hostname = $env:COMPUTERNAME Output = "" Error = $null BrowsersCleared = @() } try { # Get all user profiles $userProfiles = Get-ChildItem "C:\Users" -Directory | Where-Object { $_.Name -notin @('Public', 'Default', 'Default User') } foreach ($profile in $userProfiles) { $userName = $profile.Name # Chrome cache paths $chromeCachePaths = @( "$($profile.FullName)\AppData\Local\Google\Chrome\User Data\Default\Cache", "$($profile.FullName)\AppData\Local\Google\Chrome\User Data\Default\Code Cache" ) # Edge cache paths $edgeCachePaths = @( "$($profile.FullName)\AppData\Local\Microsoft\Edge\User Data\Default\Cache", "$($profile.FullName)\AppData\Local\Microsoft\Edge\User Data\Default\Code Cache" ) $allPaths = $chromeCachePaths + $edgeCachePaths foreach ($cachePath in $allPaths) { if (Test-Path $cachePath) { try { Remove-Item "$cachePath\*" -Recurse -Force -ErrorAction SilentlyContinue $browserType = if ($cachePath -like "*Chrome*") { "Chrome" } else { "Edge" } $result.BrowsersCleared += "$userName-$browserType" } catch { } } } } $result.Success = $true $result.Output = "Cleared cache for: $($result.BrowsersCleared -join ', ')" Write-Output $result.Output } catch { $result.Error = $_.Exception.Message } return $result } # ------------------------------------------------------------------------- # RestartWinRM - Restart WinRM service # ------------------------------------------------------------------------- 'RestartWinRM' = { $result = @{ Success = $false Task = 'RestartWinRM' Hostname = $env:COMPUTERNAME Output = "" Error = $null } try { Write-Output "Restarting WinRM service..." Restart-Service -Name WinRM -Force -ErrorAction Stop Start-Sleep -Seconds 2 $winrmStatus = (Get-Service -Name WinRM).Status $result.Success = ($winrmStatus -eq 'Running') $result.Output = "WinRM service restarted. Status: $winrmStatus" Write-Output $result.Output } catch { $result.Error = $_.Exception.Message } return $result } # ------------------------------------------------------------------------- # SetTimezone - Set timezone to Eastern Standard Time # ------------------------------------------------------------------------- 'SetTimezone' = { $result = @{ Success = $false Task = 'SetTimezone' Hostname = $env:COMPUTERNAME Output = "" Error = $null PreviousTimezone = "" NewTimezone = "" } try { # Get current timezone $currentTz = Get-TimeZone $result.PreviousTimezone = $currentTz.Id $targetTz = "Eastern Standard Time" if ($currentTz.Id -eq $targetTz) { $result.Success = $true $result.NewTimezone = $currentTz.Id $result.Output = "Timezone already set to $targetTz" Write-Output $result.Output } else { Write-Output "Changing timezone from $($currentTz.Id) to $targetTz..." Set-TimeZone -Id $targetTz -ErrorAction Stop # Verify change $newTz = Get-TimeZone $result.NewTimezone = $newTz.Id $result.Success = ($newTz.Id -eq $targetTz) $result.Output = "Timezone changed: $($currentTz.Id) -> $($newTz.Id)" Write-Output $result.Output } } catch { $result.Error = $_.Exception.Message } return $result } # ------------------------------------------------------------------------- # SyncTime - Sync time with domain controller # ------------------------------------------------------------------------- 'SyncTime' = { $result = @{ Success = $false Task = 'SyncTime' Hostname = $env:COMPUTERNAME Output = "" Error = $null TimeBefore = "" TimeAfter = "" TimeSource = "" } try { $result.TimeBefore = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") Write-Output "Syncing time with domain controller..." # Get current time source $w32tmSource = & w32tm /query /source 2>&1 $result.TimeSource = ($w32tmSource -join " ").Trim() # Force time resync $resyncResult = & w32tm /resync /force 2>&1 $resyncOutput = $resyncResult -join "`n" # Check if successful if ($resyncOutput -match "The command completed successfully" -or $LASTEXITCODE -eq 0) { Start-Sleep -Seconds 1 $result.TimeAfter = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") $result.Success = $true $result.Output = "Time synced with $($result.TimeSource). Time: $($result.TimeAfter)" } else { $result.Output = "Sync attempted. Result: $resyncOutput" $result.Success = $false } Write-Output $result.Output } catch { $result.Error = $_.Exception.Message } return $result } # ------------------------------------------------------------------------- # UpdateEMxAuthToken - Backup and prepare for file copy (runs on remote PC) # The actual file is pushed via Copy-Item -ToSession from the caller # ------------------------------------------------------------------------- 'UpdateEMxAuthToken' = { param($SourceFileContent) $result = @{ Success = $false Task = 'UpdateEMxAuthToken' Hostname = $env:COMPUTERNAME Output = "" Error = $null FailReason = "" PathsUpdated = @() PathsFailed = @() BackupsCreated = @() TempFile = "" DNCKilled = $false DNCRestarted = $false } $destFile = "eMxInfo.txt" $tempPath = "C:\Windows\Temp\eMxInfo.txt" # Check both possible DNC installation paths $destDirs = @( "C:\Program Files (x86)\DNC\Server Files", "C:\Program Files\DNC\Server Files" ) try { # Check if temp file was pushed if (-not (Test-Path $tempPath)) { $result.FailReason = "Source file not found at $tempPath - file push may have failed" $result.Error = $result.FailReason Write-Output $result.FailReason return $result } # Track which paths exist $validPaths = @() foreach ($destDir in $destDirs) { if (Test-Path $destDir) { $validPaths += $destDir } } if ($validPaths.Count -eq 0) { $result.FailReason = "No DNC Server Files directory found in Program Files or Program Files (x86)" $result.Error = $result.FailReason Write-Output $result.FailReason # Clean up temp file Remove-Item $tempPath -Force -ErrorAction SilentlyContinue return $result } Write-Output "Found $($validPaths.Count) DNC installation(s)" # Kill DNCMain.exe before copying Write-Output "Stopping DNCMain.exe..." $dncProcess = Get-Process -Name "DNCMain" -ErrorAction SilentlyContinue if ($dncProcess) { try { taskkill /IM DNCMain.exe /F 2>&1 | Out-Null Start-Sleep -Seconds 2 $result.DNCKilled = $true Write-Output " DNCMain.exe stopped" } catch { Write-Output " Warning: Could not stop DNCMain.exe - $($_.Exception.Message)" } } else { Write-Output " DNCMain.exe not running" } # Process each valid path foreach ($destDir in $validPaths) { $destPath = Join-Path $destDir $destFile $pathLabel = if ($destDir -like "*x86*") { "x86" } else { "x64" } Write-Output "Processing $pathLabel path: $destDir" # Check if destination file exists and back it up if (Test-Path $destPath) { $dateStamp = Get-Date -Format "yyyyMMdd" $backupName = "eMxInfo-old-$dateStamp.txt" $backupPath = Join-Path $destDir $backupName Write-Output " File exists, renaming to $backupName..." try { # Remove existing backup if same date if (Test-Path $backupPath) { Remove-Item $backupPath -Force -ErrorAction Stop } Rename-Item -Path $destPath -NewName $backupName -Force -ErrorAction Stop $result.BackupsCreated += "$pathLabel`:$backupName" } catch { $result.PathsFailed += "$pathLabel`: Failed to rename - $($_.Exception.Message)" Write-Output " FAILED to rename: $($_.Exception.Message)" continue } } else { Write-Output " File does not exist, creating new..." } # Copy from temp location to destination try { Copy-Item -Path $tempPath -Destination $destPath -Force -ErrorAction Stop # Verify the copy if (Test-Path $destPath) { $result.PathsUpdated += $pathLabel Write-Output " SUCCESS" } else { $result.PathsFailed += "$pathLabel`: Copy succeeded but file not found" Write-Output " FAILED: File not found after copy" } } catch { $result.PathsFailed += "$pathLabel`: Failed to copy - $($_.Exception.Message)" Write-Output " FAILED to copy: $($_.Exception.Message)" } } # Clean up temp file Remove-Item $tempPath -Force -ErrorAction SilentlyContinue # Determine overall success if ($result.PathsUpdated.Count -gt 0) { $result.Success = $true $result.Output = "Updated: $($result.PathsUpdated -join ', ')" if ($result.BackupsCreated.Count -gt 0) { $result.Output += " | Backups: $($result.BackupsCreated -join ', ')" } if ($result.PathsFailed.Count -gt 0) { $result.Output += " | Failed: $($result.PathsFailed.Count)" } # Restart DNC - find LDnc.exe in one of the valid paths Write-Output "Starting LDnc.exe..." $ldncStarted = $false foreach ($destDir in $validPaths) { $ldncPath = Join-Path $destDir "LDnc.exe" if (Test-Path $ldncPath) { try { Start-Process -FilePath $ldncPath -ErrorAction Stop $result.DNCRestarted = $true $ldncStarted = $true Write-Output " LDnc.exe started from $destDir" break } catch { Write-Output " Warning: Could not start LDnc.exe from $destDir - $($_.Exception.Message)" } } } if (-not $ldncStarted) { Write-Output " Warning: LDnc.exe not found or could not be started" } $result.Output += " | DNC restarted: $($result.DNCRestarted)" } else { $result.FailReason = "All paths failed: $($result.PathsFailed -join '; ')" $result.Error = $result.FailReason } } catch { $result.FailReason = "Unexpected error: $($_.Exception.Message)" $result.Error = $result.FailReason Write-Output $result.FailReason } return $result } # ------------------------------------------------------------------------- # DeployUDCWebServerConfig - Deploy udc_webserver_settings.json to UDC PCs # The actual file is pushed via Copy-Item -ToSession from the caller # ------------------------------------------------------------------------- 'DeployUDCWebServerConfig' = { $result = @{ Success = $false Task = 'DeployUDCWebServerConfig' Hostname = $env:COMPUTERNAME Output = "" Error = $null FailReason = "" UDCInstalled = $false BackupCreated = $false } $udcInstallDir = "C:\Program Files\UDC" $destDir = "C:\ProgramData\UDC" $destFile = "udc_webserver_settings.json" $destPath = Join-Path $destDir $destFile $tempPath = "C:\Windows\Temp\udc_webserver_settings.json" try { # Check if UDC is installed if (-not (Test-Path $udcInstallDir)) { $result.FailReason = "UDC not installed ($udcInstallDir not found) - skipping" $result.Output = $result.FailReason Write-Output $result.FailReason return $result } $result.UDCInstalled = $true Write-Output "UDC installation found at $udcInstallDir" # Check if temp file was pushed if (-not (Test-Path $tempPath)) { $result.FailReason = "Source file not found at $tempPath - file push may have failed" $result.Error = $result.FailReason Write-Output $result.FailReason return $result } # Create destination directory if it doesn't exist if (-not (Test-Path $destDir)) { New-Item -Path $destDir -ItemType Directory -Force | Out-Null Write-Output "Created directory: $destDir" } # Backup existing config if present if (Test-Path $destPath) { $dateStamp = Get-Date -Format "yyyyMMdd" $backupName = "udc_webserver_settings-old-$dateStamp.json" $backupPath = Join-Path $destDir $backupName Write-Output "Existing config found, backing up to $backupName..." try { if (Test-Path $backupPath) { Remove-Item $backupPath -Force -ErrorAction Stop } Rename-Item -Path $destPath -NewName $backupName -Force -ErrorAction Stop $result.BackupCreated = $true } catch { Write-Output "Warning: Could not backup existing config - $($_.Exception.Message)" } } # Copy from temp location to destination Copy-Item -Path $tempPath -Destination $destPath -Force -ErrorAction Stop # Verify the copy if (Test-Path $destPath) { $result.Success = $true $result.Output = "Config deployed to $destPath" if ($result.BackupCreated) { $result.Output += " (backup created)" } Write-Output "SUCCESS: $($result.Output)" } else { $result.FailReason = "Copy succeeded but file not found at destination" $result.Error = $result.FailReason Write-Output "FAILED: $($result.FailReason)" } # Clean up temp file Remove-Item $tempPath -Force -ErrorAction SilentlyContinue } catch { $result.FailReason = "Unexpected error: $($_.Exception.Message)" $result.Error = $result.FailReason Write-Output $result.FailReason } return $result } # ------------------------------------------------------------------------- # InstallDashboard / InstallLobbyDisplay - Install kiosk app # ------------------------------------------------------------------------- 'InstallKioskApp' = { param($InstallerPath, $AppName) $result = @{ Success = $false Task = 'InstallKioskApp' Hostname = $env:COMPUTERNAME Output = "" Error = $null AppName = $AppName } try { if (-not (Test-Path $InstallerPath)) { $result.Error = "Installer not found at $InstallerPath" Write-Output $result.Error return $result } Write-Output "Installing $AppName..." $proc = Start-Process -FilePath $InstallerPath -ArgumentList "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" -Wait -PassThru -WindowStyle Hidden # Clean up installer Remove-Item $InstallerPath -Force -ErrorAction SilentlyContinue if ($proc.ExitCode -eq 0) { $result.Success = $true $result.Output = "$AppName installed successfully" } else { $result.Error = "Installer exited with code $($proc.ExitCode)" } Write-Output $result.Output } catch { $result.Error = $_.Exception.Message Write-Output "Error: $($result.Error)" } return $result } # ------------------------------------------------------------------------- # UninstallDashboard / UninstallLobbyDisplay - Uninstall kiosk app # ------------------------------------------------------------------------- 'UninstallKioskApp' = { param($UninstallGuid, $AppName) $result = @{ Success = $false Task = 'UninstallKioskApp' Hostname = $env:COMPUTERNAME Output = "" Error = $null AppName = $AppName } try { Write-Output "Uninstalling $AppName..." # Find uninstaller in registry $uninstallPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$UninstallGuid`_is1" if (-not (Test-Path $uninstallPath)) { $uninstallPath = "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\$UninstallGuid`_is1" } if (Test-Path $uninstallPath) { $uninstallString = (Get-ItemProperty $uninstallPath).UninstallString if ($uninstallString) { $uninstallExe = $uninstallString -replace '"', '' $proc = Start-Process -FilePath $uninstallExe -ArgumentList "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" -Wait -PassThru -WindowStyle Hidden if ($proc.ExitCode -eq 0) { $result.Success = $true $result.Output = "$AppName uninstalled successfully" } else { $result.Error = "Uninstaller exited with code $($proc.ExitCode)" } } else { $result.Error = "No uninstall string found in registry" } } else { $result.Error = "$AppName not found in registry (may not be installed)" } Write-Output $(if ($result.Success) { $result.Output } else { $result.Error }) } catch { $result.Error = $_.Exception.Message Write-Output "Error: $($result.Error)" } return $result } # ------------------------------------------------------------------------- # Reboot - Restart the computer # ------------------------------------------------------------------------- 'Reboot' = { $result = @{ Success = $false Task = 'Reboot' Hostname = $env:COMPUTERNAME Output = "" Error = $null } try { Write-Output "Initiating system restart..." # Use shutdown command with 30 second delay to allow WinRM to return $shutdownResult = & shutdown.exe /r /t 30 /c "Remote maintenance reboot initiated" 2>&1 $exitCode = $LASTEXITCODE if ($exitCode -eq 0) { $result.Success = $true $result.Output = "Reboot scheduled in 30 seconds" Write-Output $result.Output } else { $result.Error = "Shutdown command failed with exit code $exitCode : $shutdownResult" Write-Output $result.Error } } catch { $result.Error = $_.Exception.Message Write-Output "Error: $($result.Error)" } return $result } } # ============================================================================= # Main Execution # ============================================================================= Write-Host "" Write-Host "=" * 70 -ForegroundColor Cyan Write-Host " Remote Maintenance Tool - Task: $Task" -ForegroundColor Cyan Write-Host "=" * 70 -ForegroundColor Cyan Write-Host "" # Get credentials if (-not $Credential) { Write-Log "Enter credentials for remote PCs:" -Level "INFO" $Credential = Get-Credential -Message "Enter admin credentials for remote PCs" if (-not $Credential) { Write-Log "Credentials required. Exiting." -Level "ERROR" exit 1 } } # Build computer list $computers = @() if ($All) { Write-Log "Querying ShopDB for all shopfloor PCs..." -Level "INFO" $shopfloorPCs = Get-ShopfloorPCsFromApi -ApiUrl $ApiUrl $computers = $shopfloorPCs | ForEach-Object { $_.hostname } | Where-Object { $_ } Write-Log "Found $($computers.Count) shopfloor PCs" -Level "INFO" } elseif ($PcType) { $pcTypeId = $PcTypeLookup[$PcType] Write-Log "Querying ShopDB for PCs of type '$PcType' (ID: $pcTypeId)..." -Level "INFO" $shopfloorPCs = Get-ShopfloorPCsFromApi -ApiUrl $ApiUrl -PcTypeId $pcTypeId $computers = $shopfloorPCs | ForEach-Object { $_.hostname } | Where-Object { $_ } Write-Log "Found $($computers.Count) PCs of type '$PcType'" -Level "INFO" } elseif ($BusinessUnit) { $businessUnitId = $BusinessUnitLookup[$BusinessUnit] Write-Log "Querying ShopDB for PCs in business unit '$BusinessUnit' (ID: $businessUnitId)..." -Level "INFO" $shopfloorPCs = Get-ShopfloorPCsFromApi -ApiUrl $ApiUrl -BusinessUnitId $businessUnitId $computers = $shopfloorPCs | ForEach-Object { $_.hostname } | Where-Object { $_ } Write-Log "Found $($computers.Count) PCs in business unit '$BusinessUnit'" -Level "INFO" } elseif ($ComputerListFile) { if (Test-Path $ComputerListFile) { $computers = Get-Content $ComputerListFile | Where-Object { $_.Trim() -and -not $_.StartsWith("#") } } else { Write-Log "Computer list file not found: $ComputerListFile" -Level "ERROR" exit 1 } } elseif ($ComputerName) { $computers = $ComputerName } else { Write-Log "No computers specified. Use -ComputerName, -ComputerListFile, -All, -PcType, or -BusinessUnit" -Level "ERROR" exit 1 } if ($computers.Count -eq 0) { Write-Log "No computers to process." -Level "ERROR" exit 1 } Write-Log "Target computers: $($computers.Count)" -Level "INFO" Write-Log "Task: $Task" -Level "TASK" Write-Host "" # Build FQDNs $targetFQDNs = $computers | ForEach-Object { if ($_ -like "*.*") { $_ } else { "$_.$DnsSuffix" } } # Determine which tasks to run $tasksToRun = @($Task) # Create session options $sessionOption = New-PSSessionOption -OpenTimeout 30000 -OperationTimeout 600000 -NoMachineProfile # Special handling for UpdateEMxAuthToken - requires pushing file first if ($Task -eq 'UpdateEMxAuthToken') { $sourcePath = "\\tsgwp00525.wjs.geaerospace.net\dt\shopfloor\scripts\eMx\eMxInfo.txt" $remoteTempPath = "C:\Windows\Temp\eMxInfo.txt" Write-Log "UpdateEMxAuthToken: Checking source file..." -Level "INFO" if (-not (Test-Path $sourcePath)) { Write-Log "Source file not found: $sourcePath" -Level "ERROR" exit 1 } Write-Log "Source file found. Will push to each PC before executing." -Level "INFO" $successCount = 0 $failCount = 0 foreach ($fqdn in $targetFQDNs) { Write-Host "" Write-Log "Processing: $fqdn" -Level "TASK" try { # Create session $session = New-PSSession -ComputerName $fqdn -Credential $Credential -SessionOption $sessionOption -Authentication Negotiate -ErrorAction Stop # Push the file to remote temp location Write-Log " Pushing file to remote PC..." -Level "INFO" Copy-Item -Path $sourcePath -Destination $remoteTempPath -ToSession $session -Force -ErrorAction Stop # Execute the scriptblock Write-Log " Executing update task..." -Level "INFO" $result = Invoke-Command -Session $session -ScriptBlock $TaskScripts['UpdateEMxAuthToken'] -ErrorAction Stop # Close session Remove-PSSession $session -ErrorAction SilentlyContinue # Process result if ($result.Success) { Write-Log "[OK] $($result.Hostname)" -Level "SUCCESS" Write-Host " $($result.Output)" -ForegroundColor Gray if ($result.PathsFailed.Count -gt 0) { foreach ($fail in $result.PathsFailed) { Write-Host " [!] $fail" -ForegroundColor Yellow } } $successCount++ } else { $errorMsg = if ($result.FailReason) { $result.FailReason } else { $result.Error } Write-Log "[FAIL] $($result.Hostname): $errorMsg" -Level "ERROR" $failCount++ } } catch { Write-Log "[FAIL] ${fqdn}: $($_.Exception.Message)" -Level "ERROR" $failCount++ # Clean up session if it exists if ($session) { Remove-PSSession $session -ErrorAction SilentlyContinue } } } # Summary Write-Host "" Write-Host ("=" * 70) -ForegroundColor Cyan Write-Host " SUMMARY" -ForegroundColor Cyan Write-Host ("=" * 70) -ForegroundColor Cyan Write-Host " Task: $Task" -ForegroundColor White Write-Host " Successful: $successCount" -ForegroundColor Green Write-Host " Failed: $failCount" -ForegroundColor $(if ($failCount -gt 0) { "Red" } else { "White" }) Write-Host ("=" * 70) -ForegroundColor Cyan exit 0 } # Special handling for DeployUDCWebServerConfig - check for UDC installation, then push config file if ($Task -eq 'DeployUDCWebServerConfig') { $sourcePath = "\\tsgwp00525.wjs.geaerospace.net\dt\shopfloor\scripts\UDC\udc_webserver_settings.json" $remoteTempPath = "C:\Windows\Temp\udc_webserver_settings.json" Write-Log "DeployUDCWebServerConfig: Checking source file..." -Level "INFO" if (-not (Test-Path $sourcePath)) { Write-Log "Source file not found: $sourcePath" -Level "ERROR" exit 1 } Write-Log "Source file found: $sourcePath" -Level "INFO" Write-Log "Will check each PC for UDC installation before deploying." -Level "INFO" $successCount = 0 $failCount = 0 $skippedCount = 0 foreach ($fqdn in $targetFQDNs) { Write-Host "" Write-Log "Processing: $fqdn" -Level "TASK" try { # Create session $session = New-PSSession -ComputerName $fqdn -Credential $Credential -SessionOption $sessionOption -Authentication Negotiate -ErrorAction Stop # Check if UDC is installed before pushing the file $udcInstalled = Invoke-Command -Session $session -ScriptBlock { Test-Path "C:\Program Files\UDC" } -ErrorAction Stop if (-not $udcInstalled) { Write-Log "[SKIP] $fqdn - UDC not installed" -Level "INFO" Remove-PSSession $session -ErrorAction SilentlyContinue $skippedCount++ continue } # Push the config file to remote temp location Write-Log " UDC installed - pushing config file..." -Level "INFO" Copy-Item -Path $sourcePath -Destination $remoteTempPath -ToSession $session -Force -ErrorAction Stop # Execute the scriptblock Write-Log " Executing deploy task..." -Level "INFO" $result = Invoke-Command -Session $session -ScriptBlock $TaskScripts['DeployUDCWebServerConfig'] -ErrorAction Stop # Close session Remove-PSSession $session -ErrorAction SilentlyContinue # Process result if ($result.Success) { Write-Log "[OK] $($result.Hostname): $($result.Output)" -Level "SUCCESS" $successCount++ } else { $errorMsg = if ($result.FailReason) { $result.FailReason } else { $result.Error } Write-Log "[FAIL] $($result.Hostname): $errorMsg" -Level "ERROR" $failCount++ } } catch { Write-Log "[FAIL] ${fqdn}: $($_.Exception.Message)" -Level "ERROR" $failCount++ if ($session) { Remove-PSSession $session -ErrorAction SilentlyContinue } } } # Summary Write-Host "" Write-Host ("=" * 70) -ForegroundColor Cyan Write-Host " SUMMARY" -ForegroundColor Cyan Write-Host ("=" * 70) -ForegroundColor Cyan Write-Host " Task: $Task" -ForegroundColor White Write-Host " Successful: $successCount" -ForegroundColor Green Write-Host " Skipped: $skippedCount (UDC not installed)" -ForegroundColor Yellow Write-Host " Failed: $failCount" -ForegroundColor $(if ($failCount -gt 0) { "Red" } else { "White" }) Write-Host ("=" * 70) -ForegroundColor Cyan exit 0 } # Special handling for Install/Uninstall kiosk apps $KioskAppConfig = @{ 'InstallDashboard' = @{ Action = 'Install' InstallerPath = '\\tsgwp00525.wjs.geaerospace.net\dt\shopfloor\scripts\Dashboard\GEAerospaceDashboardSetup.exe' InstallerName = 'GEAerospaceDashboardSetup.exe' AppName = 'GE Aerospace Dashboard' UninstallGuid = '{9D9EEE25-4D24-422D-98AF-2ADEDA4745ED}' } 'InstallLobbyDisplay' = @{ Action = 'Install' InstallerPath = '\\tsgwp00525.wjs.geaerospace.net\dt\shopfloor\scripts\LobbyDisplay\GEAerospaceLobbyDisplaySetup.exe' InstallerName = 'GEAerospaceLobbyDisplaySetup.exe' AppName = 'GE Aerospace Lobby Display' UninstallGuid = '{42FFB952-0B72-493F-8869-D957344CA305}' } 'UninstallDashboard' = @{ Action = 'Uninstall' InstallerName = 'GEAerospaceDashboardSetup.exe' AppName = 'GE Aerospace Dashboard' UninstallGuid = '{9D9EEE25-4D24-422D-98AF-2ADEDA4745ED}' } 'UninstallLobbyDisplay' = @{ Action = 'Uninstall' InstallerName = 'GEAerospaceLobbyDisplaySetup.exe' AppName = 'GE Aerospace Lobby Display' UninstallGuid = '{42FFB952-0B72-493F-8869-D957344CA305}' } } if ($KioskAppConfig.ContainsKey($Task)) { $appConfig = $KioskAppConfig[$Task] if ($appConfig.Action -eq 'Install') { # Find installer from network share $installerPath = $appConfig.InstallerPath if (-not (Test-Path $installerPath)) { Write-Log "Installer not found: $installerPath" -Level "ERROR" Write-Log "Ensure the installer exists on the network share" -Level "ERROR" exit 1 } Write-Log "$($appConfig.Action): $($appConfig.AppName)" -Level "INFO" Write-Log "Installer: $installerPath" -Level "INFO" } else { Write-Log "$($appConfig.Action): $($appConfig.AppName)" -Level "INFO" } $successCount = 0 $failCount = 0 foreach ($fqdn in $targetFQDNs) { Write-Host "" Write-Log "Processing: $fqdn" -Level "TASK" try { $session = New-PSSession -ComputerName $fqdn -Credential $Credential -SessionOption $sessionOption -Authentication Negotiate -ErrorAction Stop if ($appConfig.Action -eq 'Install') { $remoteTempPath = "C:\Windows\Temp\$($appConfig.InstallerName)" Write-Log " Pushing installer to remote PC..." -Level "INFO" Copy-Item -Path $installerPath -Destination $remoteTempPath -ToSession $session -Force -ErrorAction Stop Write-Log " Running installer silently..." -Level "INFO" $result = Invoke-Command -Session $session -ScriptBlock $TaskScripts['InstallKioskApp'] -ArgumentList $remoteTempPath, $appConfig.AppName -ErrorAction Stop } else { Write-Log " Running uninstaller..." -Level "INFO" $result = Invoke-Command -Session $session -ScriptBlock $TaskScripts['UninstallKioskApp'] -ArgumentList $appConfig.UninstallGuid, $appConfig.AppName -ErrorAction Stop } Remove-PSSession $session -ErrorAction SilentlyContinue if ($result.Success) { Write-Log "[OK] $($result.Hostname) - $($result.Output)" -Level "SUCCESS" $successCount++ } else { $errorMsg = if ($result.Error) { $result.Error } else { "Unknown error" } Write-Log "[FAIL] $($result.Hostname): $errorMsg" -Level "ERROR" $failCount++ } } catch { Write-Log "[FAIL] ${fqdn}: $($_.Exception.Message)" -Level "ERROR" $failCount++ if ($session) { Remove-PSSession $session -ErrorAction SilentlyContinue } } } # Summary Write-Host "" Write-Host ("=" * 70) -ForegroundColor Cyan Write-Host " SUMMARY" -ForegroundColor Cyan Write-Host ("=" * 70) -ForegroundColor Cyan Write-Host " Task: $Task" -ForegroundColor White Write-Host " App: $($appConfig.AppName)" -ForegroundColor White Write-Host " Successful: $successCount" -ForegroundColor Green Write-Host " Failed: $failCount" -ForegroundColor $(if ($failCount -gt 0) { "Red" } else { "White" }) Write-Host ("=" * 70) -ForegroundColor Cyan exit 0 } # Process each task foreach ($currentTask in $tasksToRun) { if ($tasksToRun.Count -gt 1) { Write-Host "" Write-Log "Running task: $currentTask" -Level "TASK" } $scriptBlock = $TaskScripts[$currentTask] if (-not $scriptBlock) { Write-Log "Unknown task: $currentTask" -Level "ERROR" continue } # Execute on remote computers $sessionParams = @{ ComputerName = $targetFQDNs ScriptBlock = $scriptBlock Credential = $Credential SessionOption = $sessionOption ErrorAction = 'SilentlyContinue' ErrorVariable = 'remoteErrors' } if ($ThrottleLimit -and $PSVersionTable.PSVersion.Major -ge 7) { $sessionParams.ThrottleLimit = $ThrottleLimit } Write-Log "Executing on $($targetFQDNs.Count) computer(s)..." -Level "INFO" $results = Invoke-Command @sessionParams # Process results $successCount = 0 $failCount = 0 foreach ($result in $results) { if ($result.Success) { Write-Log "[OK] $($result.Hostname)" -Level "SUCCESS" # Display task-specific output switch ($currentTask) { 'OptimizeDisk' { foreach ($drive in $result.Drives) { $status = if ($drive.Success) { "OK" } else { "FAIL" } Write-Host " Drive $($drive.DriveLetter): $($drive.MediaType) - $($drive.Action) [$status]" -ForegroundColor Gray } } 'DISM' { Write-Host " Duration: $($result.Duration) minutes, Exit code: $($result.ExitCode)" -ForegroundColor Gray } 'SFC' { Write-Host " $($result.Summary), Duration: $($result.Duration) minutes" -ForegroundColor Gray } 'DiskCleanup' { Write-Host " Space freed: $($result.SpaceFreed) GB" -ForegroundColor Gray } 'UpdateEMxAuthToken' { Write-Host " $($result.Output)" -ForegroundColor Gray if ($result.PathsFailed.Count -gt 0) { foreach ($fail in $result.PathsFailed) { Write-Host " [!] $fail" -ForegroundColor Yellow } } } default { if ($result.Output) { Write-Host " $($result.Output)" -ForegroundColor Gray } } } $successCount++ } else { $errorMsg = if ($result.FailReason) { $result.FailReason } else { $result.Error } Write-Log "[FAIL] $($result.Hostname): $errorMsg" -Level "ERROR" $failCount++ } } # Handle connection errors foreach ($err in $remoteErrors) { $target = if ($err.TargetObject) { $err.TargetObject } else { "Unknown" } Write-Log "[FAIL] ${target}: $($err.Exception.Message)" -Level "ERROR" $failCount++ } } # Summary Write-Host "" Write-Host "=" * 70 -ForegroundColor Cyan Write-Host " SUMMARY" -ForegroundColor Cyan Write-Host "=" * 70 -ForegroundColor Cyan Write-Host " Task: $Task" -ForegroundColor White Write-Host " Successful: $successCount" -ForegroundColor Green Write-Host " Failed: $failCount" -ForegroundColor $(if ($failCount -gt 0) { "Red" } else { "White" }) Write-Host "=" * 70 -ForegroundColor Cyan