Public/Get-sqmAgentJobScheduleReport.ps1
|
<#
.SYNOPSIS Generates a comprehensive SQL Agent Job Schedule Report. .DESCRIPTION Creates an HTML report showing all SQL Agent jobs with detailed schedule information, execution history, and performance metrics. Report includes: - Job Name (enabled/disabled status) - Schedule (start time, interval, frequency) - Last Execution (time, status, duration) - Next Scheduled Execution - Average Job Duration - Last Error Message (if failed) .PARAMETER SqlInstance SQL Server instance name. Default: current computer. .PARAMETER SqlCredential PSCredential for the SQL Server connection. .PARAMETER OutputPath Folder path for HTML report output. Default: C:\System\WinSrvLog\MSSQL Creates filename: AgentJobSchedule_<instance>_<timestamp>.html .PARAMETER OutputCsv Also export data as CSV file. .PARAMETER EnableException Throw exceptions immediately instead of logging. .EXAMPLE Get-sqmAgentJobScheduleReport -SqlInstance "SQL-Server1" .EXAMPLE Get-sqmAgentJobScheduleReport -SqlInstance "SQL-Server1" -OutputPath "C:\Reports" -OutputCsv .NOTES Requires: - dbatools module - SQL Server 2016+ with Agent enabled - Access to msdb database - Invoke-sqmLogging function .LINK Get-sqmAgentJobHistory Invoke-sqmSetupReport #> function Get-sqmAgentJobScheduleReport { [CmdletBinding()] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory = $false)] [string]$SqlInstance = $env:COMPUTERNAME, [Parameter(Mandatory = $false)] [System.Management.Automation.PSCredential]$SqlCredential, [Parameter(Mandatory = $false)] [string]$OutputPath = 'C:\System\WinSrvLog\MSSQL', [Parameter(Mandatory = $false)] [switch]$OutputCsv, [Parameter(Mandatory = $false)] [switch]$EnableException ) begin { $functionName = $MyInvocation.MyCommand.Name $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' # Validate dbatools if (-not (Get-Module dbatools -ListAvailable -ErrorAction SilentlyContinue)) { $errMsg = "dbatools module not found. Install: Install-Module dbatools -Force" Invoke-sqmLogging -Message $errMsg -FunctionName $functionName -Level "ERROR" if ($EnableException) { throw $errMsg } else { return } } # Create output directory if needed if (-not (Test-Path $OutputPath)) { try { $null = New-Item -ItemType Directory -Path $OutputPath -Force -ErrorAction Stop Invoke-sqmLogging -Message "Created output directory: $OutputPath" ` -FunctionName $functionName -Level "INFO" } catch { $errMsg = "Failed to create output directory '$OutputPath': $($_.Exception.Message)" Invoke-sqmLogging -Message $errMsg -FunctionName $functionName -Level "ERROR" if ($EnableException) { throw $errMsg } else { return } } } # Verify directory is writable try { $testFile = Join-Path $OutputPath ".sqmtest" $null = "test" | Out-File -FilePath $testFile -Encoding UTF8 -Force -ErrorAction Stop Remove-Item $testFile -Force -ErrorAction SilentlyContinue } catch { $errMsg = "Output directory '$OutputPath' is not writable: $($_.Exception.Message)" Invoke-sqmLogging -Message $errMsg -FunctionName $functionName -Level "ERROR" if ($EnableException) { throw $errMsg } else { return } } $jobData = @() $cleanInstance = $SqlInstance -replace '\\.*$', '' # Remove \INSTANCE suffix for filename } process { try { Invoke-sqmLogging -Message "Starting Agent Job Report for $SqlInstance" ` -FunctionName $functionName -Level "INFO" # Get all SQL Agent jobs $jobs = Get-DbaAgentJob -SqlInstance $SqlInstance -SqlCredential $SqlCredential ` -ErrorAction Stop if (-not $jobs) { Invoke-sqmLogging -Message "No SQL Agent jobs found on $SqlInstance" ` -FunctionName $functionName -Level "WARNING" } # Get job execution history from msdb $jobHistoryQuery = @" SELECT sj.name AS JobName, sj.enabled AS IsEnabled, ss.name AS ScheduleName, ss.freq_type AS FrequencyType, ss.freq_interval AS FrequencyInterval, ss.freq_subday_type AS SubdayType, ss.freq_subday_interval AS SubdayInterval, ss.active_start_time AS ActiveStartTime, MAX(jh.run_date) AS LastRunDate, MAX(jh.run_time) AS LastRunTime, MAX(CASE WHEN jh.run_status = 1 THEN 'Success' ELSE 'Failed' END) AS LastRunStatus, AVG(CAST( (jh.run_duration / 10000 * 3600) + ((jh.run_duration % 10000) / 100 * 60) + (jh.run_duration % 100) AS FLOAT)) AS AvgDurationSeconds, MAX(jh.message) AS LastErrorMessage FROM msdb.dbo.sysjobs sj LEFT JOIN msdb.dbo.sysjobschedules sjs ON sj.job_id = sjs.job_id LEFT JOIN msdb.dbo.sysschedules ss ON sjs.schedule_id = ss.schedule_id LEFT JOIN msdb.dbo.sysjobhistory jh ON sj.job_id = jh.job_id AND jh.step_id = 0 GROUP BY sj.name, sj.enabled, ss.name, ss.freq_type, ss.freq_interval, ss.freq_subday_type, ss.freq_subday_interval, ss.active_start_time ORDER BY sj.name "@ $jobHistoryData = Invoke-DbaQuery -SqlInstance $SqlInstance ` -SqlCredential $SqlCredential ` -Database 'msdb' ` -Query $jobHistoryQuery ` -ErrorAction Stop # Process each job foreach ($job in $jobs) { $history = $jobHistoryData | Where-Object { $_.JobName -eq $job.Name } # Parse schedule frequency $scheduleText = if ($history) { _ConvertJobSchedule -FrequencyType $history.FrequencyType ` -FrequencyInterval $history.FrequencyInterval ` -SubdayType $history.SubdayType ` -SubdayInterval $history.SubdayInterval ` -StartTime $history.ActiveStartTime } else { 'No Schedule' } # Parse last execution $lastRunDateTime = if ($history.LastRunDate) { _ConvertJobRunTime -RunDate $history.LastRunDate -RunTime $history.LastRunTime } else { $null } # Calculate average duration (safe conversion from SQL NULL/empty) $avgDuration = if ($history.AvgDurationSeconds -and [int]::TryParse([string]$history.AvgDurationSeconds, [ref]$null)) { $seconds = [int][string]$history.AvgDurationSeconds "$([math]::Floor($seconds / 60))m $($seconds % 60)s" } else { 'N/A' } # Next execution estimate $nextExecution = if ($lastRunDateTime -and $scheduleText -ne 'No Schedule') { _EstimateNextExecution -LastRun $lastRunDateTime -Schedule $scheduleText } else { 'N/A' } $jobData += [PSCustomObject]@{ JobName = $job.Name Enabled = if ($job.IsEnabled) { 'Yes' } else { 'No' } ScheduleName = if ($history.ScheduleName) { $history.ScheduleName } else { 'Not Scheduled' } Schedule = $scheduleText LastExecution = if ($lastRunDateTime) { $lastRunDateTime } else { 'Never' } LastStatus = if ($history.LastRunStatus) { $history.LastRunStatus } else { 'Unknown' } NextExecution = $nextExecution AvgDuration = $avgDuration LastError = if ($history.LastErrorMessage -and $history.LastRunStatus -eq 'Failed') { $cleanMsg = ($history.LastErrorMessage -replace '\[.*?\]', '' -replace '\r\n', ' ').Trim() $cleanMsg.Substring(0, [math]::Min(100, $cleanMsg.Length)) } else { 'N/A' } RawAvgSeconds = if ($history.AvgDurationSeconds -and [int]::TryParse([string]$history.AvgDurationSeconds, [ref]$null)) { [int][string]$history.AvgDurationSeconds } else { 0 } } } # Export CSV if requested if ($OutputCsv -and $jobData) { $csvPath = Join-Path $OutputPath "AgentJobSchedule_${cleanInstance}_${timestamp}.csv" try { $jobData | Export-Csv -Path $csvPath -NoTypeInformation -Force -ErrorAction Stop Invoke-sqmLogging -Message "CSV exported to: $csvPath" ` -FunctionName $functionName -Level "INFO" } catch { $csvErr = "Failed to write CSV to '$csvPath': $($_.Exception.Message)" Invoke-sqmLogging -Message $csvErr -FunctionName $functionName -Level "ERROR" throw $csvErr } } # Generate HTML report $htmlPath = Join-Path $OutputPath "AgentJobSchedule_${cleanInstance}_${timestamp}.html" $html = _GenerateAgentJobHtml -JobData $jobData -SqlInstance $SqlInstance -Timestamp $timestamp try { $html | Out-File -FilePath $htmlPath -Encoding UTF8 -Force -ErrorAction Stop Invoke-sqmLogging -Message "HTML report generated: $htmlPath" ` -FunctionName $functionName -Level "INFO" } catch { $fileErr = "Failed to write HTML report to '$htmlPath': $($_.Exception.Message)" Invoke-sqmLogging -Message $fileErr -FunctionName $functionName -Level "ERROR" throw $fileErr } } catch { $errMsg = "Error generating Agent Job Report: $($_.Exception.Message)" Invoke-sqmLogging -Message $errMsg -FunctionName $functionName -Level "ERROR" if ($EnableException) { throw } else { return } } } end { # Return all job data collected from process block # Note: If reports failed to write, error will have been thrown in process block # Only reach here if process completed successfully return $jobData } } # ============ Helper Functions ============ function _ConvertJobSchedule { param( [object]$FrequencyType, [object]$FrequencyInterval, [object]$SubdayType, [object]$SubdayInterval, [object]$StartTime ) # Convert to int, handle NULL/empty [int]$FrequencyType = if ([int]::TryParse($FrequencyType, [ref]$null)) { [int]$FrequencyType } else { 0 } [int]$FrequencyInterval = if ([int]::TryParse($FrequencyInterval, [ref]$null)) { [int]$FrequencyInterval } else { 0 } [int]$SubdayType = if ([int]::TryParse($SubdayType, [ref]$null)) { [int]$SubdayType } else { 0 } [int]$SubdayInterval = if ([int]::TryParse($SubdayInterval, [ref]$null)) { [int]$SubdayInterval } else { 0 } [int]$StartTime = if ([int]::TryParse($StartTime, [ref]$null)) { [int]$StartTime } else { 0 } if (-not $FrequencyType) { return 'No Schedule' } # Frequency Type: 1=Once, 4=Daily, 8=Weekly, 16=Monthly, 32=Monthly Relative, 64=When Agent starts, 128=When CPU idle $freqDesc = switch ($FrequencyType) { 1 { 'One Time' } 4 { "Daily (every $FrequencyInterval day(s))" } 8 { 'Weekly' } 16 { "Monthly (day $FrequencyInterval)" } 32 { 'Monthly (relative)' } 64 { 'When SQL Agent starts' } 128 { 'When CPU is idle' } default { "Frequency Type: $FrequencyType" } } # Add subday interval if present # freq_subday_type: 1=AtSpecifiedTime, 2=Seconds, 4=Minutes, 8=Hours, 16=Days if ($SubdayType -and $SubdayInterval) { $subdayDesc = switch ($SubdayType) { 1 { '' } # At specified time only - no subday interval 2 { "every $SubdayInterval second(s)" } 4 { "every $SubdayInterval minute(s)" } 8 { "every $SubdayInterval hour(s)" } 16 { "every $SubdayInterval day(s)" } default { '' } } if ($subdayDesc) { $freqDesc += " $subdayDesc" } } # Add start time (format: HHMMSS) if ($StartTime -gt 0) { $hours = [int][math]::Floor($StartTime / 10000) $minutes = [int][math]::Floor(($StartTime % 10000) / 100) $seconds = [int]($StartTime % 100) # Format as HH:MM or HH:MM:SS if seconds present if ($seconds -gt 0) { $timeStr = "{0:D2}:{1:D2}:{2:D2}" -f $hours, $minutes, $seconds } else { $timeStr = "{0:D2}:{1:D2}" -f $hours, $minutes } $freqDesc += " @ $timeStr" } return $freqDesc } function _ConvertJobRunTime { param( [object]$RunDate, [object]$RunTime ) # Safe conversion from SQL NULL/empty [int]$RunDate = if ([int]::TryParse([string]$RunDate, [ref]$null)) { [int][string]$RunDate } else { 0 } [int]$RunTime = if ([int]::TryParse([string]$RunTime, [ref]$null)) { [int][string]$RunTime } else { 0 } # If date is 0 (NULL/empty from SQL), return null if ($RunDate -eq 0) { return $null } try { $dateStr = $RunDate.ToString('00000000') $timeStr = $RunTime.ToString('000000') $year = [int]$dateStr.Substring(0, 4) $month = [int]$dateStr.Substring(4, 2) $day = [int]$dateStr.Substring(6, 2) $hour = [int]$timeStr.Substring(0, 2) $minute = [int]$timeStr.Substring(2, 2) $second = [int]$timeStr.Substring(4, 2) $dt = New-Object DateTime($year, $month, $day, $hour, $minute, $second) return $dt.ToString('yyyy-MM-dd HH:mm:ss') } catch { return 'Invalid DateTime' } } function _EstimateNextExecution { param( [datetime]$LastRun, [string]$Schedule ) # Simple heuristics based on schedule text if ($Schedule -match 'Daily.*every (\d+)') { $days = [int]$matches[1] return ($LastRun.AddDays($days)).ToString('yyyy-MM-dd HH:mm') } elseif ($Schedule -match 'every (\d+) hour') { $hours = [int]$matches[1] return ($LastRun.AddHours($hours)).ToString('yyyy-MM-dd HH:mm') } elseif ($Schedule -match 'every (\d+) minute') { $minutes = [int]$matches[1] return ($LastRun.AddMinutes($minutes)).ToString('yyyy-MM-dd HH:mm') } else { return 'N/A' } } function _GenerateAgentJobHtml { param( [PSCustomObject[]]$JobData, [string]$SqlInstance, [string]$Timestamp ) $reportDate = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $successCount = ($JobData | Where-Object { $_.LastStatus -eq 'Success' }).Count $failureCount = ($JobData | Where-Object { $_.LastStatus -eq 'Failed' }).Count $disabledCount = ($JobData | Where-Object { $_.Enabled -eq 'No' }).Count $html = @" <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>SQL Agent Job Schedule Report</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Segoe UI', Arial, sans-serif; background: #0f172a; color: #e2e8f0; padding: 20px; } .container { max-width: 1600px; margin: 0 auto; } .header { background: linear-gradient(160deg, #1e3a8a 0%, #2e5090 100%); padding: 30px; border-radius: 12px; margin-bottom: 30px; border-left: 4px solid #3b82f6; } .header h1 { color: #60a5fa; font-size: 2em; margin-bottom: 10px; } .header p { color: #94a3b8; font-size: 0.95em; } .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 15px; margin-bottom: 30px; } .stat-card { background: #0b1e3d; border: 1px solid #3b82f6; padding: 20px; border-radius: 8px; text-align: center; } .stat-value { font-size: 2.2em; font-weight: bold; color: #34d399; margin-bottom: 5px; } .stat-label { color: #94a3b8; font-size: 0.9em; } .controls { display: flex; gap: 10px; margin-bottom: 20px; } .controls input { background: #0b1e3d; border: 1px solid #3b82f6; color: #e2e8f0; padding: 10px; border-radius: 6px; flex: 1; max-width: 300px; } .controls button { background: #1e3a8a; border: 1px solid #3b82f6; color: #60a5fa; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-weight: bold; } .controls button:hover { background: #2e5090; } table { width: 100%; border-collapse: collapse; background: #0b1e3d; border-radius: 8px; overflow: hidden; margin-bottom: 30px; } th { background: #1e3a8a; color: #60a5fa; padding: 15px; text-align: left; font-weight: 600; border-bottom: 2px solid #3b82f6; } td { padding: 12px 15px; border-bottom: 1px solid #2d5a8c; } tr:hover { background: #1a2f4d; } .job-name { color: #60a5fa; font-weight: 600; font-family: monospace; } .status-success { color: #34d399; font-weight: bold; } .status-failed { color: #ef4444; font-weight: bold; } .status-unknown { color: #94a3b8; } .enabled-yes { color: #34d399; } .enabled-no { color: #f59e0b; } .footer { color: #94a3b8; font-size: 0.85em; text-align: center; margin-top: 50px; padding-top: 20px; border-top: 1px solid #3b82f6; } </style> </head> <body> <div class="container"> <div class="header"> <h1>📊 SQL Agent Job Schedule Report</h1> <p>Server: <strong>$SqlInstance</strong> | Generated: $reportDate</p> </div> <div class="stats"> <div class="stat-card"> <div class="stat-value">$($JobData.Count)</div> <div class="stat-label">Total Jobs</div> </div> <div class="stat-card"> <div class="stat-value" style="color: #34d399;">$successCount</div> <div class="stat-label">Last Success</div> </div> <div class="stat-card"> <div class="stat-value" style="color: #ef4444;">$failureCount</div> <div class="stat-label">Last Failed</div> </div> <div class="stat-card"> <div class="stat-value" style="color: #f59e0b;">$disabledCount</div> <div class="stat-label">Disabled</div> </div> </div> <div class="controls"> <input type="text" id="searchBox" placeholder="Search job name..." onkeyup="filterTable()"> <button onclick="sortTable('JobName')">Sort by Name</button> <button onclick="sortTable('AvgDuration')">Sort by Duration</button> </div> <table id="jobTable"> <thead> <tr> <th style="width: 20%;">Job Name</th> <th style="width: 8%;">Status</th> <th style="width: 8%;">Enabled</th> <th style="width: 20%;">Schedule</th> <th style="width: 14%;">Last Execution</th> <th style="width: 12%;">Next Execution</th> <th style="width: 10%;">Avg Duration</th> <th style="width: 18%;">Last Error</th> </tr> </thead> <tbody> "@ # Add table rows foreach ($job in $JobData) { $statusClass = switch ($job.LastStatus) { 'Success' { 'status-success' } 'Failed' { 'status-failed' } default { 'status-unknown' } } $enabledClass = switch ($job.Enabled) { 'Yes' { 'enabled-yes' } 'No' { 'enabled-no' } } $html += @" <tr> <td class="job-name">$($job.JobName)</td> <td class="$statusClass">$($job.LastStatus)</td> <td class="$enabledClass">$($job.Enabled)</td> <td style="font-size: 0.9em; color: #94a3b8;">$($job.Schedule)</td> <td style="color: #5dade2;">$($job.LastExecution)</td> <td style="color: #5dade2;">$($job.NextExecution)</td> <td style="font-weight: 600;">$($job.AvgDuration)</td> <td style="color: #f59e0b; font-size: 0.85em;">$($job.LastError)</td> </tr> "@ } $html += @" </tbody> </table> <div class="footer"> <p>Generated by sqmSQLTool | Get-sqmAgentJobScheduleReport</p> <p>Report ID: $Timestamp</p> </div> </div> <script> function filterTable() { const input = document.getElementById('searchBox'); const filter = input.value.toLowerCase(); const table = document.getElementById('jobTable'); const rows = table.getElementsByTagName('tr'); for (let i = 1; i < rows.length; i++) { const jobName = rows[i].cells[0].textContent.toLowerCase(); rows[i].style.display = jobName.includes(filter) ? '' : 'none'; } } function sortTable(column) { const table = document.getElementById('jobTable'); const tbody = table.querySelector('tbody'); const rows = Array.from(tbody.querySelectorAll('tr')); const columnIndex = { 'JobName': 0, 'LastStatus': 1, 'AvgDuration': 6 }[column] || 0; rows.sort((a, b) => { const aText = a.cells[columnIndex].textContent.trim(); const bText = b.cells[columnIndex].textContent.trim(); return aText.localeCompare(bText); }); rows.forEach(row => tbody.appendChild(row)); } </script> </body> </html> "@ return $html } |