Private/Kinds/Printing.ps1
|
# The Printing Kind: the print spooler, the default printer, and what is stuck in between. # # Printing is where "Word is slow" most often turns out not to be Word. Office asks the # default printer for its capabilities when a document opens, so a default printer on a # network that is no longer there - the office printer, from home - holds every document # open for as long as the connection takes to time out. A spooler crashing on a bad driver # takes every printer with it, and one job stuck at the head of a queue blocks the rest. # # Gathered in the Technician's own session, never the Elevated Part: printer connections # and the default printer belong to the user, and an administrator's session would see a # different set. # How often the default printer is connected to. Not a Check Definition parameter, for the # reason Private/Sampling.ps1 gives about measurements: one connect is not evidence, and a # Customer may disagree about a threshold, not about how many attempts make a reading. $script:PrintingConnectAttempts = 3 # Where the print system records what went wrong. Its Admin channel is enabled on every # Windows and readable without admin rights. $script:PrintingLog = 'Microsoft-Windows-PrintService/Admin' # The port a printer shared from a print server is reached through. $script:PrintingServerPort = 445 function Get-PrintingData { [CmdletBinding()] [OutputType([psobject])] param([hashtable]$Parameters = @{}) $days = [int](Get-Parameter $Parameters 'Days' 30) $since = (Get-Date).AddDays(-$days) $spooler = Get-CimInstance Win32_Service -Filter "Name='Spooler'" -ErrorAction SilentlyContinue | Select-Object -First 1 $printers = @(Get-CimInstance Win32_Printer -ErrorAction SilentlyContinue | ForEach-Object { [pscustomobject]@{ Name = "$($_.Name)" Default = [bool]$_.Default Network = [bool]$_.Network WorkOffline = [bool]$_.WorkOffline PrinterStatus = $_.PrinterStatus ExtendedPrinterStatus = $_.ExtendedPrinterStatus DetectedErrorState = $_.DetectedErrorState PortName = "$($_.PortName)" ServerName = "$($_.ServerName)" ShareName = "$($_.ShareName)" DriverName = "$($_.DriverName)" } }) $ports = @(Get-CimInstance Win32_TCPIPPrinterPort -ErrorAction SilentlyContinue | ForEach-Object { [pscustomobject]@{ Name = "$($_.Name)"; HostAddress = "$($_.HostAddress)"; PortNumber = $_.PortNumber } }) $default = $printers | Where-Object { $_.Default } | Select-Object -First 1 $reach = $null if ($default) { $reach = Measure-PrinterReachability -Printer $default -Port $ports } $now = Get-Date $jobs = @(Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue | ForEach-Object { $age = $null if ($_.TimeSubmitted -is [datetime]) { $age = [math]::Round(($now - $_.TimeSubmitted).TotalMinutes, 1) } [pscustomobject]@{ # Win32_PrintJob names a job "<printer>, <id>"; the printer is what matters. Printer = ("$($_.Name)" -replace ',\s*\d+$', '') Document = "$($_.Document)" JobStatus = "$($_.JobStatus)" AgeMinutes = $age } }) $refused = @{} $errors = @() if (Test-EventLogReadable -Log $script:PrintingLog -Unreadable $refused) { try { # Level 1 and 2 are Critical and Error. The Admin channel logs little else, but # the warnings it does log are about drivers being replaced, which is no problem. $errors = @(Get-WinEvent -FilterHashtable @{ LogName = $script:PrintingLog; Level = 1, 2; StartTime = $since } -ErrorAction SilentlyContinue | ForEach-Object { ConvertTo-EventRow -LogEntry $_ -Tag 'Print error' }) } catch { } # "No events matched" arrives as an error too; the log was readable. } $unreadable = $refused.ContainsKey($script:PrintingLog) [pscustomobject]@{ PSTypeName = 'Gutcheck.Data.Printing' Days = $days SpoolerFound = $null -ne $spooler SpoolerState = "$($spooler.State)" SpoolerMode = "$($spooler.StartMode)" Printers = $printers Reachability = $reach Jobs = $jobs Errors = $errors LogUnreadable = $unreadable } } function Measure-PrinterReachability { <# .SYNOPSIS Connects to whatever the default printer is reached through, if anything. .DESCRIPTION A printer shared from a print server is reached through the server, over SMB. A printer with its own TCP/IP port is reached directly, on that port. Anything else - USB, WSD, a PDF writer - has no address this can connect to, and says so rather than reporting a connection nobody made. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)]$Printer, [AllowEmptyCollection()][object[]]$Port = @() ) $hostName = $null $number = $null $via = 'None' if ($Printer.ServerName) { $hostName = $Printer.ServerName.TrimStart('\') $number = $script:PrintingServerPort $via = 'PrintServer' } else { $tcp = @($Port | Where-Object { $_.Name -eq $Printer.PortName }) | Select-Object -First 1 if ($tcp -and $tcp.HostAddress) { $hostName = $tcp.HostAddress $number = [int]$tcp.PortNumber $via = 'TcpPort' } } if (-not $hostName) { return [pscustomobject]@{ Via = $via; HostName = $null; Port = $null; Resolved = $null; Attempts = 0; Successes = 0; AverageMs = $null } } $resolved = $true if ($hostName -notmatch '^\d{1,3}(\.\d{1,3}){3}$') { $resolved = [bool](Resolve-HostAddress -HostName $hostName).Resolved } $times = @() if ($resolved) { $times = @(1..$script:PrintingConnectAttempts | ForEach-Object { Measure-TcpConnect -HostName $hostName -Port $number } | Where-Object { $null -ne $_ }) } [pscustomobject]@{ Via = $via HostName = $hostName Port = $number Resolved = $resolved Attempts = $script:PrintingConnectAttempts Successes = $times.Count AverageMs = $(if ($times.Count) { [math]::Round(($times | Measure-Object -Average).Average, 1) } else { $null }) } } function ConvertTo-PrintingFinding { [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()]$Data, [hashtable]$Parameters = @{} ) if (-not (Get-DataProperty $Data 'SpoolerFound')) { return New-UnavailableFinding -Category System -Check (Get-Text 'Check.Printing.Spooler') ` -Hint (Get-Text 'Hint.Printing.SpoolerNotFound') } if ((Get-DataProperty $Data 'SpoolerState') -ne 'Running') { # Nothing below means anything without the spooler: every printer reads as absent. return New-Finding -Category System -Check (Get-Text 'Check.Printing.Spooler') -Severity FAIL ` -Value ((Get-Text 'Value.Printing.SpoolerState') -f (Get-ServiceStateText -State (Get-DataProperty $Data 'SpoolerState')), (ConvertTo-ServiceStartModeText -StartMode (Get-DataProperty $Data 'SpoolerMode'))) ` -Hint (Get-Text 'Hint.Printing.SpoolerNotRunning') } New-Finding -Category System -Check (Get-Text 'Check.Printing.Spooler') -Severity OK -Value (Get-Text 'Value.Printing.Running') New-DefaultPrinterFinding -Data $Data -Parameters $Parameters New-OfflinePrinterFinding -Data $Data -Parameters $Parameters New-StuckPrintJobFinding -Data $Data -Parameters $Parameters New-PrintErrorFinding -Data $Data -Parameters $Parameters } function Test-PrinterOffline { <# .SYNOPSIS Whether a printer says it cannot print, by any of the three ways Windows says it. .DESCRIPTION PrinterStatus and ExtendedPrinterStatus 7 are Offline; ExtendedPrinterStatus 9 is Error. WorkOffline is the "use printer offline" switch a user or a driver flipped. #> [CmdletBinding()] [OutputType([bool])] param([Parameter(Mandatory)]$Printer) if (Get-DataProperty $Printer 'WorkOffline') { return $true } $status = ConvertTo-Number (Get-DataProperty $Printer 'PrinterStatus') $extended = ConvertTo-Number (Get-DataProperty $Printer 'ExtendedPrinterStatus') ($status -eq 7) -or ($extended -eq 7) -or ($extended -eq 9) } function New-DefaultPrinterFinding { [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) $warnMs = Get-Parameter $Parameters 'PrinterConnectWarnMs' 200 $failMs = Get-Parameter $Parameters 'PrinterConnectFailMs' ([double]::MaxValue) $check = Get-Text 'Check.Printing.DefaultPrinter' $default = @((Get-DataCollection $Data 'Printers') | Where-Object { $_.Default }) | Select-Object -First 1 if (-not $default) { return New-Finding -Category System -Check $check -Severity INFO -Value (Get-Text 'Value.Shared.None') ` -Hint (Get-Text 'Hint.Printing.NoDefaultPrinter') } $offline = 'OK' if (Test-PrinterOffline -Printer $default) { $offline = 'WARN' } $reach = Get-DataProperty $Data 'Reachability' $via = Get-DataProperty $reach 'Via' if (-not $reach -or $via -eq 'None') { # USB, WSD, a PDF writer: nothing to connect to, so the status is all there is. $value = (Get-Text 'Value.Printing.DefaultLocal') -f $default.Name, $default.PortName if ($offline -ne 'OK') { $value = '{0} | {1}' -f $value, (Get-Text 'Value.Printing.Reason.Offline') } return New-Finding -Category System -Check $check -Severity $offline -Value $value ` -Hint (Get-Text 'Hint.Printing.DefaultOffline') } $target = '{0}:{1}' -f (Get-DataProperty $reach 'HostName'), (Get-DataProperty $reach 'Port') $successes = ConvertTo-Number (Get-DataProperty $reach 'Successes') $attempts = ConvertTo-Number (Get-DataProperty $reach 'Attempts') if (-not (Get-DataProperty $reach 'Resolved') -or -not $successes) { # The case this Check exists for: every document Office opens waits on this. return New-Finding -Category System -Check $check -Severity FAIL ` -Value ((Get-Text 'Value.Printing.DefaultUnreachable') -f $default.Name, $target) ` -Hint (Get-Text 'Hint.Printing.DefaultUnreachable') } $average = ConvertTo-Number (Get-DataProperty $reach 'AverageMs') $missed = 'OK' if ($successes -lt $attempts) { $missed = 'WARN' } $slow = 'OK' if ($null -ne $average) { $slow = Get-Severity $average $warnMs $failMs } # What is wrong with it, said. The Value used to read "... Verbindung Mittel 1 ms (3/3 # ok)" under a WARN, and the Hint "slow, unreliable or offline": a Technician could not # tell which, and here it was none of the first two. $reasons = @() $hints = @() if ($offline -ne 'OK') { $reasons += Get-Text 'Value.Printing.Reason.Offline' $hints += Get-Text 'Hint.Printing.DefaultOffline' } if ($missed -ne 'OK') { $reasons += Get-Text 'Value.Printing.Reason.Missed' ($attempts - $successes) $attempts } if ($slow -ne 'OK') { $reasons += Get-Text 'Value.Printing.Reason.Slow' $average } if ($missed -ne 'OK' -or $slow -ne 'OK') { $hints += Get-Text 'Hint.Printing.DefaultSlow' } $value = (Get-Text 'Value.Printing.DefaultReachable') -f $default.Name, $target, $average, $successes, $attempts if ($reasons.Count) { $value = '{0} | {1}' -f $value, ($reasons -join ' | ') } New-Finding -Category System -Check $check -Severity (Get-WorstSeverity $offline $missed $slow) ` -Value $value -Hint ($hints -join ' | ') } function New-OfflinePrinterFinding { [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) $warn = Get-Parameter $Parameters 'OfflinePrinterWarnAbove' 2 $fail = Get-Parameter $Parameters 'OfflinePrinterFailAbove' ([double]::MaxValue) # The default printer has its own Finding; counting it here too would say it twice. $offline = @((Get-DataCollection $Data 'Printers') | Where-Object { -not $_.Default -and (Test-PrinterOffline -Printer $_) }) $check = Get-Text 'Check.Printing.OfflinePrinters' if (-not $offline.Count) { return New-Finding -Category System -Check $check -Severity OK -Value (Get-Text 'Value.Shared.None') } # Counted as devices. One printer is often several queues on its print server - one # for each tray, one for duplex - whose ports are the printer's address with _1, _2 # behind it, and four queues of one printer are one printer that reports offline. $devices = @($offline | Group-Object { '{0}|{1}' -f "$($_.ServerName)".ToLowerInvariant(), ("$($_.PortName)" -replace '_\d{1,2}$', '').ToLowerInvariant() } | ForEach-Object { $name = @($_.Group | ForEach-Object { "$($_.Name)" } | Sort-Object { $_.Length }, { $_ })[0] [pscustomobject]@{ Name = $name; Queues = $_.Count; OnServer = [bool]"$($_.Group[0].ServerName)" } } | Sort-Object Name) $severity = Get-Severity $devices.Count $warn $fail if ($severity -eq 'OK') { $severity = 'INFO' } $named = @($devices | ForEach-Object { if ($_.Queues -gt 1) { (Get-Text 'Value.Printing.OfflineQueues') -f $_.Name, $_.Queues } else { $_.Name } }) # "Offline" is what Windows was told, and by whom differs: a print server says it of # a printer that does not answer its SNMP query, whether or not the printer prints. # That a printer is offline does not make it one nobody uses. $meaning = @() if (@($devices | Where-Object { $_.OnServer }).Count) { $meaning += Get-Text 'Meaning.Printing.OfflineOnServer' } if (@($devices | Where-Object { -not $_.OnServer }).Count) { $meaning += Get-Text 'Meaning.Printing.OfflineLocal' } New-Finding -Category System -Check $check -Severity $severity ` -Value ((Get-Text 'Value.Printing.OfflinePrinters') -f $devices.Count, ($named -join ', ')) ` -Meaning ($meaning -join ' | ') -Hint (Get-Text 'Hint.Printing.OfflinePrinters') ` -Signal 'printer-offline' ` -Reference @((New-Reference -Signal 'print-job-stuck'), (New-Reference -Section 'Title.Printing.Printers')) } function Format-PrintJobAge { <# .SYNOPSIS How long a job has been lying in a queue, in the unit that says it best. Pure. #> [CmdletBinding()] [OutputType([string])] param([double]$Minutes) if ($Minutes -ge 2880) { return (Get-Text 'Value.Printing.Age.Days') -f [math]::Floor($Minutes / 1440) } if ($Minutes -ge 120) { return (Get-Text 'Value.Printing.Age.Hours') -f [math]::Floor($Minutes / 60) } (Get-Text 'Value.Printing.Age.Minutes') -f [math]::Floor($Minutes) } function New-StuckPrintJobFinding { [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) $minutes = Get-Parameter $Parameters 'PrintJobStuckMinutes' 10 $warn = Get-Parameter $Parameters 'StuckPrintJobWarnAbove' 0 $fail = Get-Parameter $Parameters 'StuckPrintJobFailAbove' 10 $stuck = @((Get-DataCollection $Data 'Jobs') | Where-Object { $age = ConvertTo-Number $_.AgeMinutes $null -ne $age -and $age -gt $minutes }) $check = Get-Text 'Check.Printing.StuckJobs' if (-not $stuck.Count) { return New-Finding -Category System -Check $check -Severity OK -Value (Get-Text 'Value.Shared.None') } $printers = ($stuck | ForEach-Object { $_.Printer } | Sort-Object -Unique) -join ', ' $oldest = ($stuck | ForEach-Object { ConvertTo-Number $_.AgeMinutes } | Measure-Object -Maximum).Maximum # A job in a print server's queue is not this machine's to restart a service over. $hint = Get-Text 'Hint.Printing.StuckJobs' if (@($stuck | Where-Object { "$($_.Printer)".StartsWith('\\') }).Count) { $hint = Get-Text 'Hint.Printing.StuckJobsOnServer' } New-Finding -Category System -Check $check -Severity (Get-Severity $stuck.Count $warn $fail) ` -Value ((Get-Text 'Value.Printing.StuckJobs') -f $stuck.Count, $minutes, $printers, (Format-PrintJobAge -Minutes $oldest)) ` -Meaning (Get-Text 'Meaning.Printing.StuckJobs') -Hint $hint ` -Signal 'print-job-stuck' ` -Reference @((New-Reference -Signal 'printer-offline'), (New-Reference -Section 'Title.Printing.Jobs')) } function New-PrintErrorFinding { [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) $warn = Get-Parameter $Parameters 'PrintErrorWarnAbove' 10 $fail = Get-Parameter $Parameters 'PrintErrorFailAbove' ([double]::MaxValue) $check = Get-Text 'Check.Printing.Errors' if (Get-DataProperty $Data 'LogUnreadable') { return New-UnavailableFinding -Category System -Check $check -Hint (Get-Text 'Hint.Printing.LogUnreadable') } $errors = Get-DataCollection $Data 'Errors' $days = Get-DataProperty $Data 'Days' if (-not $errors.Count) { return New-Finding -Category System -Check $check -Severity OK -Value ((Get-Text 'Value.Printing.NoErrors') -f $days) } # The most frequent event id is the lead, and is said with what it means. $common = $errors | Group-Object Id | Sort-Object Count -Descending | Select-Object -First 3 | ForEach-Object { $id = Format-CodeWithMeaning -Code ('ID {0}' -f $_.Name) -Meaning (Get-PrintErrorIdText -Id $_.Name) (Get-Text 'Value.Printing.ErrorCount') -f $id, $_.Count } New-Finding -Category System -Check $check -Severity (Get-Severity $errors.Count $warn $fail) ` -Value ((Get-Text 'Value.Printing.Errors') -f $errors.Count, $days, ($common -join ', ')) ` -Hint (Get-Text 'Hint.Printing.Errors') } function Get-PrintErrorIdText { <# .SYNOPSIS What an error event of the print service means. Nothing for one not known here, which is then shown as the number it is. Pure. .DESCRIPTION Not a table of Microsoft's: read off the messages the Microsoft-Windows-PrintService provider carries for its events ((Get-WinEvent -ListProvider ...).Events), each error by its own message. The numbers between them are events that are no error - a driver added, a printer published - and have no meaning here, as they are not among what is counted. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Id) $number = ConvertTo-Number $Id if ($null -eq $number) { return '' } switch ([int]$number) { 808 { return (Get-Text 'Value.Printing.ErrorId.DriverLoad') } 372 { return (Get-Text 'Value.Printing.ErrorId.DocumentFailed') } 314 { return (Get-Text 'Value.Printing.ErrorId.Timeout') } 315 { return (Get-Text 'Value.Printing.ErrorId.ShareFailed') } 371 { return (Get-Text 'Value.Printing.ErrorId.UnshareFailed') } 318 { return (Get-Text 'Value.Printing.ErrorId.SettingsReset') } 319 { return (Get-Text 'Value.Printing.ErrorId.DriverMissing') } 320 { return (Get-Text 'Value.Printing.ErrorId.PortMissing') } { $_ -in 322, 323, 325, 326, 327, 328, 329, 331, 333, 335, 337 } { return (Get-Text 'Value.Printing.ErrorId.DirectoryPublish') } { $_ -in 348, 351 } { return (Get-Text 'Value.Printing.ErrorId.DriverIncompatible') } 350 { return (Get-Text 'Value.Printing.ErrorId.SpoolFileCorrupt') } 353 { return (Get-Text 'Value.Printing.ErrorId.NoRights') } 354 { return (Get-Text 'Value.Printing.ErrorId.InitFailed') } 360 { return (Get-Text 'Value.Printing.ErrorId.ColorProfile') } 361 { return (Get-Text 'Value.Printing.ErrorId.PortInit') } 362 { return (Get-Text 'Value.Printing.ErrorId.OwnNameNotResolved') } 363 { return (Get-Text 'Value.Printing.ErrorId.SpoolerStart') } { $_ -in 364, 365, 367 } { return (Get-Text 'Value.Printing.ErrorId.PrintProcessor') } 366 { return (Get-Text 'Value.Printing.ErrorId.SecurityDescriptor') } { $_ -in 368, 369, 370 } { return (Get-Text 'Value.Printing.ErrorId.DriverPackage') } 373 { return (Get-Text 'Value.Printing.ErrorId.GdiObjects') } } '' } function ConvertTo-PrintingSection { [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) New-Section -Title (Get-Text 'Title.Printing.Printers') -Row @( (Get-DataCollection $Data 'Printers') | ForEach-Object { $row = [ordered]@{} $row[(Get-Text 'Column.Printing.Name')] = $_.Name $row[(Get-Text 'Column.Printing.Default')] = Get-TriStateText ([bool]$_.Default) $row[(Get-Text 'Column.Printing.Network')] = Get-TriStateText ([bool]$_.Network) $row[(Get-Text 'Column.Printing.WorkOffline')] = Get-TriStateText ([bool]$_.WorkOffline) # What the two numbers mean, and the numbers: a code is never shown bare. $row[(Get-Text 'Column.Printing.Status')] = Get-PrinterStatusText -PrinterStatus $_.PrinterStatus -ExtendedPrinterStatus $_.ExtendedPrinterStatus $row[(Get-Text 'Column.Printing.Port')] = $_.PortName $row[(Get-Text 'Column.Printing.Server')] = $_.ServerName $row[(Get-Text 'Column.Printing.Driver')] = $_.DriverName [pscustomobject]$row } ) New-Section -Title (Get-Text 'Title.Printing.Jobs') -Row @( (Get-DataCollection $Data 'Jobs') | ForEach-Object { $age = ConvertTo-Number (Get-DataProperty $_ 'AgeMinutes') $row = [ordered]@{} $row[(Get-Text 'Column.Printing.Name')] = Get-DataProperty $_ 'Printer' $row[(Get-Text 'Column.Printing.Job.Document')] = Get-DataProperty $_ 'Document' # In Windows' own words and language: the spooler hands it over as text. $row[(Get-Text 'Column.Printing.Job.Status')] = Get-DataProperty $_ 'JobStatus' $row[(Get-Text 'Column.Printing.Job.Age')] = $(if ($null -ne $age) { Format-PrintJobAge -Minutes $age } else { '' }) [pscustomobject]$row } ) } function Get-PrinterStatusText { <# .SYNOPSIS What Windows reports of a printer, in a word, with the two numbers behind it. Pure. .DESCRIPTION Win32_Printer has two status properties, and the extended one says more: 3 idle, 4 printing, 7 offline, 8 paused, 9 error and so on, as Microsoft documents them for that class. Where the extended one is "unknown" or absent, the plain one (3 idle, 4 printing, 7 offline) is what there is. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$PrinterStatus, [AllowNull()]$ExtendedPrinterStatus) $plain = ConvertTo-Number $PrinterStatus $extended = ConvertTo-Number $ExtendedPrinterStatus $read = $extended if ($null -eq $read -or $read -in 1, 2) { if ($null -ne $plain -and $plain -notin 1, 2) { $read = $plain } } $text = switch ($read) { 2 { Get-Text 'Value.Printing.Status.Unknown' } 3 { Get-Text 'Value.Printing.Status.Idle' } 4 { Get-Text 'Value.Printing.Status.Printing' } 5 { Get-Text 'Value.Printing.Status.WarmingUp' } 6 { Get-Text 'Value.Printing.Status.Stopped' } 7 { Get-Text 'Value.Printing.Status.Offline' } 8 { Get-Text 'Value.Printing.Status.Paused' } 9 { Get-Text 'Value.Printing.Status.Error' } 10 { Get-Text 'Value.Printing.Status.Busy' } 11 { Get-Text 'Value.Printing.Status.NotAvailable' } 12 { Get-Text 'Value.Printing.Status.Waiting' } 13 { Get-Text 'Value.Printing.Status.Processing' } 14 { Get-Text 'Value.Printing.Status.Initializing' } 15 { Get-Text 'Value.Printing.Status.PowerSave' } 16 { Get-Text 'Value.Printing.Status.PendingDeletion' } 17 { Get-Text 'Value.Printing.Status.IoActive' } 18 { Get-Text 'Value.Printing.Status.ManualFeed' } default { Get-Text 'Value.Printing.Status.Other' } } (Get-Text 'Value.Printing.Status') -f $text, ('{0}/{1}' -f $PrinterStatus, $ExtendedPrinterStatus) } function ConvertTo-PrintingEvent { [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) (Get-DataCollection $Data 'Errors') } |