# .SYNOPSIS G-Scan disk report for NinjaOne: scan a drive and fill custom fields with the result. .DESCRIPTION Runs gscan.com without a browser and reads the JSON it writes to standard output. Fills up to four NinjaOne custom fields: gscanSummary Text one line: used, free, what a safe clean-up frees gscanFreePercent Integer free space in percent gscanCleanableGb Decimal Recycle Bin + temporary files + downloaded updates, in GB gscanReport WYSIWYG the largest folders and the findings, as tables Outside NinjaOne the same values are printed instead, so the script can be tried in an ordinary PowerShell window first. Nothing is deleted and nothing is written to disk. .PARAMETER Drive What to scan. Default: the system drive. A folder or \\server\share works too. .PARAMETER GScan Full path to gscan.com. Default: the standard installation folder. .PARAMETER SummaryField Name of the text field for the summary. Empty skips it. The same goes for FreePercentField, CleanableField and ReportField. .PARAMETER TopFolders How many of the largest folders the report lists. Default 10. .PARAMETER TimeoutSeconds Give up 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 fields filled, 1 the scan failed, 2 gscan.com not found or wrong input. #> [CmdletBinding()] param( [string]$Drive = "$env:SystemDrive\", [string]$GScan = '', [string]$SummaryField = 'gscanSummary', [string]$FreePercentField = 'gscanFreePercent', [string]$CleanableField = 'gscanCleanableGb', [string]$ReportField = 'gscanReport', [int]$TopFolders = 10, [int]$TimeoutSeconds = 1800 ) $ErrorActionPreference = 'Stop' # NinjaOne script variables arrive as environment variables with the same name. if ($env:drive) { $Drive = $env:drive } 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 # What the finding codes mean, as the G-Scan page words them. $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' sysfiles = 'System files take a fixed share' installer = 'Windows Installer cache is large - do not clear by hand' denied = 'Part of the disk was not read' } # The three clean-ups that need no judgement: G-Scan's own page has a button for each. $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) # Read both at once: a full pipe on one of them would stop the other. $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]) } function ConvertTo-Html([string]$Text) { [System.Net.WebUtility]::HtmlEncode($Text) } function Set-NinjaField([string]$Name, [string]$Value) { if (-not $Name) { return } try { if (Get-Command 'Ninja-Property-Set-Piped' -ErrorAction SilentlyContinue) { $Value | Ninja-Property-Set-Piped $Name } elseif (Get-Command 'Ninja-Property-Set' -ErrorAction SilentlyContinue) { Ninja-Property-Set $Name $Value } elseif ($Value.Length -gt 300) { Write-Output ('[{0}] ({1} characters)' -f $Name, $Value.Length) } else { Write-Output ('[{0}] {1}' -f $Name, $Value) } } catch { Write-Warning ('Could not fill custom field {0}: {1}' -f $Name, $_.Exception.Message) } } # ------------------------------------------------------------------------------ scan --- $exe = Find-GScan $GScan if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) { Write-Output "gscan.com not found at $exe. Install G-Scan 1.14 or later, or pass -GScan." exit 2 } $run = Invoke-GScan $exe @('--scan', $Drive, '--out', '-') $TimeoutSeconds if ($run.ExitCode -ne 0) { Write-Output "G-Scan could not scan $Drive (exit code $($run.ExitCode)): $($run.Error)" if ($run.ExitCode -eq 2) { exit 2 } exit 1 } try { $r = $run.Output | ConvertFrom-Json } catch { Write-Output "The G-Scan result could not be read: $($_.Exception.Message)" exit 1 } # --------------------------------------------------------------------------- figures --- $m = $r.meta $root = [string]$m.path $sep = '/' if ($root -match '^[A-Za-z]:' -or $root.StartsWith('\\')) { $sep = '\' } $base = $root.TrimEnd('\', '/') $used = [double]$m.used $size = [double]$m.volumeSize $free = [double]$m.volumeFree $safe = 0.0 foreach ($a in @($r.advice)) { if ($safeCodes -contains $a.code) { $safe += [double]$a.bytes } } $folders = @($r.tree.c | Where-Object { -not $_.rest } | Select-Object -First $TopFolders) $findings = @($r.advice | Where-Object { $_.level -ne 'good' } | Sort-Object { [double]$_.bytes } -Descending) $parts = @() $pct = $null if ($size -gt 0) { $pct = [int][math]::Round($free / $size * 100) $parts += '{0} used of {1}, {2} free ({3}%)' -f (Format-Size $used), (Format-Size $size), (Format-Size $free), $pct } else { $parts += '{0} used' -f (Format-Size $used) } $parts += 'safe clean-up {0}' -f (Format-Size $safe) if ($folders.Count -gt 0) { $parts += 'largest {0} {1}' -f ($base + $sep + $folders[0].n), (Format-Size $folders[0].s) } if ([double]$m.denied -gt 0) { $parts += '{0} folders not readable' -f $m.denied } $summary = '{0}: {1}. Scanned {2}.' -f $root, ($parts -join ', '), (Get-Date -Format 'yyyy-MM-dd HH:mm') # ---------------------------------------------------------------------------- report --- $sb = New-Object System.Text.StringBuilder [void]$sb.Append('
' + (ConvertTo-Html $summary) + '
') [void]$sb.Append('| Size | Share | Folder |
|---|---|---|
| ' + (ConvertTo-Html (Format-Size $f.s)) + ' | ' + $share + ' | ' + (ConvertTo-Html ($base + $sep + $f.n)) + ' |
| Size | Finding | Largest location |
|---|---|---|
| ' + (ConvertTo-Html (Format-Size $a.bytes)) + ' | ' + (ConvertTo-Html $label) + ' | ' + (ConvertTo-Html $where) + ' |