<# .SYNOPSIS G-Scan low-disk alert for NinjaOne: exit code 1 when a drive runs low, with where the space went. .DESCRIPTION Checks the free space first, which costs nothing. Only when it is below the threshold does it run G-Scan, so the alert says straight away what is using the drive: the largest folders, the findings, and what a safe clean-up would free. Schedule it, or use it where NinjaOne reacts to a script's exit code; the output is the text of the alert. Nothing is deleted and nothing is written to disk. .PARAMETER Drive A drive letter, such as C: or D:\. Default: the system drive. .PARAMETER MinFreePercent Alert when less than this percentage is free. Default 10. .PARAMETER MinFreeGb Also alert when less than this many GB is free. Default 0, which leaves it off. .PARAMETER GScan Full path to gscan.com. Default: the standard installation folder. .PARAMETER TopFolders How many of the largest folders the alert lists. Default 5. .PARAMETER TimeoutSeconds Give up on the scan after this long. Default 1800. .NOTES G-Scan by Garia.Net - https://garia.net/ Run as System, so the scan reads every folder and can use turbo mode. Windows PowerShell 5.1 or later. Exit codes: 0 enough free space, 1 low on space (the output says why), 2 wrong input. #> [CmdletBinding()] param( [string]$Drive = "$env:SystemDrive\", [double]$MinFreePercent = 10, [double]$MinFreeGb = 0, [string]$GScan = '', [int]$TopFolders = 5, [int]$TimeoutSeconds = 1800 ) $ErrorActionPreference = 'Stop' # NinjaOne script variables arrive as environment variables with the same name. if ($env:drive) { $Drive = $env:drive } if ($env:minFreePercent) { $MinFreePercent = [double]$env:minFreePercent } if ($env:minFreeGb) { $MinFreeGb = [double]$env:minFreeGb } if ($env:gscan) { $GScan = $env:gscan } if ($env:topFolders) { $TopFolders = [int]$env:topFolders } if ($env:timeoutSeconds) { $TimeoutSeconds = [int]$env:timeoutSeconds } $inv = [System.Globalization.CultureInfo]::InvariantCulture $labels = @{ recyclebin = 'Recycle Bin has never been emptied' tempfiles = 'Temporary files are piling up' updates = 'Downloaded updates are still there' downloads = 'Downloads folder has got out of hand' oldfiles = 'Large files not touched in a year' duplicates = 'Likely duplicate files' caches = 'Caches and rebuildable folders' } $safeCodes = 'recyclebin', 'tempfiles', 'updates' function Find-GScan([string]$Given) { if ($Given) { return $Given } $pf = $env:ProgramW6432 if (-not $pf) { $pf = $env:ProgramFiles } Join-Path $pf 'GariaNetTools\G-Scan\gscan.com' } # Backslashes right before a closing quote must be doubled, or "C:\" would swallow the # quote and everything after it. function Format-Argument([string]$Value) { '"' + ($Value -replace '(\\+)$', '$1$1') + '"' } # gscan.com with both output streams read as UTF-8, whatever the console code page is. function Invoke-GScan([string]$Exe, [string[]]$Arguments, [int]$Timeout) { $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $Exe $psi.Arguments = ($Arguments | ForEach-Object { Format-Argument $_ }) -join ' ' $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8 $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8 $p = [System.Diagnostics.Process]::Start($psi) $out = $p.StandardOutput.ReadToEndAsync() $err = $p.StandardError.ReadToEndAsync() if (-not $p.WaitForExit($Timeout * 1000)) { try { $p.Kill() } catch { } return [pscustomobject]@{ ExitCode = -1; Output = ''; Error = "no result within $Timeout seconds" } } $p.WaitForExit() [pscustomobject]@{ ExitCode = $p.ExitCode; Output = $out.Result; Error = $err.Result.Trim() } } function Format-Size([double]$Bytes) { $units = 'B', 'KB', 'MB', 'GB', 'TB', 'PB' $i = 0 while ($Bytes -ge 1024 -and $i -lt $units.Count - 1) { $Bytes /= 1024; $i++ } if ($i -eq 0) { return [string]::Format($inv, '{0:0} {1}', $Bytes, $units[$i]) } [string]::Format($inv, '{0:0.0} {1}', $Bytes, $units[$i]) } # ------------------------------------------------------------------ free space first --- if ($Drive -notmatch '^[A-Za-z]:\\?$') { Write-Output "Drive must be a drive letter such as C: or D:\, not '$Drive'." exit 2 } $letter = $Drive.Substring(0, 2).ToUpperInvariant() + '\' $info = New-Object System.IO.DriveInfo($letter) if (-not $info.IsReady) { Write-Output "$letter is not ready." exit 2 } $size = [double]$info.TotalSize $free = [double]$info.TotalFreeSpace $pct = $free / $size * 100 $low = ($pct -lt $MinFreePercent) -or ($MinFreeGb -gt 0 -and $free -lt $MinFreeGb * 1GB) $state = [string]::Format($inv, '{0} free on {1} ({2:0.0}%)', (Format-Size $free), $letter, $pct) if (-not $low) { Write-Output "OK: $state." exit 0 } $limit = [string]::Format($inv, '{0:0.#}%', $MinFreePercent) if ($MinFreeGb -gt 0) { $limit += [string]::Format($inv, ' or {0:0.#} GB', $MinFreeGb) } Write-Output "LOW DISK SPACE: $state, below $limit." # ------------------------------------------------------------- then where it went --- $exe = Find-GScan $GScan if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) { Write-Output "G-Scan is not installed at $exe, so there is no breakdown." exit 1 } $run = Invoke-GScan $exe @('--scan', $letter, '--out', '-') $TimeoutSeconds if ($run.ExitCode -ne 0) { Write-Output "G-Scan could not scan $letter (exit code $($run.ExitCode)): $($run.Error)" exit 1 } try { $r = $run.Output | ConvertFrom-Json } catch { Write-Output "The G-Scan result could not be read: $($_.Exception.Message)" exit 1 } $base = $letter.TrimEnd('\') $used = [double]$r.meta.used Write-Output '' Write-Output 'Largest folders:' foreach ($f in @($r.tree.c | Where-Object { -not $_.rest } | Select-Object -First $TopFolders)) { $share = '' if ($used -gt 0) { $share = [string]::Format($inv, '{0,4:0}%', [double]$f.s / $used * 100) } Write-Output (' {0,10} {1} {2}' -f (Format-Size $f.s), $share, ($base + '\' + $f.n)) } $findings = @($r.advice | Where-Object { $labels.ContainsKey([string]$_.code) } | Sort-Object { [double]$_.bytes } -Descending) if ($findings.Count -gt 0) { Write-Output '' Write-Output 'Findings:' foreach ($a in $findings) { $where = [string](@($a.paths) | Select-Object -First 1) Write-Output (' {0,10} {1} {2}' -f (Format-Size $a.bytes), $labels[[string]$a.code], $where) } } $safe = 0.0 foreach ($a in @($r.advice)) { if ($safeCodes -contains $a.code) { $safe += [double]$a.bytes } } Write-Output '' Write-Output ('Safe to clean now (Recycle Bin, temporary files, downloaded updates): {0}' -f (Format-Size $safe)) if ([double]$r.meta.denied -gt 0) { Write-Output ('{0} folders could not be read; actual use is slightly higher.' -f $r.meta.denied) } exit 1