diff --git a/backend/Config/CIPPTimers.json b/backend/Config/CIPPTimers.json index e64cc3ccb5..82158d849c 100644 --- a/backend/Config/CIPPTimers.json +++ b/backend/Config/CIPPTimers.json @@ -284,5 +284,13 @@ "RunOnProcessor": true, "TZOffset": true, "PreferredProcessor": "standards" + }, + { + "Id": "0b31a4b7-4104-479e-a358-a57675f80bd5", + "Command": "Start-CIPPTaskPreflightCheck", + "Description": "Flag planned scheduled tasks whose prerequisites are no longer available", + "Cron": "0 7 */6 * * *", + "Priority": 25, + "RunOnProcessor": true } ] diff --git a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 index b37e214051..3512149cba 100644 --- a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 +++ b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 @@ -39,12 +39,12 @@ function Push-ExecScheduledCommand { $CurrentTask = Get-AzDataTableEntity @Table -Filter "PartitionKey eq '$($task.PartitionKey)' and RowKey eq '$($task.RowKey)'" if (!$CurrentTask) { Write-Information "The task $($task.Name) for tenant $($task.Tenant) does not exist in the ScheduledTasks table. Exiting." - Remove-Variable -Name ScheduledTaskId -Scope Script -ErrorAction SilentlyContinue + Set-CippScheduledTaskContext -TaskId '' return } if ($CurrentTask.TaskState -eq 'Completed' -and !$IsMultiTenantTask) { Write-Information "The task $($task.Name) for tenant $($task.Tenant) is already completed. Skipping execution." - Remove-Variable -Name ScheduledTaskId -Scope Script -ErrorAction SilentlyContinue + Set-CippScheduledTaskContext -TaskId '' return } # Task should be 'Pending' (queued by orchestrator) or 'Running' (retry/recovery) @@ -68,7 +68,7 @@ function Push-ExecScheduledCommand { # If executed within last 15 minutes, skip (likely a duplicate pickup) if ($timeSinceExecution -lt 900) { Write-Information "One-time task $($task.Name) for tenant $Tenant was recently executed ($timeSinceExecution seconds ago). Skipping to prevent duplicate execution." - Remove-Variable -Name ScheduledTaskId -Scope Script -ErrorAction SilentlyContinue + Set-CippScheduledTaskContext -TaskId '' return } } @@ -114,7 +114,7 @@ function Push-ExecScheduledCommand { TaskState = 'Planned' ScheduledTime = [string]$nextRunUnixTime } - Remove-Variable -Name ScheduledTaskId -Scope Script -ErrorAction SilentlyContinue + Set-CippScheduledTaskContext -TaskId '' return } } @@ -142,11 +142,16 @@ function Push-ExecScheduledCommand { RowKey = $task.RowKey Results = "$Results" TaskState = $State + HasErrors = $true + ErrorSummary = "$Results" + AtRisk = $false + AtRiskReason = '' + Acknowledged = $false } } Write-LogMessage -API 'Scheduler_UserTasks' -tenant $Tenant -tenantid $TenantInfo.customerId -message "Failed to execute task $($task.Name): The command $($Item.Command) does not exist." -sev Error - Remove-Variable -Name ScheduledTaskId -Scope Script -ErrorAction SilentlyContinue + Set-CippScheduledTaskContext -TaskId '' return } @@ -160,9 +165,14 @@ function Push-ExecScheduledCommand { RowKey = $task.RowKey Results = "$Results" TaskState = $State + HasErrors = $true + ErrorSummary = "$Results" + AtRisk = $false + AtRiskReason = '' + Acknowledged = $false } } - Remove-Variable -Name ScheduledTaskId -Scope Script -ErrorAction SilentlyContinue + Set-CippScheduledTaskContext -TaskId '' return } if ($Item.Command -in (Get-CIPPSchedulerBlockedCommands)) { @@ -175,9 +185,14 @@ function Push-ExecScheduledCommand { RowKey = $task.RowKey Results = "$Results" TaskState = $State + HasErrors = $true + ErrorSummary = "$Results" + AtRisk = $false + AtRiskReason = '' + Acknowledged = $false } } - Remove-Variable -Name ScheduledTaskId -Scope Script -ErrorAction SilentlyContinue + Set-CippScheduledTaskContext -TaskId '' return } @@ -265,7 +280,16 @@ function Push-ExecScheduledCommand { Write-Information "Starting task: $($Item.Command) for tenant: $Tenant with parameters: $($commandParameters | ConvertTo-Json -Depth 10)" $results = & $Item.Command @commandParameters } catch { - $results = "Task Failed: $($_.Exception.Message)" + # Commands like New-CIPPUserTask throw a hashtable carrying their partial results + # (throw @{'Results' = ...}). A thrown hashtable's Exception.Message is the literal + # type name, so unwrap it - otherwise the stored result reads + # "Task Failed: System.Collections.Hashtable" instead of what actually went wrong. + $ThrownObject = $_.TargetObject + $results = if ($ThrownObject -is [System.Collections.IDictionary] -and $ThrownObject.Results) { + "Task Failed: $(@($ThrownObject.Results) -join ' | ')" + } else { + "Task Failed: $($_.Exception.Message)" + } $State = 'Failed' } Write-Information 'Ran the command. Processing results' @@ -354,9 +378,15 @@ function Push-ExecScheduledCommand { Results = "$errorMessage" ScheduledTime = "$nextRunUnixTime" TaskState = $State + HasErrors = $true + ErrorSummary = "$errorMessage" + AtRisk = $false + AtRiskReason = '' + Acknowledged = $false } } Write-LogMessage -API 'Scheduler_UserTasks' -tenant $Tenant -tenantid $TenantInfo.customerId -message "Failed to execute task $($task.Name): $errorMessage" -sev Error -LogData (Get-CippExceptionData -Exception $_.Exception) + $TaskFailureLogged = $true } # For orchestrator-based commands, skip post-execution alerts as they will be handled by the orchestrator's post-execution function @@ -374,6 +404,21 @@ function Push-ExecScheduledCommand { Send-CIPPScheduledTaskAlert @AlertParams } + # A task can finish without throwing while a step inside it failed - the clearest case being a user + # that is created successfully but never licensed because the tenant has no licences left. Those steps + # log at Error severity, so the errors collected against this task's context are what tell the two + # apart. Without this the row reads Completed and the failure only exists as text inside Results. + $TaskErrors = @(Get-CippScheduledTaskError) + $TaskHasErrors = $TaskErrors.Count -gt 0 + $TaskErrorSummary = if ($TaskHasErrors) { $TaskErrors -join ' | ' } else { '' } + if ($TaskErrorSummary.Length -gt 4000) { + $TaskErrorSummary = $TaskErrorSummary.Substring(0, 4000) + } + + # Every terminal write below also clears AtRisk: the preflight flag is a prediction about a run + # that has now happened, and nothing else ever re-examines a task that has left the Planned + # state - without this, a flagged task that ran would sit in the at-risk view forever. + try { # For orchestrator-based commands, skip task state update as it will be handled by post-execution if ($Item.Command -in $OrchestratorBasedCommands) { @@ -386,6 +431,11 @@ function Push-ExecScheduledCommand { RowKey = $task.RowKey Results = "$results" TaskState = 'Failed' + HasErrors = $true + ErrorSummary = "$results" + AtRisk = $false + AtRiskReason = '' + Acknowledged = $false } } else { # Update task state to 'Processing' to indicate orchestration is in progress @@ -402,12 +452,20 @@ function Push-ExecScheduledCommand { # The PostExecution function will aggregate all results and update the parent task Write-Information "Multi-tenant execution for tenant $Tenant - parent task state will be updated by PostExecution" } elseif ($task.Recurrence -eq '0' -or [string]::IsNullOrEmpty($task.Recurrence) -or $Trigger.ExecutionMode.value -eq 'once' -or $Trigger.ExecutionMode -eq 'once') { - Write-Information 'Recurrence empty or 0. Task is not recurring. Setting task state to completed.' + # $State is set to 'Failed' when the command threw. Before, it was only honoured for + # orchestrator-based commands, so every other failing task was written as Completed. + $FinalState = if ($State -eq 'Failed') { 'Failed' } else { 'Completed' } + Write-Information "Recurrence empty or 0. Task is not recurring. Setting task state to $FinalState." Update-AzDataTableEntity -Force @Table -Entity @{ PartitionKey = $task.PartitionKey RowKey = $task.RowKey Results = "$StoredResults" - TaskState = 'Completed' + TaskState = $FinalState + HasErrors = $TaskHasErrors + ErrorSummary = $TaskErrorSummary + AtRisk = $false + AtRiskReason = '' + Acknowledged = $false } } else { #if recurrence is just a number, add it in days. @@ -429,22 +487,41 @@ function Push-ExecScheduledCommand { } $nextRunUnixTime = [int64]$task.ScheduledTime + [int64]$secondsToAdd + # A recurring task that failed still has to be rescheduled, so it goes to 'Failed - Planned' + # rather than 'Failed' - the orchestrator picks that state back up on the next cycle. + $FinalState = if ($State -eq 'Failed') { 'Failed - Planned' } else { 'Planned' } Write-Information "The job is recurring. It was scheduled for $($task.ScheduledTime). The next runtime should be $nextRunUnixTime" Update-AzDataTableEntity -Force @Table -Entity @{ PartitionKey = $task.PartitionKey RowKey = $task.RowKey Results = "$StoredResults" - TaskState = 'Planned' + TaskState = $FinalState ScheduledTime = "$nextRunUnixTime" + HasErrors = $TaskHasErrors + ErrorSummary = $TaskErrorSummary + AtRisk = $false + AtRiskReason = '' + Acknowledged = $false } } } catch { Write-Warning "Failed to update task state: $($_.Exception.Message)" Write-Information $_.InvocationInfo.PositionMessage } - if ($TaskType -ne 'Alert') { - Write-LogMessage -API 'Scheduler_UserTasks' -tenant $Tenant -tenantid $TenantInfo.customerId -message "Successfully executed task: $($task.Name)" -sev Info + if ($TaskType -ne 'Alert' -and -not $TaskFailureLogged) { + if ($State -eq 'Failed') { + # $StoredResults, not $results: by this point a thrown command's message has been wrapped + # into a hashtable by the result processing above, which interpolates as + # "System.Collections.Hashtable" and loses the error entirely. + Write-LogMessage -API 'Scheduler_UserTasks' -tenant $Tenant -tenantid $TenantInfo.customerId -message "Failed to execute task $($task.Name): $StoredResults" -sev Error + } elseif ($TaskHasErrors) { + # The task itself ran, but a step inside it failed. Logged as an error so it can raise a + # notification rather than being reported as a clean success. + Write-LogMessage -API 'Scheduler_UserTasks' -tenant $Tenant -tenantid $TenantInfo.customerId -message "Executed task $($task.Name) with errors: $TaskErrorSummary" -sev Error + } else { + Write-LogMessage -API 'Scheduler_UserTasks' -tenant $Tenant -tenantid $TenantInfo.customerId -message "Successfully executed task: $($task.Name)" -sev Info + } } - Remove-Variable -Name ScheduledTaskId -Scope Script -ErrorAction SilentlyContinue + Set-CippScheduledTaskContext -TaskId '' return 'Task Completed Successfully.' } diff --git a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ScheduledTaskPostExecution.ps1 b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ScheduledTaskPostExecution.ps1 index 949cceed7d..c1617e90a5 100644 --- a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ScheduledTaskPostExecution.ps1 +++ b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ScheduledTaskPostExecution.ps1 @@ -65,6 +65,14 @@ function Push-ScheduledTaskPostExecution { # Prepare aggregated results message $AggregatedMessage = "Multi-tenant task completed: $SuccessCount successful, $FailureCount failed (Total: $TotalTenants tenants)" + # The task state below only goes to Failed when *every* tenant failed, so a partial failure - 3 of + # 50 tenants - otherwise reports as a clean Completed and is invisible. Flag any failure so those + # tasks still surface for review. + $HasFailures = $FailureCount -gt 0 + $FailureSummary = if ($HasFailures) { + "$FailureCount of $TotalTenants tenant(s) failed. Per-tenant detail is available in the More Info pane." + } else { '' } + # Calculate next run time for recurring tasks if ($IsRecurring -and !$IsTriggerOnce) { # Convert recurrence to seconds @@ -95,6 +103,9 @@ function Push-ScheduledTaskPostExecution { Results = $AggregatedMessage TaskState = if ($FailureCount -gt 0 -and $FailureCount -eq $TotalTenants) { 'Failed - Planned' } else { 'Planned' } ScheduledTime = "$nextRunUnixTime" + HasErrors = $HasFailures + ErrorSummary = $FailureSummary + Acknowledged = $false } } else { # Invalid recurrence, mark as completed @@ -104,6 +115,9 @@ function Push-ScheduledTaskPostExecution { RowKey = $ParentTask.RowKey Results = "$AggregatedMessage - Warning: Invalid recurrence, task will not repeat" TaskState = 'Completed' + HasErrors = $HasFailures + ErrorSummary = $FailureSummary + Acknowledged = $false } } } else { @@ -114,6 +128,9 @@ function Push-ScheduledTaskPostExecution { RowKey = $ParentTask.RowKey Results = $AggregatedMessage TaskState = if ($FailureCount -gt 0 -and $FailureCount -eq $TotalTenants) { 'Failed' } else { 'Completed' } + HasErrors = $HasFailures + ErrorSummary = $FailureSummary + Acknowledged = $false } } diff --git a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPTaskPreflightCheck.ps1 b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPTaskPreflightCheck.ps1 new file mode 100644 index 0000000000..b6e0f92337 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPTaskPreflightCheck.ps1 @@ -0,0 +1,163 @@ +function Start-CIPPTaskPreflightCheck { + <# + .SYNOPSIS + Flag planned scheduled tasks whose prerequisites are no longer available + + .DESCRIPTION + A task scheduled days in advance can become undeliverable long before it runs. The clearest case is a + scheduled user creation that needs a licence: the licence is available when the job is booked, gets + consumed by someone else in the meantime, and the job only fails once the scheduled time arrives. + + This walks the planned tasks that depend on a licence and compares what they ask for against what the + tenant actually has left, marking the ones that would fail today so they can be dealt with in advance + rather than discovered afterwards. + + Only tasks that reference licences are inspected, so the cost scales with the number of licence-bearing + scheduled tasks rather than the size of the table. + + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param() + + $Table = Get-CippTable -tablename 'ScheduledTasks' + $Filter = "PartitionKey eq 'ScheduledTask' and TaskState eq 'Planned'" + $Tasks = Get-CIPPAzDataTableEntity @Table -Filter $Filter + + Write-Information "Preflight: retrieved $(($Tasks | Measure-Object).Count) planned scheduled tasks." + + # Work out which tasks depend on a licence, and which SKUs each one needs. + $LicenseTasks = [System.Collections.Generic.List[object]]::new() + foreach ($Task in $Tasks) { + if (!$Task.Command -or !$Task.Parameters) { continue } + + try { + $Parameters = $Task.Parameters | ConvertFrom-Json -ErrorAction Stop + } catch { + Write-Information "Preflight: could not parse parameters for task $($Task.RowKey), skipping." + continue + } + + # A Sherweb-backed creation buys the subscription during the run before assigning it, so the + # tenant having none right now says nothing about whether the task will succeed. Flagging + # those would be a false alarm on every single one. + if ($Task.Command -eq 'New-CIPPUserTask' -and $Parameters.UserObj.sherwebLicense.value) { + Write-Information "Preflight: task $($Task.RowKey) buys a Sherweb licence at run time, skipping." + continue + } + + $RequestedSkus = switch ($Task.Command) { + 'New-CIPPUserTask' { @($Parameters.UserObj.licenses.value) } + 'Set-CIPPUserLicense' { @($Parameters.AddLicenses) } + default { @() } + } + + $RequestedSkus = @($RequestedSkus | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($RequestedSkus.Count -eq 0) { continue } + + # Tenant on the row is the display value; the parameters carry the authoritative filter. + $TenantFilter = $Parameters.UserObj.tenantFilter ?? $Parameters.TenantFilter ?? $Task.Tenant + if ([string]::IsNullOrWhiteSpace($TenantFilter) -or $TenantFilter -eq 'AllTenants') { continue } + + $LicenseTasks.Add([PSCustomObject]@{ + Task = $Task + TenantFilter = $TenantFilter + Skus = $RequestedSkus + }) + } + + if ($LicenseTasks.Count -eq 0) { + Write-Information 'Preflight: no planned tasks depend on a licence.' + return + } + + Write-Information "Preflight: checking $($LicenseTasks.Count) licence-dependent task(s)." + + # One subscribedSkus call per distinct tenant, not per task. Deliberately not Get-CIPPLicenseOverview, + # which also expands every assigned user and group and is far more than is needed here. + # Loaded once, not per SKU: Convert-SKUname re-reads the conversion CSV on every call otherwise. + $ConvertTable = try { + [System.IO.File]::ReadAllText((Join-Path $env:CIPPRootPath 'Config\ConversionTable.csv')) | ConvertFrom-Csv + } catch { $null } + function Get-FriendlySkuName { + param($SkuId, $Fallback) + $Name = if ($ConvertTable) { Convert-SKUname -SkuID $SkuId -ConvertTable $ConvertTable } + # Unmapped SKUs come back as an array of the inputs rather than a name - use the fallback then. + if ($Name -is [string] -and -not [string]::IsNullOrWhiteSpace($Name)) { $Name } else { $Fallback } + } + + $AvailabilityByTenant = @{} + foreach ($TenantFilter in ($LicenseTasks.TenantFilter | Sort-Object -Unique)) { + try { + $Skus = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/subscribedSkus' -tenantid $TenantFilter + $Available = @{} + foreach ($Sku in $Skus) { + $Available[([string]$Sku.skuId).ToLowerInvariant()] = [PSCustomObject]@{ + Available = [int]$Sku.prepaidUnits.enabled - [int]$Sku.consumedUnits + SkuPartNumber = $Sku.skuPartNumber + } + } + $AvailabilityByTenant[$TenantFilter] = $Available + } catch { + # A tenant we cannot read is not evidence that anything is wrong, so leave its tasks alone + # rather than flagging them on a lookup failure. + Write-Information "Preflight: could not read licences for $TenantFilter, skipping its tasks. $($_.Exception.Message)" + } + } + + foreach ($Entry in $LicenseTasks) { + $Available = $AvailabilityByTenant[$Entry.TenantFilter] + if ($null -eq $Available) { continue } + + $Task = $Entry.Task + # One list per task; licence availability is the first check, but anything that can predict a + # failure cheaply belongs here - a username already taken, a target mailbox or group that has + # been deleted, a tenant that is no longer reachable. Add to $Problems and the flag, reason, + # view and notifications all follow without further wiring. + $Problems = [System.Collections.Generic.List[string]]::new() + + foreach ($Sku in $Entry.Skus) { + $SkuKey = ([string]$Sku).ToLowerInvariant() + if (!$Available.ContainsKey($SkuKey)) { + $Problems.Add("the tenant no longer has a subscription for $(Get-FriendlySkuName -SkuId $Sku -Fallback "SKU $Sku")") + } elseif ($Available[$SkuKey].Available -lt 1) { + $Problems.Add("no licences available for $(Get-FriendlySkuName -SkuId $Sku -Fallback $Available[$SkuKey].SkuPartNumber)") + } + } + + $IsAtRisk = $Problems.Count -gt 0 + $WasAtRisk = [bool]$Task.AtRisk + $Reason = if ($IsAtRisk) { "This task will fail as scheduled: $($Problems -join '; ')." } else { '' } + + # Nothing changed at all, so nothing to write - that keeps the ETag stable for the + # orchestrator's claim. The reason is compared as well as the flag: a task can stay at risk + # while the cause changes (the licences run out, then the subscription itself goes away), and + # the reason is what someone acts on, so letting it go stale defeats the point. + if ($IsAtRisk -eq $WasAtRisk -and $Reason -eq [string]$Task.AtRiskReason) { continue } + + $Action = if ($IsAtRisk) { 'Flag as at risk' } else { 'Clear at-risk flag' } + if (-not $PSCmdlet.ShouldProcess($Task.Name, $Action)) { continue } + + Update-AzDataTableEntity -Force @Table -Entity @{ + PartitionKey = $Task.PartitionKey + RowKey = $Task.RowKey + AtRisk = $IsAtRisk + AtRiskReason = $Reason + } + + if ($IsAtRisk -and -not $WasAtRisk) { + # Only on the transition into at-risk, so a task left flagged for a week does not + # re-notify on every run of this timer. A reason that changes while the task stays at + # risk updates the row above but is not worth alerting on again. + # Deliberately its own API name rather than Scheduler_UserTasks: at-risk is a prediction + # about a task that has not run, and admins may want those notifications separately from + # (or instead of) actual failures. + Write-LogMessage -API 'Scheduler_Preflight' -tenant $Entry.TenantFilter -message "Scheduled task '$($Task.Name)' is at risk: $($Problems -join '; ')." -sev Error + } elseif ($IsAtRisk) { + Write-Information "Preflight: task $($Task.RowKey) is still at risk, reason updated." + } else { + Write-Information "Preflight: task $($Task.RowKey) is no longer at risk." + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Get-CippScheduledTaskError.ps1 b/backend/Modules/CIPPCore/Public/Get-CippScheduledTaskError.ps1 new file mode 100644 index 0000000000..ed75cac4ca --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Get-CippScheduledTaskError.ps1 @@ -0,0 +1,32 @@ +function Get-CippScheduledTaskError { + <# + .SYNOPSIS + Returns the error messages logged during the currently running scheduled task. + .DESCRIPTION + Companion to Set-CippScheduledTaskContext. Write-LogMessage appends every Error and Critical entry + it writes to CIPPCore module-scoped AsyncLocal storage while a scheduled task context is active, + which lets the scheduler engine tell the difference between a task that completed cleanly and one + that completed while a step inside it failed. + + This is what catches partial failures that never throw, such as a user being created successfully + but the licence assignment failing because no licences are available. + + Returns an empty collection when no task context is active. + + Call this as @(Get-CippScheduledTaskError). PowerShell unrolls a collection on return, so the + bare form yields $null for no errors and a plain string for one. Do NOT try to defeat that by + returning with a leading comma: the output stream strips the outer array again, so callers + using the @() form receive a single nested array instead - which counts as one error even when + there are none, and joins to "System.Object[]". + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param() + + if (-not $script:CippScheduledTaskErrorStorage -or $null -eq $script:CippScheduledTaskErrorStorage.Value) { + return @() + } + + return @($script:CippScheduledTaskErrorStorage.Value) +} diff --git a/backend/Modules/CIPPCore/Public/GraphHelper/Write-LogMessage.ps1 b/backend/Modules/CIPPCore/Public/GraphHelper/Write-LogMessage.ps1 index 741dd44d48..c539f4debe 100644 --- a/backend/Modules/CIPPCore/Public/GraphHelper/Write-LogMessage.ps1 +++ b/backend/Modules/CIPPCore/Public/GraphHelper/Write-LogMessage.ps1 @@ -81,6 +81,12 @@ function Write-LogMessage { } if ($script:CippScheduledTaskIdStorage.Value) { $TableRow.ScheduledTaskId = [string]$script:CippScheduledTaskIdStorage.Value + + # Track failures inside the running scheduled task so the scheduler can flag a task that finished + # but had a step fail along the way. Capped to keep a pathological task from growing unbounded. + if ($sev -in 'Error', 'Critical' -and $null -ne $script:CippScheduledTaskErrorStorage.Value -and $script:CippScheduledTaskErrorStorage.Value.Count -lt 25) { + $script:CippScheduledTaskErrorStorage.Value.Add([string]$message) + } } if ($script:CippBaselineRunIdStorage.Value) { $TableRow.BaselineRunId = [string]$script:CippBaselineRunIdStorage.Value diff --git a/backend/Modules/CIPPCore/Public/Set-CippScheduledTaskContext.ps1 b/backend/Modules/CIPPCore/Public/Set-CippScheduledTaskContext.ps1 index 60fff712e7..234803ed5f 100644 --- a/backend/Modules/CIPPCore/Public/Set-CippScheduledTaskContext.ps1 +++ b/backend/Modules/CIPPCore/Public/Set-CippScheduledTaskContext.ps1 @@ -6,6 +6,9 @@ function Set-CippScheduledTaskContext { Used by the scheduler engine (Push-ExecScheduledCommand in CIPPActivityTriggers) so that CIPPCore functions like Write-LogMessage can attribute log entries to the running scheduled task. Module script scope is used instead of global scope, which is not reliable in Azure Functions. + + Setting a task id also resets the error collection used by Get-CippScheduledTaskError, so errors + logged by a previous task cannot leak into the next one running on the same worker. .PARAMETER TaskId The scheduled task RowKey. Pass $null or empty to clear. .FUNCTIONALITY @@ -20,4 +23,9 @@ function Set-CippScheduledTaskContext { $script:CippScheduledTaskIdStorage = [System.Threading.AsyncLocal[string]]::new() } $script:CippScheduledTaskIdStorage.Value = $TaskId + + if (-not $script:CippScheduledTaskErrorStorage) { + $script:CippScheduledTaskErrorStorage = [System.Threading.AsyncLocal[object]]::new() + } + $script:CippScheduledTaskErrorStorage.Value = [System.Collections.Generic.List[string]]::new() } diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ExecAckScheduledItem.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ExecAckScheduledItem.ps1 new file mode 100644 index 0000000000..bc023761c3 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ExecAckScheduledItem.ps1 @@ -0,0 +1,71 @@ +function Invoke-ExecAckScheduledItem { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + CIPP.Scheduler.ReadWrite + .DESCRIPTION + Acknowledges a scheduled task that completed with errors, removing it from the needs-attention + view without erasing the failure record. HasErrors and ErrorSummary stay on the row - the task + DID fail and the history should say so - but an acknowledged row no longer demands attention. + A later run that fails again clears the acknowledgement, so a recurring problem re-surfaces. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + try { + $RowKey = $Request.Body.RowKey ?? $Request.Query.RowKey + if ([string]::IsNullOrWhiteSpace($RowKey)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'RowKey is required.' } + }) + } + + $Table = Get-CIPPTable -TableName 'ScheduledTasks' + $SafeRowKey = ConvertTo-CIPPODataFilterValue -Value $RowKey -Type String + $Task = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'ScheduledTask' and RowKey eq '$SafeRowKey'" + if (!$Task) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::NotFound + Body = @{ Results = "No scheduled task found with id $RowKey." } + }) + } + + if ($Task.HasErrors -ne $true -and $Task.TaskState -notin @('Failed', 'Failed - Planned')) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = "Task '$($Task.Name)' has no errors to acknowledge." } + }) + } + + $AcknowledgedBy = try { + ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Request.Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails + } catch { 'Unknown' } + + $null = Update-AzDataTableEntity -Force @Table -Entity @{ + PartitionKey = $Task.PartitionKey + RowKey = $Task.RowKey + Acknowledged = $true + AcknowledgedBy = [string]$AcknowledgedBy + AcknowledgedAt = [string][int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds + } + + $Result = "Acknowledged task '$($Task.Name)'. It will no longer appear in the needs-attention view unless it fails again." + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Info' + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ Results = $Result } + }) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -message "Failed to acknowledge task: $($ErrorMessage.NormalizedError)" -Sev 'Error' + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::InternalServerError + Body = @{ Results = "Failed to acknowledge task: $($ErrorMessage.NormalizedError)" } + }) + } +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ExecSchedulerPreflightCheck.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ExecSchedulerPreflightCheck.ps1 new file mode 100644 index 0000000000..fe2c9f521d --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ExecSchedulerPreflightCheck.ps1 @@ -0,0 +1,39 @@ +function Invoke-ExecSchedulerPreflightCheck { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + CIPP.Scheduler.ReadWrite + .DESCRIPTION + Runs the scheduled-task preflight check immediately instead of waiting for its six-hourly + timer. Used after remediating a licence shortage so the at-risk view reflects reality without + the polling lag. The check itself is unchanged - this only changes when it runs. + + ExecCippFunction can already run it, but that endpoint is SuperAdmin-gated; this exposes just + the one safe operation at the same permission the scheduler views require. Synchronous on + purpose: the check is one Graph call per distinct tenant, and returning after it finishes + means the caller's table refresh shows the updated flags. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + try { + $null = Start-CIPPTaskPreflightCheck + $Result = 'Preflight check complete. The at-risk list now reflects current licence availability.' + Write-LogMessage -headers $Headers -API $APIName -message 'Ran an on-demand scheduler preflight check' -Sev 'Info' + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ Results = $Result } + }) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -message "Failed to queue the preflight check: $($ErrorMessage.NormalizedError)" -Sev 'Error' + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::InternalServerError + Body = @{ Results = "Failed to queue the preflight check: $($ErrorMessage.NormalizedError)" } + }) + } +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItems.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItems.ps1 index 4729b984d1..ebf7ab4379 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItems.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItems.ps1 @@ -26,6 +26,10 @@ function Invoke-ListScheduledItems { $Name = $Request.Query.Name ?? $Request.Body.Name $Type = $Request.Query.Type ?? $Request.Body.Type $SearchTitle = $Request.query.SearchTitle ?? $Request.body.SearchTitle + $State = $Request.Query.State ?? $Request.Body.State + $HasErrors = ($Request.Query.HasErrors -eq $true) -or ($Request.Body.HasErrors -eq $true) + $AtRisk = ($Request.Query.AtRisk -eq $true) -or ($Request.Body.AtRisk -eq $true) + $NeedsAttention = ($Request.Query.NeedsAttention -eq $true) -or ($Request.Body.NeedsAttention -eq $true) if ($ShowHidden) { $ScheduledItemFilter.Add("(Hidden eq true or Hidden eq 'True')") @@ -42,6 +46,33 @@ function Invoke-ListScheduledItems { $SafeType = ConvertTo-CIPPODataFilterValue -Value $Type -Type String $ScheduledItemFilter.Add("Command eq '$SafeType'") } + + # Status filters backing the failed and at-risk task views. All optional, so omitting them + # returns exactly what this endpoint returned before. An unrecognised state simply matches + # nothing once escaped, which is the sensible answer for a filter nobody can satisfy. + if ($State) { + $StateClauses = foreach ($SingleState in ($State -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })) { + "TaskState eq '{0}'" -f (ConvertTo-CIPPODataFilterValue -Value $SingleState -Type String) + } + if ($StateClauses) { + $ScheduledItemFilter.Add('({0})' -f ($StateClauses -join ' or ')) + } + } + + if ($HasErrors) { + $ScheduledItemFilter.Add("(HasErrors eq true or HasErrors eq 'True')") + } + + if ($AtRisk) { + $ScheduledItemFilter.Add("(AtRisk eq true or AtRisk eq 'True')") + } + + # Everything that needs a human to look at it: tasks that failed outright, and tasks that + # reported success while a step inside them failed. This is a union rather than an + # intersection, so it cannot be expressed by combining the filters above. + if ($NeedsAttention) { + $ScheduledItemFilter.Add("(TaskState eq 'Failed' or TaskState eq 'Failed - Planned' or HasErrors eq true or HasErrors eq 'True')") + } } if ($TenantFilter -and $TenantFilter -ne 'AllTenants') { @@ -73,6 +104,13 @@ function Invoke-ListScheduledItems { $Tasks = $Tasks | Where-Object { $_.Name -like $SearchTitle } } + # Also client-side, deliberately: an OData clause like "Acknowledged ne true" excludes every row + # that does not carry the property at all, which is every task written before acknowledgement + # existed - the filter would silently empty the view instead of narrowing it. + if ($NeedsAttention) { + $Tasks = $Tasks | Where-Object { $_.Acknowledged -ne $true } + } + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList $TenantLookup = @{} @@ -115,6 +153,24 @@ function Invoke-ListScheduledItems { } elseif ($Task.Recurrence -eq 0 -or [string]::IsNullOrEmpty($Task.Recurrence)) { $Task.Recurrence = 'Once' } + + # Tasks written before these fields existed carry no value at all, which renders as a blank + # cell rather than a readable status. Default them so every row reports one. + if ($null -eq $Task.HasErrors) { + $Task | Add-Member -NotePropertyName HasErrors -NotePropertyValue $false -Force + } + if ($null -eq $Task.AtRisk) { + $Task | Add-Member -NotePropertyName AtRisk -NotePropertyValue $false -Force + } + if ($null -eq $Task.ErrorSummary) { + $Task | Add-Member -NotePropertyName ErrorSummary -NotePropertyValue '' -Force + } + if ($null -eq $Task.AtRiskReason) { + $Task | Add-Member -NotePropertyName AtRiskReason -NotePropertyValue '' -Force + } + if ($null -eq $Task.Acknowledged) { + $Task | Add-Member -NotePropertyName Acknowledged -NotePropertyValue $false -Force + } try { $Task.ExecutedTime = [DateTimeOffset]::FromUnixTimeSeconds($Task.ExecutedTime).UtcDateTime } catch {} diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 074d0d0962..6e9ed8a2cb 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -394,6 +394,8 @@ * [Community Repositories](user-documentation/tools/community-repos/README.md) * [View Repository Templates](user-documentation/tools/community-repos/repo.md) * [Scheduler](user-documentation/tools/scheduler/README.md) + * [Failed Queue](user-documentation/tools/scheduler/failed-queue.md) + * [Pending with Issues](user-documentation/tools/scheduler/pending-with-issues.md) * [Add Task](user-documentation/tools/scheduler/job.md) * [View Scheduled Task Details](user-documentation/tools/scheduler/task.md) * [CIPP](user-documentation/cipp/README.md) diff --git a/docs/user-documentation/cipp/settings/notifications.md b/docs/user-documentation/cipp/settings/notifications.md index 6cadf57564..d8fee0bdd9 100644 --- a/docs/user-documentation/cipp/settings/notifications.md +++ b/docs/user-documentation/cipp/settings/notifications.md @@ -22,6 +22,8 @@ Under the "Choose which logs you would like to receive alerts from" you will be * New Standards added via CIPP * Removed Standards via CIPP * Token Refresh Events +* Scheduled task failures +* Tasks pending with issues ## Sending Methods diff --git a/docs/user-documentation/tools/scheduler/README.md b/docs/user-documentation/tools/scheduler/README.md index 68a93ce221..78c6d26e91 100644 --- a/docs/user-documentation/tools/scheduler/README.md +++ b/docs/user-documentation/tools/scheduler/README.md @@ -4,6 +4,8 @@ The task scheduler allows you to schedule CIPP functionality to be executed at a The scheduler allows you to schedule components to run once, every day, every 7 days, every 30 days, or every year. +The scheduler has three tabs. All Tasks is the full list described on this page. [failed-queue.md](failed-queue.md "mention") shows tasks that failed or completed with errors and need attention. [pending-with-issues.md](pending-with-issues.md "mention") shows planned tasks that are expected to fail, such as a user creation whose licence is no longer available, so they can be fixed before they run. + {% hint style="warning" %} Scheduling a task for the past will make it run on the next interval the scheduler runs. {% endhint %} @@ -35,7 +37,9 @@ Opens the [job.md](job.md "mention") page | Column | Description | | -------------- | ------------------------------------------------------------------------------- | | Executed Time | The relative time since the task was last run | -| Task State | Displays information on if the task is "Planned", "Completed", or "Failed". | +| Task State | "Planned", "Running", "Completed", "Failed", or "Failed - Planned" | +| Has Errors | Whether the last run recorded any errors, even if the task completed | +| At Risk | Whether the task is expected to fail when it runs | | Tenant | The tenant selected for the job | | Name | The job's name | | Scheduled Time | The relative time since the task ran or until the task is scheduled to run next | @@ -46,7 +50,7 @@ Opens the [job.md](job.md "mention") page ## Table Actions -
ActionDescriptionBulk Action Available
View Task DetailsWill open a view only page with the full details of the jobfalse
Run NowWill run the task at the next quarter hourtrue
Edit JobWill display the job in a state where you can edit the detailsfalse
Clone and Edit JobCreates a copy of the selected job and opens the edit window to make any necessary changesfalse
Delete JobDeletes the job from the scheduletrue
More InfoOpens the Extended Info flyout with the full details for the selected row.false
+
ActionDescriptionBulk Action Available
View Task DetailsWill open a view only page with the full details of the jobfalse
Run NowWill run the task at the next quarter hourtrue
Acknowledge ErrorsShown on tasks that failed or completed with errors. Marks the failure as dealt with and removes the task from the Failed Queue while keeping its error details. See failed-queue.mdfalse
Edit JobWill display the job in a state where you can edit the detailsfalse
Clone and Edit JobCreates a copy of the selected job and opens the edit window to make any necessary changesfalse
Delete JobDeletes the job from the scheduletrue
More InfoOpens the Extended Info flyout with the full details for the selected row.false
## Task Details diff --git a/docs/user-documentation/tools/scheduler/failed-queue.md b/docs/user-documentation/tools/scheduler/failed-queue.md new file mode 100644 index 0000000000..7c0c833625 --- /dev/null +++ b/docs/user-documentation/tools/scheduler/failed-queue.md @@ -0,0 +1,46 @@ +# Failed Queue + +The Failed Queue lists every scheduled task that needs attention: tasks that failed outright, recurring tasks whose last run failed, and tasks that completed while a step inside them failed. That last group matters most. A scheduled user creation can create the user but fail to assign the licence, and the task itself still completes; the Failed Queue is where that surfaces instead of hiding inside the task's results. + +A task leaves the queue when a later run succeeds, when it is edited (editing resets it to a clean planned task), when it is deleted, or when you acknowledge it. + +## Task States + +| State | Meaning | +| ---------------- | -------------------------------------------------------------------------------------------------------------- | +| Failed | The task ran and failed. One-time tasks stay in this state until acted on. | +| Failed - Planned | A recurring task whose last run failed. It stays in the schedule and runs again at its next interval. | +| Completed | The task finished, but a step inside it failed. Check Error Summary for what went wrong. | + +## Table Details + +| Column | Description | +| ------------- | ------------------------------------------------------------------------------ | +| Executed Time | The relative time since the task last ran | +| Task State | See the states above | +| Has Errors | Whether the last run recorded any errors | +| Error Summary | What went wrong on the last run, in the words of the error that was logged | +| Tenant | The tenant the task runs against | +| Name | The task's name | +| Command | The command the task runs | +| Results | The results of the most recent run | + +## Acknowledging a Failure + +If you have fixed the underlying problem outside CIPP, for example by assigning the missing licence to the user directly, the task row itself is still a record of a failure. Acknowledge Errors marks it as dealt with: the row leaves this queue, but keeps its error details and records who acknowledged it and when. + +{% hint style="info" %} +An acknowledgement is cleared automatically if the task fails again on a later run, so a recurring problem comes back to the queue rather than staying dismissed. +{% endhint %} + +{% hint style="warning" %} +Run Now on a completed user-creation task will fail with a duplicate username error, because the user already exists. For those, fix the user directly and acknowledge or delete the task. Run Now is the right tool for idempotent tasks such as a failed licence assignment. +{% endhint %} + +## Table Actions + +
ActionDescriptionBulk Action Available
View Task DetailsWill open a view only page with the full details of the jobfalse
Run NowWill run the task at the next quarter hourtrue
Acknowledge ErrorsMarks the failure as dealt with and removes the task from this queue. The error details stay on the task.false
Edit JobWill display the job in a state where you can edit the detailsfalse
Clone and Edit JobCreates a copy of the selected job and opens the edit window to make any necessary changesfalse
Delete JobDeletes the job from the scheduletrue
+ +*** + +{% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/scheduler/pending-with-issues.md b/docs/user-documentation/tools/scheduler/pending-with-issues.md new file mode 100644 index 0000000000..397bd4cad4 --- /dev/null +++ b/docs/user-documentation/tools/scheduler/pending-with-issues.md @@ -0,0 +1,44 @@ +# Pending with Issues + +Pending with Issues lists planned tasks that would fail if they ran right now, so you can fix the problem before the scheduled time arrives rather than finding out afterwards. The typical case is a user creation scheduled for a new starter's first day, where the licence it needs has been used up in the meantime. + +CIPP checks planned tasks every six hours. A task that would fail is flagged with the reason, for example "no licences available for Microsoft 365 E5 Developer", and appears here. The flag clears on its own when the problem goes away, when the task is edited, or once the task actually runs. + +{% hint style="info" %} +Tasks that buy their licence through Sherweb at run time are not flagged, since the licence does not need to exist in the tenant beforehand. +{% endhint %} + +## Fixing a Flagged Task + +Buy or free up the licence the task needs, then either wait for the next six-hourly check or use **Re-check Now** to update the list immediately. If the problem is real but expected, you can also edit the task to use a different licence, or reschedule it for after the licences arrive. + +A flag is a forecast, not a block. A flagged task still runs at its scheduled time and simply tries; if the licence has come back by then, it succeeds as normal. + +## Action Buttons + +
+ +Re-check Now + +Runs the availability check immediately instead of waiting for the next six-hourly pass. Use this after buying or freeing licences so the list reflects reality straight away. + +
+ +## Table Details + +| Column | Description | +| -------------- | -------------------------------------------------------------------- | +| Scheduled Time | When the task is due to run | +| Task State | Always Planned while a task is in this view | +| At Risk Reason | Why the task is expected to fail, named against the licence involved | +| Tenant | The tenant the task runs against | +| Name | The task's name | +| Command | The command the task runs | + +## Notifications + +Flagged tasks can raise a notification through the standard pipeline. Select "Tasks pending with issues" under the log types in [notifications.md](../../cipp/settings/notifications.md "mention"). A task is alerted once when it becomes flagged, not repeatedly while it stays flagged. + +*** + +{% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/frontend/src/components/CippComponents/CippNotificationForm.jsx b/frontend/src/components/CippComponents/CippNotificationForm.jsx index 03ecce096e..227f5cb92c 100644 --- a/frontend/src/components/CippComponents/CippNotificationForm.jsx +++ b/frontend/src/components/CippComponents/CippNotificationForm.jsx @@ -42,6 +42,8 @@ export const CippNotificationForm = ({ { label: "Adding a group", value: "AddGroup" }, { label: "Adding a tenant", value: "NewTenant" }, { label: "Executing the offboard wizard", value: "ExecOffboardUser" }, + { label: "Scheduled task failures", value: "Scheduler_UserTasks" }, + { label: "Tasks pending with issues", value: "Scheduler_Preflight" }, ]; const severityTypes = [ diff --git a/frontend/src/components/CippComponents/CippScheduledTaskActions.jsx b/frontend/src/components/CippComponents/CippScheduledTaskActions.jsx index 1e37c3b151..fad6838c2f 100644 --- a/frontend/src/components/CippComponents/CippScheduledTaskActions.jsx +++ b/frontend/src/components/CippComponents/CippScheduledTaskActions.jsx @@ -1,5 +1,5 @@ import { EyeIcon, TrashIcon } from "@heroicons/react/24/outline"; -import { CopyAll, Edit, PlayArrow } from "@mui/icons-material"; +import { CopyAll, Edit, PlayArrow, TaskAlt } from "@mui/icons-material"; import { usePermissions } from "../../hooks/use-permissions"; export const CippScheduledTaskActions = (drawerHandlers = {}, { hideActions = [] } = {}) => { @@ -24,6 +24,22 @@ export const CippScheduledTaskActions = (drawerHandlers = {}, { hideActions = [] allowResubmit: true, condition: () => canWriteScheduler, }, + { + label: "Acknowledge Errors", + type: "POST", + url: "/api/ExecAckScheduledItem", + data: { RowKey: "RowKey" }, + icon: , + confirmText: + "Acknowledge the errors on [Name]? The failure stays on record but the task will no longer appear as needing attention unless it fails again.", + multiPost: false, + condition: (row) => + canWriteScheduler && + row.Acknowledged !== true && + (row.HasErrors === true || + row.TaskState === "Failed" || + row.TaskState === "Failed - Planned"), + }, { label: "Edit Job", customFunction: diff --git a/frontend/src/components/CippComponents/SchedulerTable.jsx b/frontend/src/components/CippComponents/SchedulerTable.jsx new file mode 100644 index 0000000000..d8f9d107d1 --- /dev/null +++ b/frontend/src/components/CippComponents/SchedulerTable.jsx @@ -0,0 +1,106 @@ +import { useState } from 'react' +import { Button } from '@mui/material' +import CippTablePage from './CippTablePage' +import ScheduledTaskDetails from './ScheduledTaskDetails' +import { CippScheduledTaskActions } from './CippScheduledTaskActions' +import { CippSchedulerDrawer } from './CippSchedulerDrawer' +import { useSettings } from '../../hooks/use-settings' + +const buildApiUrl = (params) => { + const query = new URLSearchParams(params).toString() + return query ? `/api/ListScheduledItems?${query}` : '/api/ListScheduledItems' +} + +/** + * Shared scheduled task table used by every scheduler tab. The tabs differ only in which tasks they + * ask the API for and which columns matter, so the table, actions, off-canvas and edit/clone drawers + * are defined once here rather than repeated per tab. + */ +export const SchedulerTable = ({ + title, + apiParams = {}, + queryKeyPrefix, + simpleColumns, + filters, + showSystemJobsToggle = false, + showAddTask = false, + cardActions = null, +}) => { + const [editTaskId, setEditTaskId] = useState(null) + const [cloneTaskId, setCloneTaskId] = useState(null) + const [showHiddenJobs, setShowHiddenJobs] = useState(false) + const currentTenant = useSettings().currentTenant + + const drawerHandlers = { + openEditDrawer: (row) => setEditTaskId(row.RowKey), + openCloneDrawer: (row) => setCloneTaskId(row.RowKey), + } + + const actions = CippScheduledTaskActions(drawerHandlers) + + const params = { ...apiParams } + if (showSystemJobsToggle && showHiddenJobs) { + params.ShowHidden = true + } + + const offCanvas = { + children: (extendedData) => ( + + ), + size: 'xl', + actions: actions, + } + + const cardButton = + showSystemJobsToggle || showAddTask || cardActions ? ( + <> + {showSystemJobsToggle && ( + + )} + {showAddTask && } + {cardActions} + + ) : undefined + + return ( + <> + + + {/* Edit Drawer */} + {editTaskId && ( + setEditTaskId(null)} + onClose={() => setEditTaskId(null)} + PermissionButton={({ children }) => <>{children}} + /> + )} + + {/* Clone Drawer */} + {cloneTaskId && ( + setCloneTaskId(null)} + onClose={() => setCloneTaskId(null)} + PermissionButton={({ children }) => <>{children}} + /> + )} + + ) +} + +export default SchedulerTable diff --git a/frontend/src/pages/cipp/scheduler/at-risk.js b/frontend/src/pages/cipp/scheduler/at-risk.js new file mode 100644 index 0000000000..c102abda0f --- /dev/null +++ b/frontend/src/pages/cipp/scheduler/at-risk.js @@ -0,0 +1,67 @@ +import { Button, Tooltip } from '@mui/material' +import { Refresh } from '@mui/icons-material' +import { Layout as DashboardLayout } from '../../../layouts/index.js' +import { TabbedLayout } from '../../../layouts/TabbedLayout' +import { SchedulerTable } from '../../../components/CippComponents/SchedulerTable' +import { ApiPostCall } from '../../../api/ApiCall' +import { CippApiResults } from '../../../components/CippComponents/CippApiResults' +import tabOptions from './tabOptions' + +/** + * Planned tasks that would fail if they ran right now, most often because a licence they need has been + * consumed since the task was booked. Flagged ahead of time by Start-CIPPTaskPreflightCheck so they can + * be fixed before the scheduled time rather than after. + * + * The check runs on a six-hourly timer, so after remediating (buying or freeing a licence) the flags + * can lag by hours - Re-check Now queues an immediate pass instead of waiting. + */ +const Page = () => { + const recheck = ApiPostCall({ + relatedQueryKeys: ['ListScheduledItems-atrisk'], + }) + + return ( + <> + + {/* span so the tooltip still shows while the button is disabled mid-check */} + + + + + } + simpleColumns={[ + 'ScheduledTime', + 'TaskState', + 'AtRiskReason', + 'Tenant', + 'Name', + 'Command', + 'Parameters', + 'Recurrence', + ]} + /> + + + ) +} + +Page.getLayout = (page) => ( + + {page} + +) + +export default Page diff --git a/frontend/src/pages/cipp/scheduler/failed.js b/frontend/src/pages/cipp/scheduler/failed.js new file mode 100644 index 0000000000..12eee13679 --- /dev/null +++ b/frontend/src/pages/cipp/scheduler/failed.js @@ -0,0 +1,60 @@ +import { Layout as DashboardLayout } from '../../../layouts/index.js' +import { TabbedLayout } from '../../../layouts/TabbedLayout' +import { SchedulerTable } from '../../../components/CippComponents/SchedulerTable' +import tabOptions from './tabOptions' + +/** + * Tasks that need re-running. Covers both outright failures and tasks that reported success while a + * step inside them failed - a scheduled user creation where the licence could not be assigned lands + * here rather than looking green on the main list. + */ +const Page = () => { + // The API has already narrowed this to tasks needing attention, so within that set TaskState alone + // separates an outright failure from one that finished while a step inside it failed. + const filterList = [ + { + filterName: 'Failed outright', + value: [{ id: 'TaskState', value: 'Failed' }], + type: 'column', + }, + { + filterName: 'Recurring failures', + value: [{ id: 'TaskState', value: 'Failed - Planned' }], + type: 'column', + }, + { + filterName: 'Completed with errors', + value: [{ id: 'TaskState', value: 'Completed' }], + type: 'column', + }, + ] + + return ( + + ) +} + +Page.getLayout = (page) => ( + + {page} + +) + +export default Page diff --git a/frontend/src/pages/cipp/scheduler/index.js b/frontend/src/pages/cipp/scheduler/index.js index 446f7ca059..6ab98c044c 100644 --- a/frontend/src/pages/cipp/scheduler/index.js +++ b/frontend/src/pages/cipp/scheduler/index.js @@ -1,124 +1,62 @@ -import { Layout as DashboardLayout } from "../../../layouts/index.js"; -import CippTablePage from "../../../components/CippComponents/CippTablePage"; -import { Button } from "@mui/material"; -import { CalendarDaysIcon } from "@heroicons/react/24/outline"; -import { useState } from "react"; -import ScheduledTaskDetails from "../../../components/CippComponents/ScheduledTaskDetails"; -import { CippScheduledTaskActions } from "../../../components/CippComponents/CippScheduledTaskActions"; -import { CippSchedulerDrawer } from "../../../components/CippComponents/CippSchedulerDrawer"; -import { useSettings } from "../../../hooks/use-settings"; +import { Layout as DashboardLayout } from '../../../layouts/index.js' +import { TabbedLayout } from '../../../layouts/TabbedLayout' +import { SchedulerTable } from '../../../components/CippComponents/SchedulerTable' +import tabOptions from './tabOptions' const Page = () => { - const [editTaskId, setEditTaskId] = useState(null); - const [cloneTaskId, setCloneTaskId] = useState(null); - const currentTenant = useSettings().currentTenant; - - const drawerHandlers = { - openEditDrawer: (row) => { - setEditTaskId(row.RowKey); - }, - openCloneDrawer: (row) => { - setCloneTaskId(row.RowKey); - }, - }; - - const actions = CippScheduledTaskActions(drawerHandlers); - const filterList = [ { - filterName: "Running", - value: [{ id: "TaskState", value: "Running" }], - type: "column", + filterName: 'Running', + value: [{ id: 'TaskState', value: 'Running' }], + type: 'column', }, { - filterName: "Planned", - value: [{ id: "TaskState", value: "Planned" }], - type: "column", + filterName: 'Planned', + value: [{ id: 'TaskState', value: 'Planned' }], + type: 'column', }, { - filterName: "Failed", - value: [{ id: "TaskState", value: "Failed" }], - type: "column", + filterName: 'Failed', + value: [{ id: 'TaskState', value: 'Failed' }], + type: 'column', }, { - filterName: "Completed", - value: [{ id: "TaskState", value: "Completed" }], - type: "column", + filterName: 'Completed', + value: [{ id: 'TaskState', value: 'Completed' }], + type: 'column', }, - ]; + ] - const offCanvas = { - children: (extendedData) => ( - - ), - size: "xl", - actions: actions, - }; - const [showHiddenJobs, setShowHiddenJobs] = useState(false); return ( - <> - - - - - } - title="Scheduled Tasks" - apiUrl={ - showHiddenJobs ? `/api/ListScheduledItems?ShowHidden=true` : `/api/ListScheduledItems` - } - queryKey={ - showHiddenJobs - ? `ListScheduledItems-hidden-${currentTenant}` - : `ListScheduledItems-${currentTenant}` - } - simpleColumns={[ - "ExecutedTime", - "TaskState", - "Tenant", - "Name", - "ScheduledTime", - "Command", - "Parameters", - "PostExecution", - "Reference", - "Recurrence", - "Results", - ]} - actions={actions} - offCanvas={offCanvas} - filters={filterList} - /> - - {/* Edit Drawer */} - {editTaskId && ( - setEditTaskId(null)} - onClose={() => setEditTaskId(null)} - PermissionButton={({ children }) => <>{children}} - /> - )} - - {/* Clone Drawer */} - {cloneTaskId && ( - setCloneTaskId(null)} - onClose={() => setCloneTaskId(null)} - PermissionButton={({ children }) => <>{children}} - /> - )} - - ); -}; - -Page.getLayout = (page) => {page}; - -export default Page; + + ) +} + +Page.getLayout = (page) => ( + + {page} + +) + +export default Page diff --git a/frontend/src/pages/cipp/scheduler/tabOptions.json b/frontend/src/pages/cipp/scheduler/tabOptions.json new file mode 100644 index 0000000000..d348f66c57 --- /dev/null +++ b/frontend/src/pages/cipp/scheduler/tabOptions.json @@ -0,0 +1,17 @@ +[ + { + "label": "All Tasks", + "path": "/cipp/scheduler", + "icon": "AccessTime" + }, + { + "label": "Failed Queue", + "path": "/cipp/scheduler/failed", + "icon": "Warning" + }, + { + "label": "Pending with Issues", + "path": "/cipp/scheduler/at-risk", + "icon": "FactCheck" + } +]