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's host 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 # How often every other host is: once, and the Measurement says so in its Samples. No # Finding judges those, and Measure-TcpConnect waits three seconds for a host that does not # answer: three attempts each would cost a Run up to a minute and a half for a picture. # With MaxHosts at five, the default printer's host among them, a Run waits at most # 9 + 4 x 3 = 21 seconds on hosts that do not answer. $script:PrintingOtherConnectAttempts = 1 # How many hosts a Run connects to at most, unless the Check Definition says MaxHosts: the # Gatherer stops there, and the Judge says so of the rest. $script:PrintingMaxHosts = 5 # 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 # What the Judge observes of how things are connected (ADR-0012): each printer this machine # prints to, and what it prints through - the share of a print Server, which the Network Map # hangs on that Server's SMB Service, or the printer's own port, a Service on the printer. $script:PrintingLinks = @('PrintsTo:Printer', 'OnServer:Service') # What the port of a printer with its own TCP/IP port is called, by its number: the protocol # the print data goes by. Any other port is called by the word for print data alone. $script:PrintingPortProtocol = @{ 9100 = 'RAW'; 515 = 'LPR' } # What Get-PrintingData hands its Judge. See Private/DataShape.ps1. $script:PrintingDataShape = @{ Days = 'Answered' Since = 'Value' LogCoverage = 'List' SpoolerFound = 'Answered' SpoolerState = 'Value' SpoolerMode = 'Value' Printers = 'List' Ports = 'List' Reachability = 'Value' Hosts = 'List' Jobs = 'List' Errors = 'List' LogUnreadable = 'Value' Prefixes = 'List' } 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 } }) # Each host once, however many printers it serves, and no more of them than MaxHosts: # a machine can list dozens of printers, and a host that does not answer costs each # attempt its whole timeout. The default printer's host comes first and is always # measured - its Finding is what Office waits on. Those beyond are listed unmeasured. $maxHosts = [int](Get-Parameter $Parameters 'MaxHosts' $script:PrintingMaxHosts) $hosts = @(Get-PrintingHost -Printer $printers -Port $ports) $hosts = @(for ($i = 0; $i -lt $hosts.Count; $i++) { $one = $hosts[$i] if ($one.Default -or $i -lt $maxHosts) { Measure-PrinterReachability -Target $one } else { [pscustomobject]@{ Via = $one.Via; HostName = $one.HostName; Port = $one.Port; Default = $one.Default; Measured = $false Resolved = $null; Attempts = 0; Successes = 0; AverageMs = $null; Address = $null; Route = $null } } }) # The default printer's own reach, for its Finding: its host as measured above, or # nothing to connect to. $default = $printers | Where-Object { $_.Default } | Select-Object -First 1 $reach = $null if ($default) { $reach = @($hosts | Where-Object { $_.Default }) | Select-Object -First 1 if (-not $reach) { $reach = [pscustomobject]@{ Via = 'None'; HostName = $null; Port = $null; Resolved = $null; Attempts = 0; Successes = 0; AverageMs = $null } } } $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+$', '') # Not what the job is called: that is what a User printed (ADR-0006). JobStatus = "$($_.JobStatus)" AgeMinutes = $age } }) $refused = @{} $errors = @() $coverage = @() if (Test-EventLogReadable -Log $script:PrintingLog -Unreadable $refused) { # How far back the log reaches, as the NetworkDisruption Kind records it: a log of # a week holds no month of errors. $coverage = @([pscustomobject]@{ Log = $script:PrintingLog; Oldest = Get-NetworkDisruptionLogOldest -Log $script:PrintingLog }) 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-PrintErrorRow -LogEntry $_ }) } 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 Since = $since LogCoverage = $coverage SpoolerFound = $null -ne $spooler SpoolerState = "$($spooler.State)" SpoolerMode = "$($spooler.StartMode)" Printers = $printers # The TCP/IP ports, for which printer prints to which host's port (ConvertTo-PrintingLink). Ports = $ports Reachability = $reach Hosts = $hosts Jobs = $jobs Errors = $errors LogUnreadable = $unreadable # The machine's own addresses with their prefix lengths, for the Zone of what the # printers are reached through. See Get-OwnPrefix. Prefixes = @(Get-OwnPrefix) } } function Get-PrintingRoute { <# .SYNOPSIS How one printer is reached over the network: Via, HostName, Port and Share - or nothing for one on USB, over WSD or a PDF writer. Pure. .DESCRIPTION A printer shared from a print server is reached through the server, over SMB, and prints to its share there (Share, empty where Windows names none). A printer with its own TCP/IP port is reached directly, on that port - 9100 for RAW, 515 for LPR, as the port says. The one rule Get-PrintingHost measures by and ConvertTo-PrintingLink draws by. .PARAMETER Printer One printer as Get-PrintingData reads it. .PARAMETER Port The TCP/IP printer ports: Name, HostAddress, PortNumber. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Printer, [AllowNull()][AllowEmptyCollection()]$Port) $server = ConvertTo-ServerName -Name (Get-DataProperty $Printer 'ServerName') if ($server) { return [pscustomobject]@{ Via = 'PrintServer'; HostName = $server; Port = $script:PrintingServerPort; Share = "$(Get-DataProperty $Printer 'ShareName')".Trim() } } $tcp = @($Port | Where-Object { $_ -and "$(Get-DataProperty $_ 'Name')" -eq "$(Get-DataProperty $Printer 'PortName')" }) | Select-Object -First 1 $hostName = $(if ($tcp) { ConvertTo-ServerName -Name "$(Get-DataProperty $tcp 'HostAddress')".Trim() }) $number = $(if ($tcp) { ConvertTo-Number (Get-DataProperty $tcp 'PortNumber') }) if (-not $hostName -or $null -eq $number) { return } [pscustomobject]@{ Via = 'TcpPort'; HostName = $hostName; Port = [int]$number; Share = '' } } function Get-PrintingHost { <# .SYNOPSIS Every host the printers are reached through, each host once: the default printer's first, the rest by name. Pure. .DESCRIPTION Each printer by Get-PrintingRoute: a print server over SMB, a printer with its own TCP/IP port on that port. Anything else - USB, WSD, a PDF writer - has no address this can connect to and is not listed. Four queues of one print server, or two ports of one printer, are one host. .PARAMETER Printer The printers as Get-PrintingData reads them. .PARAMETER Port The TCP/IP printer ports: Name, HostAddress, PortNumber. #> [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()][AllowEmptyCollection()]$Printer, [AllowNull()][AllowEmptyCollection()]$Port ) $found = [ordered]@{} foreach ($one in @($Printer | Where-Object { $_ })) { $route = Get-PrintingRoute -Printer $one -Port $Port if (-not $route) { continue } $hostName = $route.HostName $number = $route.Port $via = $route.Via # One host, one connect: on the default printer's port where it is the default # printer's host - that is the way its Finding is about - else on the lowest, so # that every Run asks the same port. $key = $hostName $isDefault = [bool](Get-DataProperty $one 'Default') if (-not $found.Contains($key)) { $found[$key] = [pscustomobject]@{ Via = $via; HostName = $hostName; Port = $number; Default = $false } } $known = $found[$key] if ($isDefault -and -not $known.Default) { $known.Via = $via; $known.Port = $number; $known.Default = $true } elseif (-not $known.Default -and $number -lt $known.Port) { $known.Via = $via; $known.Port = $number } } # In an order that does not change from one Run to the next, so that the hosts beyond # MaxHosts are the same ones each time. @($found.Values | Sort-Object @{ Expression = { -not $_.Default } }, @{ Expression = { $_.HostName } }, @{ Expression = { $_.Port } }) } function Measure-PrinterReachability { <# .SYNOPSIS Connects to one host the printers are reached through, as Get-PrintingHost listed it. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)]$Target) $hostName = $Target.HostName $number = $Target.Port $via = $Target.Via $attempts = $script:PrintingOtherConnectAttempts if ($Target.Default) { $attempts = $script:PrintingConnectAttempts } $resolved = $true $address = $hostName if (-not (Test-NetworkAddress -Text $hostName)) { $resolution = Resolve-HostAddress -HostName $hostName $resolved = [bool]$resolution.Resolved $address = "$($resolution.Address)" } $times = @() $route = $null if ($resolved) { # The adapter the way leaves through, which the Judge decides the Zone by. $route = Get-AddressRoute -Address $address $times = @(1..$attempts | ForEach-Object { Measure-TcpConnect -HostName $hostName -Port $number } | Where-Object { $null -ne $_ }) } [pscustomobject]@{ Via = $via HostName = $hostName Port = $number Default = [bool]$Target.Default Measured = $true Resolved = $resolved Attempts = $attempts Successes = $times.Count AverageMs = $(if ($times.Count) { [math]::Round(($times | Measure-Object -Average).Average, 1) } else { $null }) Address = $address Route = $route } } 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') $default = New-DefaultPrinterFinding -Data $Data -Parameters $Parameters $default $offline = New-OfflinePrinterFinding -Data $Data -Parameters $Parameters $offline New-StuckPrintJobFinding -Data $Data -Parameters $Parameters New-PrintErrorFinding -Data $Data -Parameters $Parameters # And the time to the print Servers, for the Report's picture of the network. ADR-0009. ConvertTo-PrintingMeasurement -Data $Data -Parameters $Parameters -Finding $default # And which printer prints through what, for its Network Map. ADR-0012. ConvertTo-PrintingLink -Data $Data -Default $default -Offline $offline } function ConvertTo-PrintingLink { <# .SYNOPSIS Each printer this machine prints to over the network, and what it prints through: the Links of the Network Map (ADR-0012). Pure. .DESCRIPTION By the rule Get-PrintingHost reaches a printer by: one shared from a print Server prints to that share, which the Map offers through the Server's SMB Service; one with its own TCP/IP port prints to the Service on that port, named for what goes over it - "Druckdaten (RAW)" on 9100. No OnServer for the share: that Link makes a Server a file Server, and a print Server is measured as one. A printer on USB, WSD or a PDF writer is reached through nothing on the network and has no Link; nor has one its rule cannot name. At the address its host answered at, where it was measured. The Link from a printer names the Finding about it, so that the Report draws the printer in its colour: the default printer's, and for any other printer Windows reports offline, the one that lists those. A printer's name is no Server, Service or Namespace, and no Finding names it as its Subject (ADR-0015). .PARAMETER Default The Finding about the default printer. .PARAMETER Offline The Finding about the printers Windows reports offline. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data, [AllowNull()]$Default, [AllowNull()]$Offline) $addressOf = @{} foreach ($one in (Get-DataCollection $Data 'Hosts')) { $hostName = ConvertTo-ServerName -Name (Get-DataProperty $one 'HostName') if ($hostName -and "$(Get-DataProperty $one 'Address')") { $addressOf[$hostName] = "$(Get-DataProperty $one 'Address')" } } $ports = Get-DataCollection $Data 'Ports' Select-UniqueLink -Link @(foreach ($printer in (Get-DataCollection $Data 'Printers')) { $name = "$(Get-DataProperty $printer 'Name')".Trim() $route = Get-PrintingRoute -Printer $printer -Port $ports if (-not $name -or -not $route) { continue } $about = $null if ([bool](Get-DataProperty $printer 'Default')) { $about = $Default } elseif ($Offline -and (Test-PrinterOffline -Printer $printer)) { $about = $Offline } $from = New-LinkEnd -Thing Printer -Name $name $address = $addressOf[$route.HostName] if ($route.Via -eq 'PrintServer') { if (-not $route.Share) { continue } # A share its rule cannot name costs this printer its Link, never the Judge. $share = $null try { $share = New-LinkEnd -Thing Share -Server $route.HostName -Name $route.Share -Address $address } catch { continue } New-Link -Kind PrintsTo -From $from -To $share -Finding $about continue } $called = $(if ($script:PrintingPortProtocol.ContainsKey($route.Port)) { (Get-Text 'Value.Printing.PortService') -f $script:PrintingPortProtocol[$route.Port] } else { Get-Text 'Value.Printing.PortServicePlain' }) $onServer = New-ServiceLink -Server $route.HostName -Port $route.Port -Address $address -Name $called if (-not $onServer) { continue } New-Link -Kind PrintsTo -From $from -To $onServer.From -Finding $about $onServer }) } function ConvertTo-PrintingMeasurement { <# .SYNOPSIS The time to each host the printers are reached through, each host and port once. Pure. .DESCRIPTION Each print Server, and each printer with its own TCP/IP port - that is what this machine prints to, and is drawn as its print Server. The default printer's host is measured for the Finding about the default printer; no Finding judges the others, and they name none. A host beyond MaxHosts was not connected to and says so, so that the picture shows it and does not show it fast. Data gathered before every host was measured has the default printer's alone, as Reachability; every other print Server the printers name is then not measured, with no reason the Gatherer gave. .PARAMETER Finding The Finding about the default printer. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data, [hashtable]$Parameters = @{}, [AllowNull()]$Finding) $vpnPattern = Get-Parameter $Parameters 'VpnAdapterPattern' $script:MeasurementVpnAdapterPattern $maxHosts = [int](Get-Parameter $Parameters 'MaxHosts' $script:PrintingMaxHosts) $hosts = Get-DataCollection $Data 'Hosts' $default = $false if ($null -eq (Get-DataProperty $Data 'Hosts')) { $reach = Get-DataProperty $Data 'Reachability' $hosts = @() if ("$(Get-DataProperty $reach 'HostName')" -and "$(Get-DataProperty $reach 'Via')" -ne 'None') { $hosts = @($reach) } $default = $true } $seen = @{} foreach ($one in $hosts) { $hostName = "$(Get-DataProperty $one 'HostName')" if (-not $hostName) { continue } $seen[(ConvertTo-ServerName -Name $hostName)] = $true $common = @{ Subject = $hostName; Role = 'PrintServer'; Port = (Get-DataProperty $one 'Port') } # The Reachability of older data is the default printer's. if ($default -or (Get-DataProperty $one 'Default')) { $common.Finding = $Finding } if ($false -eq (Get-DataProperty $one 'Measured')) { New-Measurement @common -Metric TcpConnect -State NotMeasured -ErrorText (Get-Text 'Measurement.Error.PrintingCapped' $maxHosts) continue } if (-not [bool](Get-DataProperty $one 'Resolved')) { New-Measurement @common -Metric TcpConnect -State Failed -ErrorText (Get-Text 'Measurement.Error.NotResolved') continue } $common.Address = "$(Get-DataProperty $one 'Address')" $common.Zone = Get-GatheredAddressZone -Data $Data -Address $common.Address -Route (Get-DataProperty $one 'Route') -VpnPattern $vpnPattern ConvertTo-TcpConnectMeasurement -Common $common -Result $one } foreach ($printer in (Get-DataCollection $Data 'Printers')) { $server = ConvertTo-ServerName -Name (Get-DataProperty $printer 'ServerName') if (-not $server -or $seen.ContainsKey($server)) { continue } $seen[$server] = $true New-Measurement -Subject $server -Role PrintServer -Metric TcpConnect -Port $script:PrintingServerPort -State NotMeasured } } 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 (ConvertTo-ServerName -Name $_.ServerName), ("$($_.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 } # Which printers, the one with the most errors first: that is whose driver to look at. # Each with how its errors are spread in time, in the period the log covers: a driver # failing at every print and a queue that broke one night are different faults. $period = New-EventPatternPeriod -Data $Data -Parameters $Parameters -Log $script:PrintingLog $tokens = [System.Collections.Generic.List[string]]::new() $none = Get-Text 'Value.Printing.ErrorNoPrinter' $printers = Format-FindingCount -First 5 -Row $errors -Period $period -Token $tokens -By { param($r) $name = "$(Get-DataProperty $r 'Printer')"; if ($name) { $name } else { $none } } $value = (Get-Text 'Value.Printing.Errors') -f $errors.Count, $days, ($common -join ', ') if ($printers) { $value = '{0} | {1}' -f $value, ((Get-Text 'Value.Printing.ErrorPrinters') -f $printers) } # A time of day that comes back and a burst are what a Lead can connect by. ADR-0005. $signal = @() if ($tokens -contains 'daily') { $signal += 'events-daily' } if ($tokens -contains 'burst') { $signal += 'events-burst' } New-Finding -Category System -Check $check -Severity (Get-Severity $errors.Count $warn $fail) ` -Value $value -Hint (Get-Text 'Hint.Printing.Errors') -Pattern @($tokens) -Signal $signal } 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') } } '' } # Where Windows puts the printer in the payload of a print error, by event id, counted # from 0. Read off the texts of the provider's own events (Get-WinEvent -ListProvider # Microsoft-Windows-PrintService, German Windows 11 26300, 2026-10-11): "Das Dokument %1 # im Besitz von %2 konnte nicht auf dem Drucker %3 gedruckt werden" is 372. $script:PrintErrorPrinterIndex = @{ 372 = 2; 314 = 3 318 = 0; 319 = 0; 320 = 0; 359 = 0; 360 = 0; 361 = 0; 367 = 0 315 = 1; 371 = 1 } # The events whose text names the document and whose it was. Their text is not kept. $script:PrintErrorNamesDocument = @(314, 350, 372) function ConvertTo-PrintErrorRow { <# .SYNOPSIS One print error, reduced to when, which event, which printer, and what Windows said - unless what it said names a document. Pure. .DESCRIPTION What was printed and by whom is what a User did, and nothing a Report holds (ADR-0006). Windows writes both into the text of a failed print; that text is dropped, and the printer is taken from the payload by its place. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)]$LogEntry) $id = [int]$LogEntry.Id $printer = '' if ($script:PrintErrorPrinterIndex.ContainsKey($id)) { $printer = "$(Get-EventPayload -LogEntry $LogEntry -Index $script:PrintErrorPrinterIndex[$id])".Trim() } $row = ConvertTo-EventRow -LogEntry $LogEntry -Tag 'Print error' if ($script:PrintErrorNamesDocument -contains $id) { $row.Message = '' } $row | Add-Member -NotePropertyName 'Printer' -NotePropertyValue $printer -PassThru } function ConvertTo-PrintingSection { [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) New-Section -Title (Get-Text 'Title.Printing.Printers') -Help (Get-Text 'Help.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') -Help (Get-Text 'Help.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' # 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 } ) # The errors themselves, so that the Finding can point here and not at a file. $errors = @((Get-DataCollection $Data 'Errors') | Sort-Object { ConvertTo-DataTime (Get-DataProperty $_ 'Time') } -Descending | Select-Object -First 200) if ($errors.Count) { New-Section -Title (Get-Text 'Title.Printing.Errors') -Help (Get-Text 'Help.Printing.Errors') -Row @( $errors | ForEach-Object { $id = Get-DataProperty $_ 'Id' $row = [ordered]@{} $row[(Get-Text 'Column.Printing.Error.Time')] = Get-DataProperty $_ 'Time' # The number with what it means: a code is never shown bare. $row[(Get-Text 'Column.Printing.Error.Event')] = Format-CodeWithMeaning -Code ('ID {0}' -f $id) -Meaning (Get-PrintErrorIdText -Id $id) $row[(Get-Text 'Column.Printing.Name')] = Get-DataProperty $_ 'Printer' $row[(Get-Text 'Column.Printing.Error.Message')] = Format-CodeInText "$(Get-DataProperty $_ 'Message')" [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') } |