+
+
$safeHostname
+
Storage: $safeServerStatus | $now
+
+
+$svgChart
+
+
+
+
+| Drive |
+Size |
+Used |
+Free |
+GB/Mo |
+Full |
+Status |
+
+
+$($tableRows.ToString())
+
+
+
+
+v$($Script:VERSION) | $driveCount drives | $totalPoints pts | OLS regression | SQLite
+
+
+"@
+
+ return $html
+}
+
+# ============================================================================
+# ALERTING (Event Log)
+# ============================================================================
+function Initialize-EventSource {
+ try {
+ if (-not [System.Diagnostics.EventLog]::SourceExists($Script:EVENT_SOURCE)) {
+ New-EventLog -LogName Application -Source $Script:EVENT_SOURCE -ErrorAction Stop | Out-Null
+ Write-VerboseLog "Event log source '$($Script:EVENT_SOURCE)' registered"
+ }
+ }
+ catch {
+ Write-Log "WARNING: Could not register event log source: $_"
+ return $false
+ }
+ return $true
+}
+
+function Write-CriticalAlert {
+ param(
+ [array]$CriticalDrives,
+ [string]$Hostname
+ )
+
+ if ($CriticalDrives.Count -eq 0) { return }
+
+ if ($CriticalDrives.Count -eq 1) {
+ $d = $CriticalDrives[0]
+ $daysText = if ($d.DaysUntilFull -match '^\d') { "$($d.DaysUntilFull) days until full" } else { $d.DaysUntilFull }
+ $message = "STORAGE CRITICAL: Server $Hostname - Drive $($d.Letter) has $daysText ($($d.GBPerMonth) GB/month growth rate). Immediate attention required."
+ }
+ else {
+ $lines = "STORAGE CRITICAL: Server $Hostname - Multiple drives require attention:`r`n"
+ foreach ($d in $CriticalDrives) {
+ $daysText = if ($d.DaysUntilFull -match '^\d') { "$($d.DaysUntilFull) days until full" } else { $d.DaysUntilFull }
+ $lines += "- Drive $($d.Letter): $daysText ($($d.GBPerMonth) GB/month)`r`n"
+ }
+ $message = $lines + "Immediate attention required."
+ }
+
+ try {
+ Write-EventLog -LogName Application -Source $Script:EVENT_SOURCE -EventId 5001 -EntryType Warning -Message $message -ErrorAction Stop
+ Write-Log "! Critical alert written to Event Log (ID 5001)"
+ }
+ catch {
+ Write-Log "ERROR: Failed to write Event 5001: $_"
+ }
+}
+
+# ============================================================================
+# NINJA RMM INTEGRATION (single WYSIWYG field)
+# ============================================================================
+function Update-NinjaField {
+ <#
+ .SYNOPSIS
+ Writes the HTML storage report to a single Ninja RMM WYSIWYG custom field.
+ Requires a WYSIWYG field named 'storageReport' in Ninja RMM.
+ #>
+ param([string]$HtmlReport)
+
+ $runningInNinja = $null -ne (Get-Command "Ninja-Property-Set" -ErrorAction SilentlyContinue)
+ if (-not $runningInNinja) { return $false }
+
+ try {
+ Ninja-Property-Set $Script:FIELD_STORAGE_REPORT $HtmlReport
+ return $true
+ }
+ catch {
+ Write-Log "ERROR: Failed to update Ninja field: $_"
+ return $false
+ }
+}
+
+# ============================================================================
+# CONSOLE OUTPUT
+# ============================================================================
+function Write-Summary {
+ param(
+ [string]$Hostname,
+ [string]$ServerStatus,
+ [array]$AllAnalyses,
+ [array]$NewCriticalDrives,
+ [bool]$IsNinja
+ )
+
+ Write-Log "Storage Growth Analysis - $Hostname"
+ Write-Log ("$([char]0x2550)" * 63)
+ Write-Log ""
+
+ # OS Drive first
+ $osDrive = $AllAnalyses | Where-Object { $_.DriveType -eq 'OS' } | Select-Object -First 1
+ if ($osDrive) {
+ Write-Log "OS DRIVE ($($osDrive.Letter))"
+ $labelDisplay = if ($osDrive.VolumeLabel) { $osDrive.VolumeLabel } else { $osDrive.Letter }
+ Write-Log " Drive $($osDrive.Letter) ($labelDisplay)"
+
+ if ($osDrive.CurrentUsedGB -gt 0 -or $osDrive.CurrentFreeGB -gt 0) {
+ Write-Log " Current: $($osDrive.CurrentUsedGB.ToString('F1')) / $($osDrive.TotalSizeGB.ToString('F1')) GB ($($osDrive.CurrentPercent.ToString('F1'))%)"
+ }
+
+ $baseStatus = $osDrive.Status -replace '\s*\(Limited\)', ''
+ if ($baseStatus -ne "Insufficient Data" -and $baseStatus -ne "Offline") {
+ Write-Log " Growth: $($osDrive.GBPerMonth) GB/month"
+ $daysDisplay = $osDrive.DaysUntilFull
+ if ($daysDisplay -match '^\d') { $daysDisplay = "$daysDisplay days" }
+ Write-Log " Status: $($osDrive.Status) ($daysDisplay)"
+ }
+ else {
+ Write-Log " Status: $($osDrive.Status)"
+ }
+ }
+
+ Write-Log ""
+ Write-Log "DATA DRIVES"
+ Write-Log ("$([char]0x2500)" * 63)
+
+ $dataDrives = @($AllAnalyses | Where-Object { $_.DriveType -ne 'OS' })
+ if ($dataDrives.Count -eq 0) {
+ Write-Log " No data drives detected"
+ }
+ else {
+ $idx = 1
+ foreach ($d in $dataDrives) {
+ $labelDisplay = if ($d.VolumeLabel) { $d.VolumeLabel } else { $d.Letter }
+ Write-Log " [$idx] Drive $($d.Letter) ($labelDisplay)"
+
+ if ($d.DriveStatus -eq "Offline") {
+ Write-Log " Status: OFFLINE"
+ }
+ elseif (($d.Status -replace '\s*\(Limited\)', '') -eq "Insufficient Data") {
+ Write-Log " Status: $($d.Status)"
+ }
+ else {
+ if ($d.CurrentUsedGB -gt 0 -or $d.CurrentFreeGB -gt 0) {
+ Write-Log " Current: $($d.CurrentUsedGB.ToString('F1')) / $($d.TotalSizeGB.ToString('F1')) GB ($($d.CurrentPercent.ToString('F1'))%)"
+ }
+ Write-Log " Growth: $($d.GBPerMonth) GB/month"
+ $daysDisplay = $d.DaysUntilFull
+ if ($daysDisplay -match '^\d') { $daysDisplay = "$daysDisplay days" }
+ Write-Log " Status: $($d.Status) ($daysDisplay)"
+
+ $baseSlotStatus = $d.Status -replace '\s*\(Limited\)', ''
+ if ($baseSlotStatus -eq "Critical" -and $NewCriticalDrives) {
+ $isNewCritical = $d.Letter -in @($NewCriticalDrives | ForEach-Object { $_.Letter })
+ if ($isNewCritical) {
+ Write-Log " Alert: NEW - Event 5001 written"
+ }
+ }
+ }
+
+ Write-Log ""
+ $idx++
+ }
+ }
+
+ Write-Log ("$([char]0x2550)" * 63)
+ Write-Log "SERVER STATUS: $($ServerStatus.ToUpper())"
+ Write-Log "Database: $($Script:DB_PATH)"
+ Write-Log ""
+}
+
+# ============================================================================
+# MAIN EXECUTION
+# ============================================================================
+function Main {
+ $hostname = $env:COMPUTERNAME
+ $deviceId = $env:COMPUTERNAME
+ $runningInNinja = $null -ne (Get-Command "Ninja-Property-Set" -ErrorAction SilentlyContinue)
+
+ # -- Step 1: Initialize ---------------------------------------------------
+
+ if (-not $runningInNinja) {
+ Write-Log "*** TEST MODE - Not running in Ninja context ***"
+ Write-Log "Ninja custom field updates will be skipped."
+ Write-Log ""
+ }
+
+ Write-VerboseLog "PowerShell Version: $($PSVersionTable.PSVersion.ToString())"
+ Write-VerboseLog "Script Version: $($Script:VERSION)"
+ Write-VerboseLog "Database Path: $($Script:DB_PATH)"
+
+ # Create storage folder
+ if (-not (Test-Path $Script:STORAGE_PATH)) {
+ try {
+ New-Item -Path $Script:STORAGE_PATH -ItemType Directory -Force -ErrorAction Stop | Out-Null
+ Write-VerboseLog "Created storage folder: $($Script:STORAGE_PATH)"
+ }
+ catch {
+ Write-Log "CRITICAL: Cannot create storage folder: $_"
+ Save-LogFile
+ exit 1
+ }
+ }
+
+ # Initialize PSSQLite module
+ if (-not (Initialize-SQLiteModule)) {
+ Write-Log "CRITICAL: PSSQLite module is required. Cannot proceed."
+ Save-LogFile
+ exit 1
+ }
+
+ # Initialize database schema
+ if (-not (Initialize-Database)) {
+ Write-Log "CRITICAL: Database initialization failed. Cannot proceed."
+ Save-LogFile
+ exit 1
+ }
+
+ # Register event log source
+ $eventLogAvailable = Initialize-EventSource
+
+ # Register device
+ Get-DeviceRecord -DeviceId $deviceId -Hostname $hostname
+
+ # -- Step 2: Migrate Legacy Data ------------------------------------------
+
+ # Check for existing JSON history and migrate (one-time)
+ $existingDrives = @(Get-AllDeviceDrives -DeviceId $deviceId)
+ if ($existingDrives.Count -eq 0) {
+ Import-LegacyJsonHistory -DeviceId $deviceId
+ }
+
+ # -- Step 3: Discover & Collect -------------------------------------------
+
+ $currentDrives = $null
+ try {
+ $currentDrives = @(Get-FilteredDrives)
+ }
+ catch {
+ Write-Log "ERROR: Drive enumeration failed: $_"
+ Save-LogFile
+ exit 2
+ }
+
+ if ($currentDrives.Count -eq 0) {
+ Write-Log "WARNING: No qualifying drives found."
+ }
+
+ # -- Step 4: Update Database ----------------------------------------------
+
+ $now = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ss")
+ $visibleLetters = @($currentDrives | ForEach-Object { $_.Letter })
+
+ # Upsert drives and record daily metrics (idempotent per calendar day)
+ foreach ($drive in $currentDrives) {
+ $driveId = Save-DriveRecord -DeviceId $deviceId -DriveLetter $drive.Letter `
+ -VolumeLabel $drive.VolumeLabel -TotalSizeGB $drive.TotalSizeGB `
+ -DriveType $drive.DriveType -Status "Online"
+
+ Set-DailyMetric -DriveId $driveId -Timestamp $now `
+ -UsedGB $drive.UsedGB -FreeGB $drive.FreeGB -UsagePercent $drive.UsagePercent
+
+ Write-VerboseLog "Drive $($drive.Letter): Metric recorded (Used: $($drive.UsedGB) GB, Free: $($drive.FreeGB) GB)"
+ }
+
+ # Mark drives not currently visible as Offline
+ $allDrives = @(Get-AllDeviceDrives -DeviceId $deviceId)
+ foreach ($dbDrive in $allDrives) {
+ if ($dbDrive.drive_letter -notin $visibleLetters -and $dbDrive.status -ne "Offline") {
+ Write-VerboseLog "Drive $($dbDrive.drive_letter): No longer visible - marking Offline"
+ Set-DriveOffline -DriveId $dbDrive.id
+ }
+ }
+
+ # Remove drives offline > 30 days
+ $removedCount = Remove-StaleDrives -DeviceId $deviceId
+ if ($removedCount -gt 0) {
+ Write-Log "Removed $removedCount stale drives (offline > $($Script:OFFLINE_REMOVAL_DAYS) days)"
+ }
+
+ # Prune old metrics
+ Remove-OldMetrics
+
+ # -- Step 5: Analyze All Drives -------------------------------------------
+
+ $allDrives = @(Get-AllDeviceDrives -DeviceId $deviceId)
+ $allAnalyses = [System.Collections.ArrayList]::new()
+
+ foreach ($dbDrive in $allDrives) {
+ $metrics = @(Get-DriveMetrics -DriveId $dbDrive.id)
+ $analysis = Get-DriveAnalysis -DriveRecord $dbDrive -Metrics $metrics
+ [void]$allAnalyses.Add($analysis)
+ }
+
+ # Sort: OS first, then data drives by criticality
+ $osAnalyses = @($allAnalyses | Where-Object { $_.DriveType -eq 'OS' })
+ $dataAnalyses = @($allAnalyses | Where-Object { $_.DriveType -ne 'OS' })
+ $sortedData = @(Sort-DriveAnalyses -Analyses $dataAnalyses)
+
+ $sortedAll = [System.Collections.ArrayList]::new()
+ foreach ($a in $osAnalyses) { [void]$sortedAll.Add($a) }
+ foreach ($a in $sortedData) { [void]$sortedAll.Add($a) }
+
+ # -- Step 6: Server Status ------------------------------------------------
+
+ $worstSeverity = 0
+ $serverStatus = "Insufficient Data"
+
+ $onlineAnalyses = @($allAnalyses | Where-Object { $_.DriveStatus -ne "Offline" })
+ foreach ($analysis in $onlineAnalyses) {
+ $severity = Get-ServerStatusSeverity -Status $analysis.Status
+ if ($severity -gt $worstSeverity) {
+ $worstSeverity = $severity
+ $serverStatus = $analysis.Status -replace '\s*\(Limited\)', ''
+ }
+ }
+
+ # -- Step 7: Critical Drive Alerts (fire-once) ----------------------------
+
+ $newCriticalDrives = [System.Collections.ArrayList]::new()
+
+ foreach ($analysis in $allAnalyses) {
+ $baseStatus = $analysis.Status -replace '\s*\(Limited\)', ''
+
+ if ($baseStatus -eq "Critical") {
+ if (-not $analysis.AlertSent) {
+ [void]$newCriticalDrives.Add($analysis)
+ Set-DriveAlertSent -DriveId $analysis.DriveId -AlertSent $true
+ Write-VerboseLog "Drive $($analysis.Letter): NEW Critical - writing Event 5001"
+ }
+ }
+ else {
+ if ($analysis.AlertSent) {
+ Set-DriveAlertSent -DriveId $analysis.DriveId -AlertSent $false
+ Write-VerboseLog "Drive $($analysis.Letter): No longer Critical, resetting alert"
+ }
+ }
+ }
+
+ if ($newCriticalDrives.Count -gt 0 -and $eventLogAvailable) {
+ Write-CriticalAlert -CriticalDrives $newCriticalDrives -Hostname $hostname
+ }
+
+ # -- Step 8: Generate Report & Output -------------------------------------
+
+ # Console summary
+ Write-Summary -Hostname $hostname -ServerStatus $serverStatus -AllAnalyses @($sortedAll) `
+ -NewCriticalDrives $newCriticalDrives -IsNinja $runningInNinja
+
+ # Build HTML report with SVG chart
+ $htmlReport = Build-StorageReport -Hostname $hostname -ServerStatus $serverStatus -AllAnalyses @($sortedAll)
+
+ # Update Ninja WYSIWYG field
+ if ($runningInNinja) {
+ $ninjaSuccess = Update-NinjaField -HtmlReport $htmlReport
+ if ($ninjaSuccess) {
+ Write-Log ([char]0x2713 + " WYSIWYG field '$($Script:FIELD_STORAGE_REPORT)' updated")
+ }
+ }
+ else {
+ Write-Log "*** TEST MODE - Ninja field not updated ***"
+ Write-VerboseLog "HTML report generated ($($htmlReport.Length) chars)"
+ }
+
+ # -- Step 9: Finalize -----------------------------------------------------
+
+ # Log database stats
+ $totalDrives = $allDrives.Count
+ try {
+ $totalMetrics = Invoke-SqliteQuery -DataSource $Script:DB_PATH -Query "SELECT COUNT(*) AS cnt FROM metric" -ErrorAction Stop
+ $metricCount = if ($totalMetrics) { $totalMetrics.cnt } else { 0 }
+ }
+ catch { $metricCount = '?' }
+ Write-Log ([char]0x2713 + " Database updated ($totalDrives drives, $metricCount data points)")
+
+ # Write log file
+ Save-LogFile
+
+ exit 0
+}
+
+# ============================================================================
+# ENTRY POINT
+# ============================================================================
+Main