mirror of
https://github.com/getgrav/grav.git
synced 2025-10-26 00:46:07 +02:00
Merge branch 'feature/modern-scheduler' into develop
This commit is contained in:
@@ -55,7 +55,7 @@
|
||||
"league/climate": "^3.6",
|
||||
"miljar/php-exif": "^0.6",
|
||||
"composer/ca-bundle": "^1.2",
|
||||
"dragonmantank/cron-expression": "^1.2",
|
||||
"dragonmantank/cron-expression": "^3.3",
|
||||
"willdurand/negotiation": "^3.0",
|
||||
"itsgoingd/clockwork": "^5.0",
|
||||
"symfony/http-client": "^4.4",
|
||||
|
||||
@@ -4,74 +4,850 @@ form:
|
||||
validation: loose
|
||||
|
||||
fields:
|
||||
scheduler_tabs:
|
||||
type: tabs
|
||||
active: 1
|
||||
|
||||
status_title:
|
||||
type: section
|
||||
title: PLUGIN_ADMIN.SCHEDULER_STATUS
|
||||
underline: true
|
||||
fields:
|
||||
status_tab:
|
||||
type: tab
|
||||
title: PLUGIN_ADMIN.SCHEDULER_STATUS
|
||||
|
||||
status:
|
||||
type: cronstatus
|
||||
validate:
|
||||
type: commalist
|
||||
fields:
|
||||
status_title:
|
||||
type: section
|
||||
title: PLUGIN_ADMIN.SCHEDULER_STATUS
|
||||
underline: true
|
||||
|
||||
jobs_title:
|
||||
type: section
|
||||
title: PLUGIN_ADMIN.SCHEDULER_JOBS
|
||||
underline: true
|
||||
status:
|
||||
type: cronstatus
|
||||
validate:
|
||||
type: commalist
|
||||
|
||||
webhook_status_override:
|
||||
type: display
|
||||
label:
|
||||
content: |
|
||||
<script>
|
||||
(function() {
|
||||
function updateSchedulerStatus() {
|
||||
// Find all notice bars
|
||||
var notices = document.querySelectorAll('.notice');
|
||||
var webhookStatusChecked = false;
|
||||
|
||||
// Check for modern scheduler and webhook settings
|
||||
fetch(window.location.origin + '/grav-editor-pro/scheduler/health')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.webhook_enabled) {
|
||||
notices.forEach(function(notice) {
|
||||
if (notice.textContent.includes('Not Enabled for user:')) {
|
||||
// This is the cron status notice - replace it
|
||||
notice.className = 'notice info';
|
||||
notice.innerHTML = '<i class="fa fa-fw fa-check-circle"></i> <strong>Webhook Active</strong> - Scheduler can be triggered via webhook. Cron is not configured.';
|
||||
}
|
||||
});
|
||||
|
||||
// Also update the main status if it exists
|
||||
var statusDiv = document.querySelector('.cronstatus-status');
|
||||
if (statusDiv && statusDiv.textContent.includes('Not Enabled')) {
|
||||
statusDiv.className = 'cronstatus-status success';
|
||||
statusDiv.innerHTML = '<i class="fa fa-fw fa-check"></i> Webhook Ready';
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Webhook status check failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Run on page load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', updateSchedulerStatus);
|
||||
} else {
|
||||
updateSchedulerStatus();
|
||||
}
|
||||
|
||||
// Also run after a short delay to catch any late-rendered elements
|
||||
setTimeout(updateSchedulerStatus, 500);
|
||||
})();
|
||||
</script>
|
||||
markdown: false
|
||||
|
||||
status_enhanced:
|
||||
type: display
|
||||
label:
|
||||
content: |
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Check if webhook is enabled
|
||||
var webhookEnabled = document.querySelector('[name="data[scheduler][modern][webhook][enabled]"]:checked');
|
||||
var statusDiv = document.querySelector('.cronstatus-status');
|
||||
|
||||
// Also find the parent notice bar
|
||||
var noticeBar = document.querySelector('.notice.alert');
|
||||
|
||||
if (statusDiv) {
|
||||
var currentStatus = statusDiv.textContent || statusDiv.innerText;
|
||||
var cronReady = currentStatus.includes('Ready');
|
||||
var cronNotEnabled = currentStatus.includes('Not Enabled');
|
||||
|
||||
// Check if scheduler-webhook plugin exists
|
||||
var webhookPluginInstalled = false;
|
||||
fetch(window.location.origin + '/grav-editor-pro/scheduler/health')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
webhookPluginInstalled = true;
|
||||
updateStatusDisplay(data);
|
||||
})
|
||||
.catch(error => {
|
||||
updateStatusDisplay(null);
|
||||
});
|
||||
|
||||
function updateStatusDisplay(healthData) {
|
||||
var isWebhookEnabled = webhookEnabled && webhookEnabled.value == '1';
|
||||
var isWebhookReady = webhookPluginInstalled && isWebhookEnabled && healthData && healthData.webhook_enabled;
|
||||
|
||||
// Update the main status text
|
||||
var mainStatusText = '';
|
||||
var mainStatusClass = '';
|
||||
|
||||
if (cronReady && isWebhookReady) {
|
||||
mainStatusText = 'Cron and Webhook Ready';
|
||||
mainStatusClass = 'success';
|
||||
} else if (cronReady) {
|
||||
mainStatusText = 'Cron Ready';
|
||||
mainStatusClass = 'success';
|
||||
} else if (isWebhookReady) {
|
||||
mainStatusText = 'Webhook Ready (No Cron)';
|
||||
mainStatusClass = 'success'; // Changed from warning to success
|
||||
} else if (cronNotEnabled && !isWebhookReady) {
|
||||
mainStatusText = 'Not Configured';
|
||||
mainStatusClass = 'error';
|
||||
} else {
|
||||
mainStatusText = 'Configuration Pending';
|
||||
mainStatusClass = 'warning';
|
||||
}
|
||||
|
||||
// Update the notice bar if webhooks are ready
|
||||
if (noticeBar && isWebhookReady) {
|
||||
// Change from error (red) to success (green) or info (blue)
|
||||
noticeBar.classList.remove('alert');
|
||||
noticeBar.classList.add('info');
|
||||
|
||||
var noticeIcon = noticeBar.querySelector('i.fa');
|
||||
if (noticeIcon) {
|
||||
noticeIcon.classList.remove('fa-times-circle');
|
||||
noticeIcon.classList.add('fa-check-circle');
|
||||
}
|
||||
|
||||
var noticeText = noticeBar.querySelector('strong') || noticeBar;
|
||||
var username = noticeText.textContent.match(/user:\s*(\w+)/);
|
||||
if (username) {
|
||||
noticeText.innerHTML = 'Webhook Ready for user: <b>' + username[1] + '</b> (Cron not configured)';
|
||||
} else {
|
||||
noticeText.innerHTML = mainStatusText;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the main status div
|
||||
if (statusDiv) {
|
||||
statusDiv.innerHTML = '<i class="fa fa-fw fa-' +
|
||||
(mainStatusClass === 'success' ? 'check' : mainStatusClass === 'warning' ? 'exclamation' : 'times') +
|
||||
'"></i> ' + mainStatusText;
|
||||
statusDiv.className = 'cronstatus-status ' + mainStatusClass;
|
||||
}
|
||||
|
||||
// Update install instructions button/content
|
||||
var installButton = document.querySelector('.cronstatus-install-button');
|
||||
var installDiv = document.querySelector('.cronstatus-install');
|
||||
|
||||
if (installDiv) {
|
||||
var installHtml = '<div class="alert alert-info">';
|
||||
installHtml += '<h4>Setup Instructions:</h4>';
|
||||
|
||||
var hasInstructions = false;
|
||||
|
||||
// Cron setup
|
||||
if (!cronReady) {
|
||||
installHtml += '<p><strong>Option 1: Traditional Cron</strong><br>';
|
||||
installHtml += 'Run: <code>bin/grav scheduler --install</code><br>';
|
||||
installHtml += 'This will add a cron job that runs every minute.</p>';
|
||||
hasInstructions = true;
|
||||
}
|
||||
|
||||
// Webhook setup
|
||||
if (!webhookPluginInstalled) {
|
||||
installHtml += '<p><strong>Option 2: Webhook Support</strong><br>';
|
||||
installHtml += '1. Install plugin: <code>bin/gpm install scheduler-webhook</code><br>';
|
||||
installHtml += '2. Configure webhook token in Advanced Features tab<br>';
|
||||
installHtml += '3. Use webhook URL in your CI/CD or cloud scheduler</p>';
|
||||
hasInstructions = true;
|
||||
} else if (!isWebhookEnabled) {
|
||||
installHtml += '<p><strong>Webhook Plugin Installed</strong><br>';
|
||||
installHtml += 'Enable webhooks in Advanced Features tab and set a secure token.</p>';
|
||||
hasInstructions = true;
|
||||
} else if (isWebhookReady) {
|
||||
installHtml += '<p><strong>✅ Webhook is Active!</strong><br>';
|
||||
installHtml += 'Trigger URL: <code>' + window.location.origin + '/grav-editor-pro/scheduler/webhook</code><br>';
|
||||
installHtml += 'Use with Authorization header: <code>Bearer YOUR_TOKEN</code></p>';
|
||||
|
||||
if (!cronReady) {
|
||||
installHtml += '<p class="text-muted"><small>Note: No cron job configured. Scheduler runs only via webhook triggers.</small></p>';
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasInstructions && cronReady) {
|
||||
installHtml += '<p><strong>✅ Cron is configured and ready!</strong><br>';
|
||||
installHtml += 'The scheduler runs automatically every minute via system cron.</p>';
|
||||
|
||||
}
|
||||
|
||||
installHtml += '</div>';
|
||||
installDiv.innerHTML = installHtml;
|
||||
|
||||
// Update button text based on status
|
||||
if (installButton) {
|
||||
if (cronReady && isWebhookReady) {
|
||||
installButton.innerHTML = '<i class="fa fa-info-circle"></i> Configuration Details';
|
||||
} else if (cronReady || isWebhookReady) {
|
||||
installButton.innerHTML = '<i class="fa fa-plus-circle"></i> Add More Triggers';
|
||||
} else {
|
||||
installButton.innerHTML = '<i class="fa fa-exclamation-triangle"></i> Install Instructions';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
custom_jobs:
|
||||
type: list
|
||||
style: vertical
|
||||
label:
|
||||
classes: cron-job-list compact
|
||||
key: id
|
||||
fields:
|
||||
.id:
|
||||
type: key
|
||||
label: ID
|
||||
placeholder: 'process-name'
|
||||
validate:
|
||||
required: true
|
||||
pattern: '[a-zа-я0-9_\-]+'
|
||||
max: 20
|
||||
message: 'ID must be lowercase with dashes/underscores only and less than 20 characters'
|
||||
.command:
|
||||
type: text
|
||||
label: PLUGIN_ADMIN.COMMAND
|
||||
placeholder: 'ls'
|
||||
validate:
|
||||
required: true
|
||||
.args:
|
||||
type: text
|
||||
label: PLUGIN_ADMIN.EXTRA_ARGUMENTS
|
||||
placeholder: '-lah'
|
||||
.at:
|
||||
type: text
|
||||
wrapper_classes: cron-selector
|
||||
label: PLUGIN_ADMIN.SCHEDULER_RUNAT
|
||||
help: PLUGIN_ADMIN.SCHEDULER_RUNAT_HELP
|
||||
placeholder: '* * * * *'
|
||||
validate:
|
||||
required: true
|
||||
.output:
|
||||
type: text
|
||||
label: PLUGIN_ADMIN.SCHEDULER_OUTPUT
|
||||
help: PLUGIN_ADMIN.SCHEDULER_OUTPUT_HELP
|
||||
placeholder: 'logs/ls-cron.out'
|
||||
.output_mode:
|
||||
type: select
|
||||
label: PLUGIN_ADMIN.SCHEDULER_OUTPUT_TYPE
|
||||
help: PLUGIN_ADMIN.SCHEDULER_OUTPUT_TYPE_HELP
|
||||
default: append
|
||||
options:
|
||||
append: Append
|
||||
overwrite: Overwrite
|
||||
.email:
|
||||
type: text
|
||||
label: PLUGIN_ADMIN.SCHEDULER_EMAIL
|
||||
help: PLUGIN_ADMIN.SCHEDULER_EMAIL_HELP
|
||||
placeholder: 'notifications@yoursite.com'
|
||||
modern_health:
|
||||
type: display
|
||||
label: Health Status
|
||||
content: |
|
||||
<div id="scheduler-health-status">
|
||||
<div class="text-muted">Checking health...</div>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
function loadHealthStatus() {
|
||||
fetch(window.location.origin + '/grav-editor-pro/scheduler/health')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
var statusEl = document.getElementById('scheduler-health-status');
|
||||
if (!statusEl) return;
|
||||
|
||||
// Modern card-based layout
|
||||
var statusColor = '#6c757d';
|
||||
var statusLabel = data.status || 'unknown';
|
||||
if (data.status === 'healthy') statusColor = '#28a745';
|
||||
else if (data.status === 'warning') statusColor = '#ffc107';
|
||||
else if (data.status === 'critical') statusColor = '#dc3545';
|
||||
|
||||
var html = '<div style="display: flex; flex-direction: column; gap: 1rem;">';
|
||||
|
||||
// Status card
|
||||
html += '<div style="display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; background: linear-gradient(135deg, #f8f9fa 0%, #fff 100%); border-radius: 6px; border: 1px solid #e9ecef; box-shadow: 0 1px 3px rgba(0,0,0,0.05);">';
|
||||
html += '<span style="font-weight: 500; color: #495057;">Status:</span>';
|
||||
html += '<span style="background: ' + statusColor + '; color: white; padding: 0.375rem 0.75rem; font-size: 0.875rem; font-weight: 500; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.025em;">' + statusLabel + '</span>';
|
||||
html += '</div>';
|
||||
|
||||
// Info grid
|
||||
html += '<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem;">';
|
||||
|
||||
// Last run card
|
||||
html += '<div style="background: white; border: 1px solid #e9ecef; border-radius: 6px; padding: 0.75rem; box-shadow: 0 1px 2px rgba(0,0,0,0.03);">';
|
||||
html += '<div style="color: #6c757d; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem;">Last Run</div>';
|
||||
if (data.last_run) {
|
||||
var age = data.last_run_age;
|
||||
var ageText = 'just now';
|
||||
if (age > 86400) {
|
||||
ageText = Math.floor(age / 86400) + ' day(s) ago';
|
||||
} else if (age > 3600) {
|
||||
ageText = Math.floor(age / 3600) + ' hour(s) ago';
|
||||
} else if (age > 60) {
|
||||
ageText = Math.floor(age / 60) + ' minute(s) ago';
|
||||
} else if (age > 0) {
|
||||
ageText = age + ' second(s) ago';
|
||||
}
|
||||
html += '<div style="font-size: 1rem; color: #212529; font-weight: 500;">' + ageText + '</div>';
|
||||
} else {
|
||||
html += '<div style="font-size: 1rem; color: #6c757d;">Never</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// Jobs count card
|
||||
html += '<div style="background: white; border: 1px solid #e9ecef; border-radius: 6px; padding: 0.75rem; box-shadow: 0 1px 2px rgba(0,0,0,0.03);">';
|
||||
html += '<div style="color: #6c757d; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem;">Scheduled Jobs</div>';
|
||||
html += '<div style="font-size: 1rem; color: #212529; font-weight: 500;">' + (data.scheduled_jobs || 0) + '</div>';
|
||||
html += '</div>';
|
||||
|
||||
html += '</div>'; // Close grid
|
||||
|
||||
// Additional info if available
|
||||
if (data.modern_features && data.queue_size !== undefined) {
|
||||
html += '<div style="background: white; border: 1px solid #e9ecef; border-radius: 6px; padding: 0.75rem; box-shadow: 0 1px 2px rgba(0,0,0,0.03);">';
|
||||
html += '<span style="color: #6c757d; font-size: 0.875rem;">Queue Size: </span>';
|
||||
html += '<span style="font-weight: 500;">' + data.queue_size + '</span>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// Failed jobs warning
|
||||
if (data.failed_jobs_24h > 0) {
|
||||
html += '<div style="background: #fff5f5; border: 1px solid #feb2b2; border-radius: 6px; padding: 0.75rem; color: #c53030;">';
|
||||
html += '<strong>⚠️ Failed Jobs (24h):</strong> ' + data.failed_jobs_24h;
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>'; // Close main container
|
||||
statusEl.innerHTML = html;
|
||||
})
|
||||
.catch(error => {
|
||||
var statusEl = document.getElementById('scheduler-health-status');
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML = '<div class="alert alert-warning">Unable to fetch health status. Ensure scheduler-webhook plugin is installed.</div>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load on page ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', loadHealthStatus);
|
||||
} else {
|
||||
loadHealthStatus();
|
||||
}
|
||||
|
||||
// Refresh every 30 seconds
|
||||
setInterval(loadHealthStatus, 30000);
|
||||
})();
|
||||
</script>
|
||||
markdown: false
|
||||
|
||||
cron_setup:
|
||||
type: display
|
||||
label: Cron Setup Commands
|
||||
content: |
|
||||
<script src="{{ url('plugin://admin/themes/grav/js/clipboard-helper.js') }}"></script>
|
||||
<div class="cron-setup-commands">
|
||||
<script>
|
||||
|
||||
(function() {
|
||||
// Get the Grav root path from the current location
|
||||
var pathParts = window.location.pathname.split('/');
|
||||
var gravPath = '/path/to/grav'; // Default fallback
|
||||
|
||||
// Try to determine actual path (assuming we're in /admin or similar)
|
||||
if (typeof GravAdmin !== 'undefined' && GravAdmin.config && GravAdmin.config.base_url_relative) {
|
||||
// Remove admin path to get to root
|
||||
gravPath = window.location.pathname.replace(/\/admin.*$/, '') || '/';
|
||||
if (gravPath === '/') {
|
||||
gravPath = '/var/www/html'; // Common server path
|
||||
}
|
||||
}
|
||||
|
||||
// For local development, use the actual path
|
||||
if (window.location.hostname === 'trilby.local') {
|
||||
gravPath = '/Users/rhuk/workspace/trilby/grav-editor-pro';
|
||||
}
|
||||
|
||||
var quickInstallCmd = "(crontab -l 2>/dev/null; echo '* * * * * cd " + gravPath + " && bin/grav scheduler 1>> /dev/null 2>&1') | crontab -";
|
||||
var manualEntryCmd = "* * * * * cd " + gravPath + " && bin/grav scheduler 1>> /dev/null 2>&1";
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var quickInput = document.getElementById('cron-quick-install');
|
||||
var manualInput = document.getElementById('cron-manual-entry');
|
||||
|
||||
if (quickInput) quickInput.value = quickInstallCmd;
|
||||
if (manualInput) manualInput.value = manualEntryCmd;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="display: block; margin-bottom: 0.25rem; font-weight: 500;">Quick Install (adds to existing crontab):</label>
|
||||
<div class="form-input-wrapper form-input-addon-wrapper">
|
||||
<input type="text" id="cron-quick-install" readonly value="Loading..." style="font-family: monospace; background: #f5f5f5;">
|
||||
<div class="form-input-addon form-input-append" style="cursor: pointer;" onclick="GravClipboard.copy(this)"><i class="fa fa-copy"></i> Copy</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="display: block; margin-bottom: 0.25rem; font-weight: 500;">Manual Entry (add to crontab -e):</label>
|
||||
<div class="form-input-wrapper form-input-addon-wrapper">
|
||||
<input type="text" id="cron-manual-entry" readonly value="Loading..." style="font-family: monospace; background: #f5f5f5;">
|
||||
<div class="form-input-addon form-input-append" style="cursor: pointer;" onclick="GravClipboard.copy(this)"><i class="fa fa-copy"></i> Copy</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info" style="margin-top: 0.5rem;">
|
||||
<i class="fa fa-info-circle"></i> <strong>Note:</strong> These commands will run the scheduler every minute. Adjust the path if needed before copying.
|
||||
</div>
|
||||
</div>
|
||||
markdown: false
|
||||
|
||||
trigger_methods:
|
||||
type: display
|
||||
label: Active Triggers
|
||||
content: |
|
||||
<div id="scheduler-triggers">
|
||||
<div class="text-muted">Checking triggers...</div>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
function loadTriggers() {
|
||||
// Check cron status from the main status field
|
||||
var cronReady = false;
|
||||
var statusDiv = document.querySelector('.cronstatus-status');
|
||||
if (statusDiv) {
|
||||
var statusText = statusDiv.textContent || statusDiv.innerText;
|
||||
cronReady = statusText.includes('Ready');
|
||||
}
|
||||
|
||||
// Check webhook status
|
||||
fetch(window.location.origin + '/grav-editor-pro/scheduler/health')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
var triggersEl = document.getElementById('scheduler-triggers');
|
||||
if (!triggersEl) return;
|
||||
|
||||
var html = '<div style="display: flex; flex-direction: column; gap: 0.5rem;">';
|
||||
|
||||
// Cron trigger card
|
||||
var cronIcon = cronReady ? '✅' : '❌';
|
||||
var cronStatus = cronReady ? 'Active' : 'Not Configured';
|
||||
var cronStatusColor = cronReady ? '#28a745' : '#6c757d';
|
||||
var cardBg = cronReady ? '#f8f9fa' : '#fff';
|
||||
|
||||
html += '<div style="display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; background: ' + cardBg + '; border: 1px solid #e9ecef; border-radius: 4px;">';
|
||||
html += '<div style="display: flex; align-items: center; gap: 0.75rem;">';
|
||||
html += '<span style="font-size: 1.25rem; line-height: 1;">' + cronIcon + '</span>';
|
||||
html += '<span style="font-weight: 500; color: #212529; font-size: 1rem;">Cron:</span>';
|
||||
html += '</div>';
|
||||
html += '<span style="background: ' + cronStatusColor + '; color: white; padding: 0.25rem 0.75rem; font-size: 0.875rem; font-weight: 500; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.025em;">' + cronStatus + '</span>';
|
||||
html += '</div>';
|
||||
|
||||
// Webhook trigger card
|
||||
if (data.webhook_enabled) {
|
||||
html += '<div style="display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 4px;">';
|
||||
html += '<div style="display: flex; align-items: center; gap: 0.75rem;">';
|
||||
html += '<span style="font-size: 1.25rem; line-height: 1;">✅</span>';
|
||||
html += '<span style="font-weight: 500; color: #212529; font-size: 1rem;">Webhook:</span>';
|
||||
html += '</div>';
|
||||
html += '<span style="background: #28a745; color: white; padding: 0.25rem 0.75rem; font-size: 0.875rem; font-weight: 500; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.025em;">ACTIVE</span>';
|
||||
html += '</div>';
|
||||
} else {
|
||||
// Show webhook as not configured/disabled
|
||||
html += '<div style="display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; background: #fff; border: 1px solid #e9ecef; border-radius: 4px;">';
|
||||
html += '<div style="display: flex; align-items: center; gap: 0.75rem;">';
|
||||
html += '<span style="font-size: 1.25rem; line-height: 1;">⚠️</span>';
|
||||
html += '<span style="font-weight: 500; color: #212529; font-size: 1rem;">Webhook:</span>';
|
||||
html += '</div>';
|
||||
html += '<span style="background: #ffc107; color: #212529; padding: 0.25rem 0.75rem; font-size: 0.875rem; font-weight: 500; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.025em;">DISABLED</span>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
// Add warning if no triggers active
|
||||
if (!cronReady && !data.webhook_enabled) {
|
||||
html += '<div class="alert alert-warning" style="margin-top: 1rem;"><i class="fa fa-exclamation-triangle"></i> No triggers active! Configure cron or enable webhooks.</div>';
|
||||
}
|
||||
|
||||
triggersEl.innerHTML = html;
|
||||
})
|
||||
.catch(error => {
|
||||
var triggersEl = document.getElementById('scheduler-triggers');
|
||||
if (triggersEl) {
|
||||
// Show just cron status if health endpoint not available
|
||||
var html = '<ul class="list-unstyled">';
|
||||
if (cronReady) {
|
||||
html += '<li>✅ <strong>Cron:</strong> <span class="badge badge-success">Active</span></li>';
|
||||
} else {
|
||||
html += '<li>❌ <strong>Cron:</strong> <span class="badge badge-secondary">Not Configured</span></li>';
|
||||
}
|
||||
html += '<li>⚠️ <strong>Webhook:</strong> <span class="badge badge-secondary">Plugin Not Installed</span></li>';
|
||||
html += '</ul>';
|
||||
triggersEl.innerHTML = html;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load on page ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', loadTriggers);
|
||||
} else {
|
||||
loadTriggers();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
markdown: false
|
||||
|
||||
jobs_tab:
|
||||
type: tab
|
||||
title: PLUGIN_ADMIN.SCHEDULER_JOBS
|
||||
|
||||
fields:
|
||||
jobs_title:
|
||||
type: section
|
||||
title: PLUGIN_ADMIN.SCHEDULER_JOBS
|
||||
underline: true
|
||||
|
||||
custom_jobs:
|
||||
type: list
|
||||
style: vertical
|
||||
label:
|
||||
classes: cron-job-list compact
|
||||
key: id
|
||||
fields:
|
||||
.id:
|
||||
type: key
|
||||
label: ID
|
||||
placeholder: 'process-name'
|
||||
validate:
|
||||
required: true
|
||||
pattern: '[a-zа-я0-9_\-]+'
|
||||
max: 20
|
||||
message: 'ID must be lowercase with dashes/underscores only and less than 20 characters'
|
||||
.command:
|
||||
type: text
|
||||
label: PLUGIN_ADMIN.COMMAND
|
||||
placeholder: 'ls'
|
||||
validate:
|
||||
required: true
|
||||
.args:
|
||||
type: text
|
||||
label: PLUGIN_ADMIN.EXTRA_ARGUMENTS
|
||||
placeholder: '-lah'
|
||||
.at:
|
||||
type: text
|
||||
wrapper_classes: cron-selector
|
||||
label: PLUGIN_ADMIN.SCHEDULER_RUNAT
|
||||
help: PLUGIN_ADMIN.SCHEDULER_RUNAT_HELP
|
||||
placeholder: '* * * * *'
|
||||
validate:
|
||||
required: true
|
||||
.output:
|
||||
type: text
|
||||
label: PLUGIN_ADMIN.SCHEDULER_OUTPUT
|
||||
help: PLUGIN_ADMIN.SCHEDULER_OUTPUT_HELP
|
||||
placeholder: 'logs/ls-cron.out'
|
||||
.output_mode:
|
||||
type: select
|
||||
label: PLUGIN_ADMIN.SCHEDULER_OUTPUT_TYPE
|
||||
help: PLUGIN_ADMIN.SCHEDULER_OUTPUT_TYPE_HELP
|
||||
default: append
|
||||
options:
|
||||
append: Append
|
||||
overwrite: Overwrite
|
||||
.email:
|
||||
type: text
|
||||
label: PLUGIN_ADMIN.SCHEDULER_EMAIL
|
||||
help: PLUGIN_ADMIN.SCHEDULER_EMAIL_HELP
|
||||
placeholder: 'notifications@yoursite.com'
|
||||
|
||||
modern_tab:
|
||||
type: tab
|
||||
title: Advanced Features
|
||||
|
||||
fields:
|
||||
workers_section:
|
||||
type: section
|
||||
title: Worker Configuration
|
||||
underline: true
|
||||
|
||||
fields:
|
||||
modern.workers:
|
||||
type: number
|
||||
label: Concurrent Workers
|
||||
help: Number of jobs that can run simultaneously (1 = sequential)
|
||||
default: 4
|
||||
size: x-small
|
||||
append: workers
|
||||
validate:
|
||||
type: int
|
||||
min: 1
|
||||
max: 10
|
||||
|
||||
retry_section:
|
||||
type: section
|
||||
title: Retry Configuration
|
||||
underline: true
|
||||
|
||||
fields:
|
||||
modern.retry.enabled:
|
||||
type: toggle
|
||||
label: Enable Job Retry
|
||||
help: Automatically retry failed jobs
|
||||
highlight: 1
|
||||
default: 1
|
||||
options:
|
||||
1: PLUGIN_ADMIN.ENABLED
|
||||
0: PLUGIN_ADMIN.DISABLED
|
||||
validate:
|
||||
type: bool
|
||||
|
||||
modern.retry.max_attempts:
|
||||
type: number
|
||||
label: Maximum Retry Attempts
|
||||
help: Maximum number of times to retry a failed job
|
||||
default: 3
|
||||
size: x-small
|
||||
append: retries
|
||||
validate:
|
||||
type: int
|
||||
min: 1
|
||||
max: 10
|
||||
|
||||
modern.retry.backoff:
|
||||
type: select
|
||||
label: Retry Backoff Strategy
|
||||
help: How to calculate delay between retries
|
||||
default: exponential
|
||||
options:
|
||||
linear: Linear (fixed delay)
|
||||
exponential: Exponential (increasing delay)
|
||||
|
||||
queue_section:
|
||||
type: section
|
||||
title: Queue Configuration
|
||||
underline: true
|
||||
|
||||
fields:
|
||||
modern.queue.path:
|
||||
type: text
|
||||
label: Queue Storage Path
|
||||
help: Where to store queued jobs
|
||||
default: 'user-data://scheduler/queue'
|
||||
placeholder: 'user-data://scheduler/queue'
|
||||
|
||||
modern.queue.max_size:
|
||||
type: number
|
||||
label: Maximum Queue Size
|
||||
help: Maximum number of jobs that can be queued
|
||||
default: 1000
|
||||
size: x-small
|
||||
append: jobs
|
||||
validate:
|
||||
type: int
|
||||
min: 100
|
||||
max: 10000
|
||||
|
||||
history_section:
|
||||
type: section
|
||||
title: Job History
|
||||
underline: true
|
||||
|
||||
fields:
|
||||
modern.history.enabled:
|
||||
type: toggle
|
||||
label: Enable Job History
|
||||
help: Track execution history for all jobs
|
||||
highlight: 1
|
||||
default: 1
|
||||
options:
|
||||
1: PLUGIN_ADMIN.ENABLED
|
||||
0: PLUGIN_ADMIN.DISABLED
|
||||
validate:
|
||||
type: bool
|
||||
|
||||
modern.history.retention_days:
|
||||
type: number
|
||||
label: History Retention (days)
|
||||
help: How long to keep job history
|
||||
default: 30
|
||||
size: x-small
|
||||
append: days
|
||||
validate:
|
||||
type: int
|
||||
min: 1
|
||||
max: 365
|
||||
|
||||
webhook_section:
|
||||
type: section
|
||||
title: Webhook Configuration
|
||||
underline: true
|
||||
|
||||
fields:
|
||||
webhook_plugin_status:
|
||||
type: webhook-status
|
||||
label:
|
||||
modern.webhook.enabled:
|
||||
type: toggle
|
||||
label: Enable Webhook Triggers
|
||||
help: Allow triggering scheduler via HTTP webhook
|
||||
highlight: 0
|
||||
default: 0
|
||||
options:
|
||||
1: PLUGIN_ADMIN.ENABLED
|
||||
0: PLUGIN_ADMIN.DISABLED
|
||||
validate:
|
||||
type: bool
|
||||
|
||||
modern.webhook.token:
|
||||
type: text
|
||||
label: Webhook Security Token
|
||||
help: Secret token for authenticating webhook requests. Keep this secret!
|
||||
placeholder: 'Click Generate to create a secure token'
|
||||
autocomplete: 'off'
|
||||
|
||||
webhook_token_generate:
|
||||
type: display
|
||||
label:
|
||||
content: |
|
||||
<div style="margin-top: -10px; margin-bottom: 15px;">
|
||||
<button type="button" class="button button-primary" onclick="generateWebhookToken()">
|
||||
<i class="fa fa-refresh"></i> Generate Token
|
||||
</button>
|
||||
</div>
|
||||
<script>
|
||||
function generateWebhookToken() {
|
||||
try {
|
||||
// Generate token
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
const token = Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
// Try multiple selectors to find the field
|
||||
let field = document.querySelector('[name="data[scheduler][modern][webhook][token]"]');
|
||||
if (!field) {
|
||||
field = document.querySelector('input[name*="webhook][token"]');
|
||||
}
|
||||
if (!field) {
|
||||
field = document.getElementById('scheduler-modern-webhook-token');
|
||||
}
|
||||
if (!field) {
|
||||
// Look for any text input in the webhook section
|
||||
const webhookSection = document.querySelector('.webhook_section');
|
||||
if (webhookSection) {
|
||||
const inputs = webhookSection.querySelectorAll('input[type="text"]');
|
||||
// Find the token field by checking for the placeholder
|
||||
for (let input of inputs) {
|
||||
if (input.placeholder && input.placeholder.includes('Generate')) {
|
||||
field = input;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (field) {
|
||||
field.value = token;
|
||||
field.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
field.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
// Flash the field to show it was updated
|
||||
field.style.backgroundColor = '#d4edda';
|
||||
setTimeout(function() {
|
||||
field.style.backgroundColor = '';
|
||||
}, 500);
|
||||
// Also try to trigger Grav's form change detection
|
||||
if (window.jQuery) {
|
||||
jQuery(field).trigger('change');
|
||||
}
|
||||
} else {
|
||||
// Log more debugging info
|
||||
console.error('Token field not found. Looking for input fields...');
|
||||
console.log('All inputs:', document.querySelectorAll('input[type="text"]'));
|
||||
alert('Could not find the token field. Please ensure you are in the Advanced Features tab and the Webhook Configuration section is visible.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error generating token:', e);
|
||||
alert('Error generating token: ' + e.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
markdown: false
|
||||
|
||||
modern.webhook.path:
|
||||
type: text
|
||||
label: Webhook Path
|
||||
help: URL path for webhook endpoint
|
||||
default: '/scheduler/webhook'
|
||||
placeholder: '/scheduler/webhook'
|
||||
|
||||
health_section:
|
||||
type: section
|
||||
title: Health Check Configuration
|
||||
underline: true
|
||||
|
||||
fields:
|
||||
modern.health.enabled:
|
||||
type: toggle
|
||||
label: Enable Health Check
|
||||
help: Provide health status endpoint for monitoring
|
||||
highlight: 1
|
||||
default: 1
|
||||
options:
|
||||
1: PLUGIN_ADMIN.ENABLED
|
||||
0: PLUGIN_ADMIN.DISABLED
|
||||
validate:
|
||||
type: bool
|
||||
|
||||
modern.health.path:
|
||||
type: text
|
||||
label: Health Check Path
|
||||
help: URL path for health check endpoint
|
||||
default: '/scheduler/health'
|
||||
placeholder: '/scheduler/health'
|
||||
|
||||
webhook_usage:
|
||||
type: section
|
||||
title: Usage Examples
|
||||
underline: true
|
||||
|
||||
fields:
|
||||
webhook_examples:
|
||||
type: display
|
||||
label:
|
||||
content: |
|
||||
<script src="{{ url('plugin://admin/themes/grav/js/clipboard-helper.js') }}"></script>
|
||||
<div class="webhook-examples">
|
||||
<script>
|
||||
// Initialize webhook commands when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (typeof GravClipboard !== 'undefined') {
|
||||
GravClipboard.initWebhookCommands();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<h4>How to use webhooks:</h4>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="display: block; margin-bottom: 0.25rem; font-weight: 500;">Trigger all due jobs (respects schedule):</label>
|
||||
<div class="form-input-wrapper form-input-addon-wrapper">
|
||||
<textarea id="webhook-all-cmd" readonly rows="2" style="font-family: monospace; background: #f5f5f5; resize: none;">Loading...</textarea>
|
||||
<div class="form-input-addon form-input-append" style="cursor: pointer;" onclick="GravClipboard.copy(this)"><i class="fa fa-copy"></i> Copy</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="display: block; margin-bottom: 0.25rem; font-weight: 500;">Force-run specific job (ignores schedule):</label>
|
||||
<div class="form-input-wrapper form-input-addon-wrapper">
|
||||
<textarea id="webhook-job-cmd" readonly rows="2" style="font-family: monospace; background: #f5f5f5; resize: none;">Loading...</textarea>
|
||||
<div class="form-input-addon form-input-append" style="cursor: pointer;" onclick="GravClipboard.copy(this)"><i class="fa fa-copy"></i> Copy</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="display: block; margin-bottom: 0.25rem; font-weight: 500;">Check health status:</label>
|
||||
<div class="form-input-wrapper form-input-addon-wrapper">
|
||||
<input type="text" id="webhook-health-cmd" readonly value="Loading..." style="font-family: monospace; background: #f5f5f5;">
|
||||
<div class="form-input-addon form-input-append" style="cursor: pointer;" onclick="GravClipboard.copy(this)"><i class="fa fa-copy"></i> Copy</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1rem;">
|
||||
<p><strong>GitHub Actions example:</strong></p>
|
||||
<pre>- name: Trigger Scheduler
|
||||
run: |
|
||||
curl -X POST ${{ secrets.SITE_URL }}/scheduler/webhook \
|
||||
-H "Authorization: Bearer ${{ secrets.WEBHOOK_TOKEN }}"</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
markdown: false
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -633,6 +633,19 @@ form:
|
||||
help: PLUGIN_ADMIN.CACHE_PREFIX_HELP
|
||||
placeholder: PLUGIN_ADMIN.CACHE_PREFIX_PLACEHOLDER
|
||||
|
||||
cache.purge_max_age_days:
|
||||
type: text
|
||||
size: x-small
|
||||
append: GRAV.NICETIME.DAY_PLURAL
|
||||
label: PLUGIN_ADMIN.CACHE_PURGE_AGE
|
||||
help: PLUGIN_ADMIN.CACHE_PURGE_AGE_HELP
|
||||
validate:
|
||||
type: number
|
||||
min: 1
|
||||
max: 365
|
||||
step: 1
|
||||
default: 30
|
||||
|
||||
cache.purge_at:
|
||||
type: cron
|
||||
label: PLUGIN_ADMIN.CACHE_PURGE_JOB
|
||||
|
||||
68
system/config/scheduler.yaml
Normal file
68
system/config/scheduler.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
# Grav Scheduler Configuration
|
||||
|
||||
# Default scheduler settings (backward compatible)
|
||||
defaults:
|
||||
output: true
|
||||
output_type: file
|
||||
email: null
|
||||
|
||||
# Status of individual jobs (enabled/disabled)
|
||||
status: {}
|
||||
|
||||
# Custom scheduled jobs
|
||||
custom_jobs: {}
|
||||
|
||||
# Modern scheduler features (disabled by default for backward compatibility)
|
||||
modern:
|
||||
# Enable modern scheduler features
|
||||
enabled: false
|
||||
|
||||
# Number of concurrent workers (1 = sequential execution like legacy)
|
||||
workers: 1
|
||||
|
||||
# Job retry configuration
|
||||
retry:
|
||||
enabled: true
|
||||
max_attempts: 3
|
||||
backoff: exponential # 'linear' or 'exponential'
|
||||
|
||||
# Job queue configuration
|
||||
queue:
|
||||
path: user-data://scheduler/queue
|
||||
max_size: 1000
|
||||
|
||||
# Webhook trigger configuration
|
||||
webhook:
|
||||
enabled: false
|
||||
token: null # Set a secure token to enable webhook triggers
|
||||
path: /scheduler/webhook
|
||||
|
||||
# Health check endpoint
|
||||
health:
|
||||
enabled: true
|
||||
path: /scheduler/health
|
||||
|
||||
# Job execution history
|
||||
history:
|
||||
enabled: true
|
||||
retention_days: 30
|
||||
path: user-data://scheduler/history
|
||||
|
||||
# Performance settings
|
||||
performance:
|
||||
job_timeout: 300 # Default timeout in seconds
|
||||
lock_timeout: 10 # Lock acquisition timeout in seconds
|
||||
|
||||
# Monitoring and alerts
|
||||
monitoring:
|
||||
enabled: false
|
||||
alert_on_failure: true
|
||||
alert_email: null
|
||||
webhook_url: null
|
||||
|
||||
# Trigger detection methods
|
||||
triggers:
|
||||
check_cron: true
|
||||
check_systemd: true
|
||||
check_webhook: true
|
||||
check_external: true
|
||||
@@ -101,6 +101,7 @@ cache:
|
||||
clear_images_by_default: false # By default grav does not include processed images in cache clear, this can be enabled
|
||||
cli_compatibility: false # Ensures only non-volatile drivers are used (file, redis, memcache, etc.)
|
||||
lifetime: 604800 # Lifetime of cached data in seconds (0 = infinite)
|
||||
purge_max_age_days: 30 # Maximum age of cache items in days before they are purged
|
||||
gzip: false # GZip compress the page output
|
||||
allow_webserver_gzip: false # If true, `content-encoding: identity` but connection isn't closed before `onShutDown()` event
|
||||
redis:
|
||||
|
||||
@@ -170,24 +170,75 @@ class Cache extends Getters
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the old out of date file-based caches
|
||||
* Deletes old cache files based on age
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function purgeOldCache()
|
||||
{
|
||||
// Get the max age for cache files from config (default 30 days)
|
||||
$max_age_days = $this->config->get('system.cache.purge_max_age_days', 30);
|
||||
$max_age_seconds = $max_age_days * 86400; // Convert days to seconds
|
||||
$now = time();
|
||||
$count = 0;
|
||||
|
||||
// First, clean up old orphaned cache directories (not the current one)
|
||||
$cache_dir = dirname($this->cache_dir);
|
||||
$current = Utils::basename($this->cache_dir);
|
||||
$count = 0;
|
||||
|
||||
|
||||
foreach (new DirectoryIterator($cache_dir) as $file) {
|
||||
$dir = $file->getBasename();
|
||||
if ($dir === $current || $file->isDot() || $file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Folder::delete($file->getPathname());
|
||||
$count++;
|
||||
|
||||
// Check if directory is old and empty or very old (90+ days)
|
||||
$dir_age = $now - $file->getMTime();
|
||||
if ($dir_age > 7776000) { // 90 days
|
||||
Folder::delete($file->getPathname());
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
// Now clean up old cache files within the current cache directory
|
||||
if (is_dir($this->cache_dir)) {
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($this->cache_dir, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isFile()) {
|
||||
$file_age = $now - $file->getMTime();
|
||||
if ($file_age > $max_age_seconds) {
|
||||
@unlink($file->getPathname());
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also clean up old files in compiled cache
|
||||
$grav = Grav::instance();
|
||||
$compiled_dir = $this->config->get('system.cache.compiled_dir', 'cache://compiled');
|
||||
$compiled_path = $grav['locator']->findResource($compiled_dir, true);
|
||||
|
||||
if ($compiled_path && is_dir($compiled_path)) {
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($compiled_path, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isFile()) {
|
||||
$file_age = $now - $file->getMTime();
|
||||
// Compiled files can be kept longer (60 days)
|
||||
if ($file_age > ($max_age_seconds * 2)) {
|
||||
@unlink($file->getPathname());
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
@@ -632,8 +683,10 @@ class Cache extends Getters
|
||||
{
|
||||
/** @var Cache $cache */
|
||||
$cache = Grav::instance()['cache'];
|
||||
$deleted_folders = $cache->purgeOldCache();
|
||||
$msg = 'Purged ' . $deleted_folders . ' old cache folders...';
|
||||
$deleted_items = $cache->purgeOldCache();
|
||||
|
||||
$max_age = $cache->config->get('system.cache.purge_max_age_days', 30);
|
||||
$msg = 'Purged ' . $deleted_items . ' old cache items (files older than ' . $max_age . ' days)';
|
||||
|
||||
if ($echo) {
|
||||
echo $msg;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
namespace Grav\Common\Scheduler;
|
||||
|
||||
use Closure;
|
||||
use Cron\CronExpression;
|
||||
use Dragonmantank\Cron\CronExpression;
|
||||
use DateTime;
|
||||
use Grav\Common\Grav;
|
||||
use InvalidArgumentException;
|
||||
@@ -77,6 +77,40 @@ class Job
|
||||
private $successful = false;
|
||||
/** @var string|null */
|
||||
private $backlink;
|
||||
|
||||
// Modern Job features
|
||||
/** @var int */
|
||||
protected $maxAttempts = 3;
|
||||
/** @var int */
|
||||
protected $retryCount = 0;
|
||||
/** @var int */
|
||||
protected $retryDelay = 60; // seconds
|
||||
/** @var string */
|
||||
protected $retryStrategy = 'exponential'; // 'linear' or 'exponential'
|
||||
/** @var float */
|
||||
protected $executionStartTime;
|
||||
/** @var float */
|
||||
protected $executionDuration = 0;
|
||||
/** @var int */
|
||||
protected $timeout = 300; // 5 minutes default
|
||||
/** @var array */
|
||||
protected $dependencies = [];
|
||||
/** @var array */
|
||||
protected $chainedJobs = [];
|
||||
/** @var string|null */
|
||||
protected $queueId;
|
||||
/** @var string */
|
||||
protected $priority = 'normal'; // 'high', 'normal', 'low'
|
||||
/** @var array */
|
||||
protected $metadata = [];
|
||||
/** @var array */
|
||||
protected $tags = [];
|
||||
/** @var callable|null */
|
||||
protected $onSuccess;
|
||||
/** @var callable|null */
|
||||
protected $onFailure;
|
||||
/** @var callable|null */
|
||||
protected $onRetry;
|
||||
|
||||
/**
|
||||
* Create a new Job instance.
|
||||
@@ -150,6 +184,16 @@ class Job
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw arguments (array or string)
|
||||
*
|
||||
* @return array|string
|
||||
*/
|
||||
public function getRawArguments()
|
||||
{
|
||||
return $this->args;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return CronExpression
|
||||
@@ -315,6 +359,13 @@ class Job
|
||||
*/
|
||||
public function run()
|
||||
{
|
||||
// Check dependencies (modern feature)
|
||||
if (!$this->checkDependencies()) {
|
||||
$this->output = 'Dependencies not met';
|
||||
$this->successful = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the truthTest failed, don't run
|
||||
if ($this->truthTest !== true) {
|
||||
return false;
|
||||
@@ -340,6 +391,11 @@ class Job
|
||||
$args = is_string($this->args) ? explode(' ', $this->args) : $this->args;
|
||||
$command = array_merge([$this->command], $args);
|
||||
$process = new Process($command);
|
||||
|
||||
// Apply timeout if set (modern feature)
|
||||
if ($this->timeout > 0) {
|
||||
$process->setTimeout($this->timeout);
|
||||
}
|
||||
|
||||
$this->process = $process;
|
||||
|
||||
@@ -563,4 +619,455 @@ class Job
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
// Modern Job Methods
|
||||
|
||||
/**
|
||||
* Set maximum retry attempts
|
||||
*
|
||||
* @param int $attempts
|
||||
* @return self
|
||||
*/
|
||||
public function maxAttempts(int $attempts): self
|
||||
{
|
||||
$this->maxAttempts = $attempts;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum retry attempts
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getMaxAttempts(): int
|
||||
{
|
||||
return $this->maxAttempts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set retry delay
|
||||
*
|
||||
* @param int $seconds
|
||||
* @param string $strategy 'linear' or 'exponential'
|
||||
* @return self
|
||||
*/
|
||||
public function retryDelay(int $seconds, string $strategy = 'exponential'): self
|
||||
{
|
||||
$this->retryDelay = $seconds;
|
||||
$this->retryStrategy = $strategy;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current retry count
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getRetryCount(): int
|
||||
{
|
||||
return $this->retryCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set job timeout
|
||||
*
|
||||
* @param int $seconds
|
||||
* @return self
|
||||
*/
|
||||
public function timeout(int $seconds): self
|
||||
{
|
||||
$this->timeout = $seconds;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set job priority
|
||||
*
|
||||
* @param string $priority 'high', 'normal', or 'low'
|
||||
* @return self
|
||||
*/
|
||||
public function priority(string $priority): self
|
||||
{
|
||||
if (!in_array($priority, ['high', 'normal', 'low'])) {
|
||||
throw new InvalidArgumentException('Priority must be high, normal, or low');
|
||||
}
|
||||
$this->priority = $priority;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get job priority
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getPriority(): string
|
||||
{
|
||||
return $this->priority;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add job dependency
|
||||
*
|
||||
* @param string $jobId
|
||||
* @return self
|
||||
*/
|
||||
public function dependsOn(string $jobId): self
|
||||
{
|
||||
$this->dependencies[] = $jobId;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain another job to run after this one
|
||||
*
|
||||
* @param Job $job
|
||||
* @param bool $onlyOnSuccess Run only if current job succeeds
|
||||
* @return self
|
||||
*/
|
||||
public function chain(Job $job, bool $onlyOnSuccess = true): self
|
||||
{
|
||||
$this->chainedJobs[] = [
|
||||
'job' => $job,
|
||||
'onlyOnSuccess' => $onlyOnSuccess,
|
||||
];
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add metadata to the job
|
||||
*
|
||||
* @param string $key
|
||||
* @param mixed $value
|
||||
* @return self
|
||||
*/
|
||||
public function withMetadata(string $key, $value): self
|
||||
{
|
||||
$this->metadata[$key] = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add tags to the job
|
||||
*
|
||||
* @param array $tags
|
||||
* @return self
|
||||
*/
|
||||
public function withTags(array $tags): self
|
||||
{
|
||||
$this->tags = array_merge($this->tags, $tags);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set success callback
|
||||
*
|
||||
* @param callable $callback
|
||||
* @return self
|
||||
*/
|
||||
public function onSuccess(callable $callback): self
|
||||
{
|
||||
$this->onSuccess = $callback;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set failure callback
|
||||
*
|
||||
* @param callable $callback
|
||||
* @return self
|
||||
*/
|
||||
public function onFailure(callable $callback): self
|
||||
{
|
||||
$this->onFailure = $callback;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set retry callback
|
||||
*
|
||||
* @param callable $callback
|
||||
* @return self
|
||||
*/
|
||||
public function onRetry(callable $callback): self
|
||||
{
|
||||
$this->onRetry = $callback;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the job with retry support
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function runWithRetry(): bool
|
||||
{
|
||||
$attempts = 0;
|
||||
$lastException = null;
|
||||
|
||||
while ($attempts < $this->maxAttempts) {
|
||||
$attempts++;
|
||||
$this->retryCount = $attempts - 1;
|
||||
|
||||
try {
|
||||
// Record execution start time
|
||||
$this->executionStartTime = microtime(true);
|
||||
|
||||
// Run the job
|
||||
$result = $this->run();
|
||||
|
||||
// Record execution time
|
||||
$this->executionDuration = microtime(true) - $this->executionStartTime;
|
||||
|
||||
if ($result && $this->isSuccessful()) {
|
||||
// Call success callback
|
||||
if ($this->onSuccess) {
|
||||
call_user_func($this->onSuccess, $this);
|
||||
}
|
||||
|
||||
// Run chained jobs
|
||||
$this->runChainedJobs(true);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new RuntimeException('Job execution failed');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$lastException = $e;
|
||||
$this->output = $e->getMessage();
|
||||
$this->successful = false;
|
||||
|
||||
if ($attempts < $this->maxAttempts) {
|
||||
// Call retry callback
|
||||
if ($this->onRetry) {
|
||||
call_user_func($this->onRetry, $this, $attempts, $e);
|
||||
}
|
||||
|
||||
// Calculate delay before retry
|
||||
$delay = $this->calculateRetryDelay($attempts);
|
||||
if ($delay > 0) {
|
||||
sleep($delay);
|
||||
}
|
||||
} else {
|
||||
// Final failure
|
||||
if ($this->onFailure) {
|
||||
call_user_func($this->onFailure, $this, $e);
|
||||
}
|
||||
|
||||
// Run chained jobs that should run on failure
|
||||
$this->runChainedJobs(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get execution time in seconds
|
||||
*
|
||||
* @return float
|
||||
*/
|
||||
public function getExecutionTime(): float
|
||||
{
|
||||
return $this->executionDuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get job metadata
|
||||
*
|
||||
* @param string|null $key
|
||||
* @return mixed
|
||||
*/
|
||||
public function getMetadata(string $key = null)
|
||||
{
|
||||
if ($key === null) {
|
||||
return $this->metadata;
|
||||
}
|
||||
|
||||
return $this->metadata[$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get job tags
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getTags(): array
|
||||
{
|
||||
return $this->tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if job has a specific tag
|
||||
*
|
||||
* @param string $tag
|
||||
* @return bool
|
||||
*/
|
||||
public function hasTag(string $tag): bool
|
||||
{
|
||||
return in_array($tag, $this->tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set queue ID
|
||||
*
|
||||
* @param string $queueId
|
||||
* @return self
|
||||
*/
|
||||
public function setQueueId(string $queueId): self
|
||||
{
|
||||
$this->queueId = $queueId;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue ID
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getQueueId(): ?string
|
||||
{
|
||||
return $this->queueId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get process (for background jobs)
|
||||
*
|
||||
* @return Process|null
|
||||
*/
|
||||
public function getProcess(): ?Process
|
||||
{
|
||||
return $this->process;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate retry delay based on strategy
|
||||
*
|
||||
* @param int $attempt
|
||||
* @return int
|
||||
*/
|
||||
protected function calculateRetryDelay(int $attempt): int
|
||||
{
|
||||
if ($this->retryStrategy === 'exponential') {
|
||||
return min($this->retryDelay * pow(2, $attempt - 1), 3600); // Max 1 hour
|
||||
}
|
||||
|
||||
return $this->retryDelay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if dependencies are met
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function checkDependencies(): bool
|
||||
{
|
||||
if (empty($this->dependencies)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// This would need to check against job history or status
|
||||
// For now, we'll assume dependencies are met
|
||||
// In a real implementation, this would check the Scheduler's job status
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run chained jobs
|
||||
*
|
||||
* @param bool $success Whether the current job succeeded
|
||||
* @return void
|
||||
*/
|
||||
protected function runChainedJobs(bool $success): void
|
||||
{
|
||||
foreach ($this->chainedJobs as $chainedJob) {
|
||||
$shouldRun = !$chainedJob['onlyOnSuccess'] || $success;
|
||||
|
||||
if ($shouldRun) {
|
||||
$job = $chainedJob['job'];
|
||||
if (method_exists($job, 'runWithRetry')) {
|
||||
$job->runWithRetry();
|
||||
} else {
|
||||
$job->run();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert job to array for serialization
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->getId(),
|
||||
'command' => is_string($this->command) ? $this->command : 'Closure',
|
||||
'at' => $this->getAt(),
|
||||
'enabled' => $this->getEnabled(),
|
||||
'priority' => $this->priority,
|
||||
'max_attempts' => $this->maxAttempts,
|
||||
'retry_count' => $this->retryCount,
|
||||
'retry_delay' => $this->retryDelay,
|
||||
'retry_strategy' => $this->retryStrategy,
|
||||
'timeout' => $this->timeout,
|
||||
'dependencies' => $this->dependencies,
|
||||
'metadata' => $this->metadata,
|
||||
'tags' => $this->tags,
|
||||
'execution_time' => $this->executionDuration,
|
||||
'successful' => $this->successful,
|
||||
'output' => $this->output,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create job from array
|
||||
*
|
||||
* @param array $data
|
||||
* @return self
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$job = new self($data['command'] ?? '', [], $data['id'] ?? null);
|
||||
|
||||
if (isset($data['at'])) {
|
||||
$job->at($data['at']);
|
||||
}
|
||||
|
||||
if (isset($data['priority'])) {
|
||||
$job->priority($data['priority']);
|
||||
}
|
||||
|
||||
if (isset($data['max_attempts'])) {
|
||||
$job->maxAttempts($data['max_attempts']);
|
||||
}
|
||||
|
||||
if (isset($data['retry_delay']) && isset($data['retry_strategy'])) {
|
||||
$job->retryDelay($data['retry_delay'], $data['retry_strategy']);
|
||||
}
|
||||
|
||||
if (isset($data['timeout'])) {
|
||||
$job->timeout($data['timeout']);
|
||||
}
|
||||
|
||||
if (isset($data['dependencies'])) {
|
||||
foreach ($data['dependencies'] as $dep) {
|
||||
$job->dependsOn($dep);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($data['metadata'])) {
|
||||
foreach ($data['metadata'] as $key => $value) {
|
||||
$job->withMetadata($key, $value);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($data['tags'])) {
|
||||
$job->withTags($data['tags']);
|
||||
}
|
||||
|
||||
return $job;
|
||||
}
|
||||
}
|
||||
|
||||
462
system/src/Grav/Common/Scheduler/JobHistory.php
Normal file
462
system/src/Grav/Common/Scheduler/JobHistory.php
Normal file
@@ -0,0 +1,462 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Grav\Common\Scheduler
|
||||
*
|
||||
* @copyright Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.
|
||||
* @license MIT License; see LICENSE file for details.
|
||||
*/
|
||||
|
||||
namespace Grav\Common\Scheduler;
|
||||
|
||||
use DateTime;
|
||||
use RocketTheme\Toolbox\File\JsonFile;
|
||||
|
||||
/**
|
||||
* Job History Manager
|
||||
*
|
||||
* Provides comprehensive job execution history, logging, and analytics
|
||||
*
|
||||
* @package Grav\Common\Scheduler
|
||||
*/
|
||||
class JobHistory
|
||||
{
|
||||
/** @var string */
|
||||
protected $historyPath;
|
||||
|
||||
/** @var int */
|
||||
protected $retentionDays = 30;
|
||||
|
||||
/** @var int */
|
||||
protected $maxOutputLength = 5000;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param string $historyPath
|
||||
* @param int $retentionDays
|
||||
*/
|
||||
public function __construct(string $historyPath, int $retentionDays = 30)
|
||||
{
|
||||
$this->historyPath = $historyPath;
|
||||
$this->retentionDays = $retentionDays;
|
||||
|
||||
// Ensure history directory exists
|
||||
if (!is_dir($this->historyPath)) {
|
||||
mkdir($this->historyPath, 0755, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log job execution
|
||||
*
|
||||
* @param Job $job
|
||||
* @param array $metadata Additional metadata to store
|
||||
* @return string Log entry ID
|
||||
*/
|
||||
public function logExecution(Job $job, array $metadata = []): string
|
||||
{
|
||||
$entryId = uniqid($job->getId() . '_', true);
|
||||
$timestamp = new DateTime();
|
||||
|
||||
$entry = [
|
||||
'id' => $entryId,
|
||||
'job_id' => $job->getId(),
|
||||
'command' => is_string($job->getCommand()) ? $job->getCommand() : 'Closure',
|
||||
'arguments' => method_exists($job, 'getRawArguments') ? $job->getRawArguments() : $job->getArguments(),
|
||||
'executed_at' => $timestamp->format('c'),
|
||||
'timestamp' => $timestamp->getTimestamp(),
|
||||
'success' => $job->isSuccessful(),
|
||||
'output' => $this->captureOutput($job),
|
||||
'execution_time' => method_exists($job, 'getExecutionTime') ? $job->getExecutionTime() : null,
|
||||
'retry_count' => method_exists($job, 'getRetryCount') ? $job->getRetryCount() : 0,
|
||||
'priority' => method_exists($job, 'getPriority') ? $job->getPriority() : 'normal',
|
||||
'tags' => method_exists($job, 'getTags') ? $job->getTags() : [],
|
||||
'metadata' => array_merge(
|
||||
method_exists($job, 'getMetadata') ? $job->getMetadata() : [],
|
||||
$metadata
|
||||
),
|
||||
];
|
||||
|
||||
// Store in daily file
|
||||
$this->storeEntry($entry);
|
||||
|
||||
// Also store in job-specific history
|
||||
$this->storeJobHistory($job->getId(), $entry);
|
||||
|
||||
return $entryId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture job output with length limit
|
||||
*
|
||||
* @param Job $job
|
||||
* @return array
|
||||
*/
|
||||
protected function captureOutput(Job $job): array
|
||||
{
|
||||
$output = $job->getOutput();
|
||||
$truncated = false;
|
||||
|
||||
if (strlen($output) > $this->maxOutputLength) {
|
||||
$output = substr($output, 0, $this->maxOutputLength);
|
||||
$truncated = true;
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => $output,
|
||||
'truncated' => $truncated,
|
||||
'length' => strlen($job->getOutput()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Store entry in daily log file
|
||||
*
|
||||
* @param array $entry
|
||||
* @return void
|
||||
*/
|
||||
protected function storeEntry(array $entry): void
|
||||
{
|
||||
$date = date('Y-m-d');
|
||||
$filename = $this->historyPath . '/' . $date . '.json';
|
||||
|
||||
$jsonFile = JsonFile::instance($filename);
|
||||
$entries = $jsonFile->content() ?: [];
|
||||
$entries[] = $entry;
|
||||
$jsonFile->save($entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store job-specific history
|
||||
*
|
||||
* @param string $jobId
|
||||
* @param array $entry
|
||||
* @return void
|
||||
*/
|
||||
protected function storeJobHistory(string $jobId, array $entry): void
|
||||
{
|
||||
$jobDir = $this->historyPath . '/jobs';
|
||||
if (!is_dir($jobDir)) {
|
||||
mkdir($jobDir, 0755, true);
|
||||
}
|
||||
|
||||
$filename = $jobDir . '/' . $jobId . '.json';
|
||||
$jsonFile = JsonFile::instance($filename);
|
||||
$history = $jsonFile->content() ?: [];
|
||||
|
||||
// Keep only last 100 executions per job
|
||||
$history[] = $entry;
|
||||
if (count($history) > 100) {
|
||||
$history = array_slice($history, -100);
|
||||
}
|
||||
|
||||
$jsonFile->save($history);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get job history
|
||||
*
|
||||
* @param string $jobId
|
||||
* @param int $limit
|
||||
* @return array
|
||||
*/
|
||||
public function getJobHistory(string $jobId, int $limit = 50): array
|
||||
{
|
||||
$filename = $this->historyPath . '/jobs/' . $jobId . '.json';
|
||||
if (!file_exists($filename)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$jsonFile = JsonFile::instance($filename);
|
||||
$history = $jsonFile->content() ?: [];
|
||||
|
||||
// Return most recent first
|
||||
$history = array_reverse($history);
|
||||
|
||||
if ($limit > 0) {
|
||||
$history = array_slice($history, 0, $limit);
|
||||
}
|
||||
|
||||
return $history;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get history for a date range
|
||||
*
|
||||
* @param DateTime $startDate
|
||||
* @param DateTime $endDate
|
||||
* @param string|null $jobId Filter by job ID
|
||||
* @return array
|
||||
*/
|
||||
public function getHistoryRange(DateTime $startDate, DateTime $endDate, ?string $jobId = null): array
|
||||
{
|
||||
$history = [];
|
||||
$current = clone $startDate;
|
||||
|
||||
while ($current <= $endDate) {
|
||||
$filename = $this->historyPath . '/' . $current->format('Y-m-d') . '.json';
|
||||
if (file_exists($filename)) {
|
||||
$jsonFile = JsonFile::instance($filename);
|
||||
$entries = $jsonFile->content() ?: [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($jobId === null || $entry['job_id'] === $jobId) {
|
||||
$history[] = $entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$current->modify('+1 day');
|
||||
}
|
||||
|
||||
return $history;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get job statistics
|
||||
*
|
||||
* @param string $jobId
|
||||
* @param int $days Number of days to analyze
|
||||
* @return array
|
||||
*/
|
||||
public function getJobStatistics(string $jobId, int $days = 7): array
|
||||
{
|
||||
$startDate = new DateTime("-{$days} days");
|
||||
$endDate = new DateTime('now');
|
||||
|
||||
$history = $this->getHistoryRange($startDate, $endDate, $jobId);
|
||||
|
||||
if (empty($history)) {
|
||||
return [
|
||||
'total_runs' => 0,
|
||||
'successful_runs' => 0,
|
||||
'failed_runs' => 0,
|
||||
'success_rate' => 0,
|
||||
'average_execution_time' => 0,
|
||||
'last_run' => null,
|
||||
'last_success' => null,
|
||||
'last_failure' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$totalRuns = count($history);
|
||||
$successfulRuns = 0;
|
||||
$executionTimes = [];
|
||||
$lastRun = null;
|
||||
$lastSuccess = null;
|
||||
$lastFailure = null;
|
||||
|
||||
foreach ($history as $entry) {
|
||||
if ($entry['success']) {
|
||||
$successfulRuns++;
|
||||
if (!$lastSuccess || $entry['timestamp'] > $lastSuccess['timestamp']) {
|
||||
$lastSuccess = $entry;
|
||||
}
|
||||
} else {
|
||||
if (!$lastFailure || $entry['timestamp'] > $lastFailure['timestamp']) {
|
||||
$lastFailure = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$lastRun || $entry['timestamp'] > $lastRun['timestamp']) {
|
||||
$lastRun = $entry;
|
||||
}
|
||||
|
||||
if (isset($entry['execution_time']) && $entry['execution_time'] > 0) {
|
||||
$executionTimes[] = $entry['execution_time'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total_runs' => $totalRuns,
|
||||
'successful_runs' => $successfulRuns,
|
||||
'failed_runs' => $totalRuns - $successfulRuns,
|
||||
'success_rate' => $totalRuns > 0 ? round(($successfulRuns / $totalRuns) * 100, 2) : 0,
|
||||
'average_execution_time' => !empty($executionTimes) ? round(array_sum($executionTimes) / count($executionTimes), 3) : 0,
|
||||
'last_run' => $lastRun,
|
||||
'last_success' => $lastSuccess,
|
||||
'last_failure' => $lastFailure,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global statistics
|
||||
*
|
||||
* @param int $days
|
||||
* @return array
|
||||
*/
|
||||
public function getGlobalStatistics(int $days = 7): array
|
||||
{
|
||||
$startDate = new DateTime("-{$days} days");
|
||||
$endDate = new DateTime('now');
|
||||
|
||||
$history = $this->getHistoryRange($startDate, $endDate);
|
||||
|
||||
$jobStats = [];
|
||||
foreach ($history as $entry) {
|
||||
$jobId = $entry['job_id'];
|
||||
if (!isset($jobStats[$jobId])) {
|
||||
$jobStats[$jobId] = [
|
||||
'runs' => 0,
|
||||
'success' => 0,
|
||||
'failed' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$jobStats[$jobId]['runs']++;
|
||||
if ($entry['success']) {
|
||||
$jobStats[$jobId]['success']++;
|
||||
} else {
|
||||
$jobStats[$jobId]['failed']++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total_executions' => count($history),
|
||||
'unique_jobs' => count($jobStats),
|
||||
'job_statistics' => $jobStats,
|
||||
'period_days' => $days,
|
||||
'from_date' => $startDate->format('Y-m-d'),
|
||||
'to_date' => $endDate->format('Y-m-d'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Search history
|
||||
*
|
||||
* @param array $criteria
|
||||
* @return array
|
||||
*/
|
||||
public function searchHistory(array $criteria): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
// Determine date range
|
||||
$startDate = isset($criteria['start_date']) ? new DateTime($criteria['start_date']) : new DateTime('-7 days');
|
||||
$endDate = isset($criteria['end_date']) ? new DateTime($criteria['end_date']) : new DateTime('now');
|
||||
|
||||
$history = $this->getHistoryRange($startDate, $endDate, $criteria['job_id'] ?? null);
|
||||
|
||||
foreach ($history as $entry) {
|
||||
$match = true;
|
||||
|
||||
// Filter by success status
|
||||
if (isset($criteria['success']) && $entry['success'] !== $criteria['success']) {
|
||||
$match = false;
|
||||
}
|
||||
|
||||
// Filter by output content
|
||||
if (isset($criteria['output_contains']) &&
|
||||
stripos($entry['output']['content'], $criteria['output_contains']) === false) {
|
||||
$match = false;
|
||||
}
|
||||
|
||||
// Filter by tags
|
||||
if (isset($criteria['tags']) && is_array($criteria['tags'])) {
|
||||
$entryTags = $entry['tags'] ?? [];
|
||||
if (empty(array_intersect($criteria['tags'], $entryTags))) {
|
||||
$match = false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($match) {
|
||||
$results[] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort results
|
||||
if (isset($criteria['sort_by'])) {
|
||||
usort($results, function($a, $b) use ($criteria) {
|
||||
$field = $criteria['sort_by'];
|
||||
$order = $criteria['sort_order'] ?? 'desc';
|
||||
|
||||
$aVal = $a[$field] ?? 0;
|
||||
$bVal = $b[$field] ?? 0;
|
||||
|
||||
if ($order === 'asc') {
|
||||
return $aVal <=> $bVal;
|
||||
} else {
|
||||
return $bVal <=> $aVal;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Limit results
|
||||
if (isset($criteria['limit'])) {
|
||||
$results = array_slice($results, 0, $criteria['limit']);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean old history files
|
||||
*
|
||||
* @return int Number of files deleted
|
||||
*/
|
||||
public function cleanOldHistory(): int
|
||||
{
|
||||
$deleted = 0;
|
||||
$cutoffDate = new DateTime("-{$this->retentionDays} days");
|
||||
|
||||
$files = glob($this->historyPath . '/*.json');
|
||||
foreach ($files as $file) {
|
||||
$filename = basename($file, '.json');
|
||||
// Check if filename is a date
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $filename)) {
|
||||
$fileDate = new DateTime($filename);
|
||||
if ($fileDate < $cutoffDate) {
|
||||
unlink($file);
|
||||
$deleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export history to CSV
|
||||
*
|
||||
* @param array $history
|
||||
* @param string $filename
|
||||
* @return bool
|
||||
*/
|
||||
public function exportToCsv(array $history, string $filename): bool
|
||||
{
|
||||
$handle = fopen($filename, 'w');
|
||||
if (!$handle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write headers
|
||||
fputcsv($handle, [
|
||||
'Job ID',
|
||||
'Executed At',
|
||||
'Success',
|
||||
'Execution Time',
|
||||
'Output Length',
|
||||
'Retry Count',
|
||||
'Priority',
|
||||
'Tags',
|
||||
]);
|
||||
|
||||
// Write data
|
||||
foreach ($history as $entry) {
|
||||
fputcsv($handle, [
|
||||
$entry['job_id'],
|
||||
$entry['executed_at'],
|
||||
$entry['success'] ? 'Yes' : 'No',
|
||||
$entry['execution_time'] ?? '',
|
||||
$entry['output']['length'] ?? 0,
|
||||
$entry['retry_count'] ?? 0,
|
||||
$entry['priority'] ?? 'normal',
|
||||
implode(', ', $entry['tags'] ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
588
system/src/Grav/Common/Scheduler/JobQueue.php
Normal file
588
system/src/Grav/Common/Scheduler/JobQueue.php
Normal file
@@ -0,0 +1,588 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Grav\Common\Scheduler
|
||||
*
|
||||
* @copyright Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.
|
||||
* @license MIT License; see LICENSE file for details.
|
||||
*/
|
||||
|
||||
namespace Grav\Common\Scheduler;
|
||||
|
||||
use RocketTheme\Toolbox\File\JsonFile;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* File-based job queue implementation
|
||||
*
|
||||
* @package Grav\Common\Scheduler
|
||||
*/
|
||||
class JobQueue
|
||||
{
|
||||
/** @var string */
|
||||
protected $queuePath;
|
||||
|
||||
/** @var string */
|
||||
protected $lockFile;
|
||||
|
||||
/** @var array Priority levels */
|
||||
const PRIORITY_HIGH = 'high';
|
||||
const PRIORITY_NORMAL = 'normal';
|
||||
const PRIORITY_LOW = 'low';
|
||||
|
||||
/**
|
||||
* JobQueue constructor
|
||||
*
|
||||
* @param string $queuePath
|
||||
*/
|
||||
public function __construct(string $queuePath)
|
||||
{
|
||||
$this->queuePath = $queuePath;
|
||||
$this->lockFile = $queuePath . '/.lock';
|
||||
|
||||
// Create queue directories
|
||||
$this->initializeDirectories();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize queue directories
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function initializeDirectories(): void
|
||||
{
|
||||
$dirs = [
|
||||
$this->queuePath . '/pending',
|
||||
$this->queuePath . '/processing',
|
||||
$this->queuePath . '/failed',
|
||||
$this->queuePath . '/completed',
|
||||
];
|
||||
|
||||
foreach ($dirs as $dir) {
|
||||
if (!file_exists($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a job to the queue
|
||||
*
|
||||
* @param Job $job
|
||||
* @param string $priority
|
||||
* @return string Job queue ID
|
||||
*/
|
||||
public function push(Job $job, string $priority = self::PRIORITY_NORMAL): string
|
||||
{
|
||||
$queueId = $this->generateQueueId($job);
|
||||
$timestamp = microtime(true);
|
||||
|
||||
$queueItem = [
|
||||
'id' => $queueId,
|
||||
'job_id' => $job->getId(),
|
||||
'command' => is_string($job->getCommand()) ? $job->getCommand() : 'Closure',
|
||||
'arguments' => method_exists($job, 'getRawArguments') ? $job->getRawArguments() : $job->getArguments(),
|
||||
'priority' => $priority,
|
||||
'timestamp' => $timestamp,
|
||||
'attempts' => 0,
|
||||
'max_attempts' => method_exists($job, 'getMaxAttempts') ? $job->getMaxAttempts() : 1,
|
||||
'created_at' => date('c'),
|
||||
'scheduled_for' => null,
|
||||
'metadata' => [],
|
||||
];
|
||||
|
||||
// Always serialize the job to preserve its full state
|
||||
$queueItem['serialized_job'] = base64_encode(serialize($job));
|
||||
|
||||
$this->writeQueueItem($queueItem, 'pending');
|
||||
|
||||
return $queueId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a job for delayed execution
|
||||
*
|
||||
* @param Job $job
|
||||
* @param \DateTime $scheduledFor
|
||||
* @param string $priority
|
||||
* @return string
|
||||
*/
|
||||
public function pushDelayed(Job $job, \DateTime $scheduledFor, string $priority = self::PRIORITY_NORMAL): string
|
||||
{
|
||||
$queueId = $this->push($job, $priority);
|
||||
|
||||
// Update the scheduled time
|
||||
$item = $this->getQueueItem($queueId, 'pending');
|
||||
if ($item) {
|
||||
$item['scheduled_for'] = $scheduledFor->format('c');
|
||||
$this->writeQueueItem($item, 'pending');
|
||||
}
|
||||
|
||||
return $queueId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop the next job from the queue
|
||||
*
|
||||
* @return Job|null
|
||||
*/
|
||||
public function pop(): ?Job
|
||||
{
|
||||
if (!$this->lock()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all pending items
|
||||
$items = $this->getPendingItems();
|
||||
|
||||
if (empty($items)) {
|
||||
$this->unlock();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort by priority and timestamp
|
||||
usort($items, function($a, $b) {
|
||||
$priorityOrder = [
|
||||
self::PRIORITY_HIGH => 0,
|
||||
self::PRIORITY_NORMAL => 1,
|
||||
self::PRIORITY_LOW => 2,
|
||||
];
|
||||
|
||||
$aPriority = $priorityOrder[$a['priority']] ?? 1;
|
||||
$bPriority = $priorityOrder[$b['priority']] ?? 1;
|
||||
|
||||
if ($aPriority !== $bPriority) {
|
||||
return $aPriority - $bPriority;
|
||||
}
|
||||
|
||||
return $a['timestamp'] <=> $b['timestamp'];
|
||||
});
|
||||
|
||||
// Get the first item that's ready to run
|
||||
$now = new \DateTime();
|
||||
foreach ($items as $item) {
|
||||
if ($item['scheduled_for']) {
|
||||
$scheduledTime = new \DateTime($item['scheduled_for']);
|
||||
if ($scheduledTime > $now) {
|
||||
continue; // Skip items not yet due
|
||||
}
|
||||
}
|
||||
|
||||
// Move to processing
|
||||
$this->moveQueueItem($item['id'], 'pending', 'processing');
|
||||
|
||||
// Reconstruct the job
|
||||
$job = $this->reconstructJob($item);
|
||||
|
||||
$this->unlock();
|
||||
return $job;
|
||||
}
|
||||
|
||||
$this->unlock();
|
||||
return null;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->unlock();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop a job from the queue with its queue ID
|
||||
*
|
||||
* @return array|null Array with 'job' and 'id' keys
|
||||
*/
|
||||
public function popWithId(): ?array
|
||||
{
|
||||
if (!$this->lock()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all pending items
|
||||
$items = $this->getPendingItems();
|
||||
|
||||
if (empty($items)) {
|
||||
$this->unlock();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort by priority and timestamp
|
||||
usort($items, function($a, $b) {
|
||||
$priorityOrder = [
|
||||
self::PRIORITY_HIGH => 0,
|
||||
self::PRIORITY_NORMAL => 1,
|
||||
self::PRIORITY_LOW => 2,
|
||||
];
|
||||
|
||||
$aPriority = $priorityOrder[$a['priority']] ?? 1;
|
||||
$bPriority = $priorityOrder[$b['priority']] ?? 1;
|
||||
|
||||
if ($aPriority !== $bPriority) {
|
||||
return $aPriority - $bPriority;
|
||||
}
|
||||
|
||||
return $a['timestamp'] <=> $b['timestamp'];
|
||||
});
|
||||
|
||||
// Get the first item that's ready to run
|
||||
$now = new \DateTime();
|
||||
foreach ($items as $item) {
|
||||
if ($item['scheduled_for']) {
|
||||
$scheduledTime = new \DateTime($item['scheduled_for']);
|
||||
if ($scheduledTime > $now) {
|
||||
continue; // Skip items not yet due
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct the job first before moving it
|
||||
$job = $this->reconstructJob($item);
|
||||
|
||||
if (!$job) {
|
||||
// Failed to reconstruct, skip this item
|
||||
continue;
|
||||
}
|
||||
|
||||
// Move to processing only if we can reconstruct the job
|
||||
$this->moveQueueItem($item['id'], 'pending', 'processing');
|
||||
|
||||
$this->unlock();
|
||||
return ['job' => $job, 'id' => $item['id']];
|
||||
}
|
||||
|
||||
$this->unlock();
|
||||
return null;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->unlock();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a job as completed
|
||||
*
|
||||
* @param string $queueId
|
||||
* @return void
|
||||
*/
|
||||
public function complete(string $queueId): void
|
||||
{
|
||||
$this->moveQueueItem($queueId, 'processing', 'completed');
|
||||
|
||||
// Clean up old completed items
|
||||
$this->cleanupCompleted();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a job as failed
|
||||
*
|
||||
* @param string $queueId
|
||||
* @param string $error
|
||||
* @return void
|
||||
*/
|
||||
public function fail(string $queueId, string $error = ''): void
|
||||
{
|
||||
$item = $this->getQueueItem($queueId, 'processing');
|
||||
|
||||
if ($item) {
|
||||
$item['attempts']++;
|
||||
$item['last_error'] = $error;
|
||||
$item['failed_at'] = date('c');
|
||||
|
||||
if ($item['attempts'] < $item['max_attempts']) {
|
||||
// Move back to pending for retry
|
||||
$item['retry_at'] = $this->calculateRetryTime($item['attempts']);
|
||||
$item['scheduled_for'] = $item['retry_at'];
|
||||
$this->writeQueueItem($item, 'pending');
|
||||
$this->deleteQueueItem($queueId, 'processing');
|
||||
} else {
|
||||
// Move to failed (dead letter queue)
|
||||
$this->writeQueueItem($item, 'failed');
|
||||
$this->deleteQueueItem($queueId, 'processing');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue size
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function size(): int
|
||||
{
|
||||
return count($this->getPendingItems());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if queue is empty
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return $this->size() === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue statistics
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
return [
|
||||
'pending' => count($this->getPendingItems()),
|
||||
'processing' => count($this->getItemsInDirectory('processing')),
|
||||
'failed' => count($this->getItemsInDirectory('failed')),
|
||||
'completed_today' => $this->countCompletedToday(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique queue ID
|
||||
*
|
||||
* @param Job $job
|
||||
* @return string
|
||||
*/
|
||||
protected function generateQueueId(Job $job): string
|
||||
{
|
||||
return $job->getId() . '_' . uniqid('', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write queue item to disk
|
||||
*
|
||||
* @param array $item
|
||||
* @param string $directory
|
||||
* @return void
|
||||
*/
|
||||
protected function writeQueueItem(array $item, string $directory): void
|
||||
{
|
||||
$path = $this->queuePath . '/' . $directory . '/' . $item['id'] . '.json';
|
||||
$file = JsonFile::instance($path);
|
||||
$file->save($item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read queue item from disk
|
||||
*
|
||||
* @param string $queueId
|
||||
* @param string $directory
|
||||
* @return array|null
|
||||
*/
|
||||
protected function getQueueItem(string $queueId, string $directory): ?array
|
||||
{
|
||||
$path = $this->queuePath . '/' . $directory . '/' . $queueId . '.json';
|
||||
|
||||
if (!file_exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$file = JsonFile::instance($path);
|
||||
return $file->content();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete queue item
|
||||
*
|
||||
* @param string $queueId
|
||||
* @param string $directory
|
||||
* @return void
|
||||
*/
|
||||
protected function deleteQueueItem(string $queueId, string $directory): void
|
||||
{
|
||||
$path = $this->queuePath . '/' . $directory . '/' . $queueId . '.json';
|
||||
|
||||
if (file_exists($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move queue item between directories
|
||||
*
|
||||
* @param string $queueId
|
||||
* @param string $fromDir
|
||||
* @param string $toDir
|
||||
* @return void
|
||||
*/
|
||||
protected function moveQueueItem(string $queueId, string $fromDir, string $toDir): void
|
||||
{
|
||||
$fromPath = $this->queuePath . '/' . $fromDir . '/' . $queueId . '.json';
|
||||
$toPath = $this->queuePath . '/' . $toDir . '/' . $queueId . '.json';
|
||||
|
||||
if (file_exists($fromPath)) {
|
||||
rename($fromPath, $toPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending items
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function getPendingItems(): array
|
||||
{
|
||||
return $this->getItemsInDirectory('pending');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get items in a specific directory
|
||||
*
|
||||
* @param string $directory
|
||||
* @return array
|
||||
*/
|
||||
protected function getItemsInDirectory(string $directory): array
|
||||
{
|
||||
$items = [];
|
||||
$path = $this->queuePath . '/' . $directory;
|
||||
|
||||
if (!is_dir($path)) {
|
||||
return $items;
|
||||
}
|
||||
|
||||
$files = glob($path . '/*.json');
|
||||
foreach ($files as $file) {
|
||||
$jsonFile = JsonFile::instance($file);
|
||||
$items[] = $jsonFile->content();
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct a job from queue item
|
||||
*
|
||||
* @param array $item
|
||||
* @return Job|null
|
||||
*/
|
||||
protected function reconstructJob(array $item): ?Job
|
||||
{
|
||||
if (isset($item['serialized_job'])) {
|
||||
// Unserialize the job
|
||||
try {
|
||||
$job = unserialize(base64_decode($item['serialized_job']));
|
||||
if ($job instanceof Job) {
|
||||
return $job;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Failed to unserialize
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new job from command
|
||||
if (isset($item['command'])) {
|
||||
$args = $item['arguments'] ?? [];
|
||||
$job = new Job($item['command'], $args, $item['job_id']);
|
||||
return $job;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate retry time with exponential backoff
|
||||
*
|
||||
* @param int $attempts
|
||||
* @return string
|
||||
*/
|
||||
protected function calculateRetryTime(int $attempts): string
|
||||
{
|
||||
$backoffSeconds = min(pow(2, $attempts) * 60, 3600); // Max 1 hour
|
||||
$retryTime = new \DateTime();
|
||||
$retryTime->modify("+{$backoffSeconds} seconds");
|
||||
return $retryTime->format('c');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old completed items
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function cleanupCompleted(): void
|
||||
{
|
||||
$items = $this->getItemsInDirectory('completed');
|
||||
$cutoff = new \DateTime('-24 hours');
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (isset($item['created_at'])) {
|
||||
$createdAt = new \DateTime($item['created_at']);
|
||||
if ($createdAt < $cutoff) {
|
||||
$this->deleteQueueItem($item['id'], 'completed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count completed jobs today
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
protected function countCompletedToday(): int
|
||||
{
|
||||
$items = $this->getItemsInDirectory('completed');
|
||||
$today = new \DateTime('today');
|
||||
$count = 0;
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (isset($item['created_at'])) {
|
||||
$createdAt = new \DateTime($item['created_at']);
|
||||
if ($createdAt >= $today) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire lock for queue operations
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function lock(): bool
|
||||
{
|
||||
$attempts = 0;
|
||||
$maxAttempts = 50; // 5 seconds total
|
||||
|
||||
while ($attempts < $maxAttempts) {
|
||||
// Check if lock file exists and is stale (older than 30 seconds)
|
||||
if (file_exists($this->lockFile)) {
|
||||
$lockAge = time() - filemtime($this->lockFile);
|
||||
if ($lockAge > 30) {
|
||||
// Stale lock, remove it
|
||||
@unlink($this->lockFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to acquire lock atomically
|
||||
$handle = @fopen($this->lockFile, 'x');
|
||||
if ($handle !== false) {
|
||||
fclose($handle);
|
||||
return true;
|
||||
}
|
||||
|
||||
$attempts++;
|
||||
usleep(100000); // 100ms
|
||||
}
|
||||
|
||||
// Could not acquire lock
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release queue lock
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function unlock(): void
|
||||
{
|
||||
if (file_exists($this->lockFile)) {
|
||||
unlink($this->lockFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ use InvalidArgumentException;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
use RocketTheme\Toolbox\File\YamlFile;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Monolog\Logger;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use function is_callable;
|
||||
use function is_string;
|
||||
|
||||
@@ -49,19 +52,57 @@ class Scheduler
|
||||
|
||||
/** @var string */
|
||||
private $status_path;
|
||||
|
||||
// Modern features (backward compatible - disabled by default)
|
||||
/** @var JobQueue|null */
|
||||
protected $jobQueue = null;
|
||||
|
||||
/** @var array */
|
||||
protected $workers = [];
|
||||
|
||||
/** @var int */
|
||||
protected $maxWorkers = 1;
|
||||
|
||||
/** @var bool */
|
||||
protected $webhookEnabled = false;
|
||||
|
||||
/** @var string|null */
|
||||
protected $webhookToken = null;
|
||||
|
||||
/** @var bool */
|
||||
protected $healthEnabled = true;
|
||||
|
||||
/** @var string */
|
||||
protected $queuePath;
|
||||
|
||||
/** @var string */
|
||||
protected $historyPath;
|
||||
|
||||
/** @var Logger|null */
|
||||
protected $logger = null;
|
||||
|
||||
/** @var array */
|
||||
protected $modernConfig = [];
|
||||
|
||||
/**
|
||||
* Create new instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$config = Grav::instance()['config']->get('scheduler.defaults', []);
|
||||
$grav = Grav::instance();
|
||||
$config = $grav['config']->get('scheduler.defaults', []);
|
||||
$this->config = $config;
|
||||
|
||||
$this->status_path = Grav::instance()['locator']->findResource('user-data://scheduler', true, true);
|
||||
$locator = $grav['locator'];
|
||||
$this->status_path = $locator->findResource('user-data://scheduler', true, true);
|
||||
if (!file_exists($this->status_path)) {
|
||||
Folder::create($this->status_path);
|
||||
}
|
||||
|
||||
// Initialize modern features (always enabled now)
|
||||
$this->modernConfig = $grav['config']->get('scheduler.modern', []);
|
||||
// Always initialize modern features - they're now part of core
|
||||
$this->initializeModernFeatures($locator);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,6 +162,16 @@ class Scheduler
|
||||
return [$background, $foreground];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the job queue
|
||||
*
|
||||
* @return JobQueue|null
|
||||
*/
|
||||
public function getJobQueue(): ?JobQueue
|
||||
{
|
||||
return $this->jobQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all jobs if they are disabled or not as one array
|
||||
*
|
||||
@@ -190,6 +241,13 @@ class Scheduler
|
||||
*/
|
||||
public function run(DateTime $runTime = null, $force = false)
|
||||
{
|
||||
// Initialize system jobs if not already done
|
||||
$grav = Grav::instance();
|
||||
if (count($this->jobs) === 0) {
|
||||
// Trigger event to load system jobs (cache-purge, cache-clear, backups, etc.)
|
||||
$grav->fireEvent('onSchedulerInitialized', new \RocketTheme\Toolbox\Event\Event(['scheduler' => $this]));
|
||||
}
|
||||
|
||||
$this->loadSavedJobs();
|
||||
|
||||
[$background, $foreground] = $this->getQueuedJobs(false);
|
||||
@@ -199,24 +257,92 @@ class Scheduler
|
||||
$runTime = new DateTime('now');
|
||||
}
|
||||
|
||||
// Star processing jobs
|
||||
foreach ($alljobs as $job) {
|
||||
if ($job->isDue($runTime) || $force) {
|
||||
$job->run();
|
||||
$this->jobs_run[] = $job;
|
||||
// Log scheduler run
|
||||
if ($this->logger) {
|
||||
$jobCount = count($alljobs);
|
||||
$forceStr = $force ? ' (forced)' : '';
|
||||
$this->logger->debug("Scheduler run started - {$jobCount} jobs available{$forceStr}", [
|
||||
'time' => $runTime->format('Y-m-d H:i:s')
|
||||
]);
|
||||
}
|
||||
|
||||
// Process jobs based on modern features
|
||||
if ($this->jobQueue && ($this->modernConfig['queue']['enabled'] ?? false)) {
|
||||
// Queue jobs for processing
|
||||
$queuedCount = 0;
|
||||
foreach ($alljobs as $job) {
|
||||
if ($job->isDue($runTime) || $force) {
|
||||
// Add to queue for concurrent processing
|
||||
$this->jobQueue->push($job);
|
||||
$queuedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->logger && $queuedCount > 0) {
|
||||
$this->logger->debug("Queued {$queuedCount} job(s) for processing");
|
||||
}
|
||||
|
||||
// Process queue with workers
|
||||
$this->processJobsWithWorkers();
|
||||
|
||||
// When using queue, states are saved by executeJob when jobs complete
|
||||
// Don't save states here as jobs may still be processing
|
||||
} else {
|
||||
// Legacy processing (one at a time)
|
||||
foreach ($alljobs as $job) {
|
||||
if ($job->isDue($runTime) || $force) {
|
||||
$job->run();
|
||||
$this->jobs_run[] = $job;
|
||||
}
|
||||
}
|
||||
|
||||
// Finish handling any background jobs
|
||||
foreach ($background as $job) {
|
||||
$job->finalize();
|
||||
}
|
||||
|
||||
// Store states for legacy mode
|
||||
$this->saveJobStates();
|
||||
|
||||
// Save history if enabled
|
||||
if (($this->modernConfig['history']['enabled'] ?? false) && $this->historyPath) {
|
||||
$this->saveJobHistory();
|
||||
}
|
||||
}
|
||||
|
||||
// Finish handling any background jobs
|
||||
foreach ($background as $job) {
|
||||
$job->finalize();
|
||||
// Log run summary
|
||||
if ($this->logger) {
|
||||
$successCount = 0;
|
||||
$failureCount = 0;
|
||||
$failedJobNames = [];
|
||||
$executedJobs = array_merge($this->executed_jobs, $this->jobs_run);
|
||||
|
||||
foreach ($executedJobs as $job) {
|
||||
if ($job->isSuccessful()) {
|
||||
$successCount++;
|
||||
} else {
|
||||
$failureCount++;
|
||||
$failedJobNames[] = $job->getId();
|
||||
}
|
||||
}
|
||||
|
||||
if (count($executedJobs) > 0) {
|
||||
if ($failureCount > 0) {
|
||||
$failedList = implode(', ', $failedJobNames);
|
||||
$this->logger->warning("Scheduler completed: {$successCount} succeeded, {$failureCount} failed (failed: {$failedList})");
|
||||
} else {
|
||||
$this->logger->info("Scheduler completed: {$successCount} job(s) succeeded");
|
||||
}
|
||||
} else {
|
||||
$this->logger->debug('Scheduler completed: no jobs were due');
|
||||
}
|
||||
}
|
||||
|
||||
// Store states
|
||||
$this->saveJobStates();
|
||||
|
||||
// Store run date
|
||||
file_put_contents("logs/lastcron.run", (new DateTime("now"))->format("Y-m-d H:i:s"), LOCK_EX);
|
||||
|
||||
// Update last run timestamp for health checks
|
||||
$this->updateLastRun();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,6 +504,114 @@ class Scheduler
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Initialize modern features
|
||||
*
|
||||
* @param mixed $locator
|
||||
* @return void
|
||||
*/
|
||||
protected function initializeModernFeatures($locator): void
|
||||
{
|
||||
// Set up paths
|
||||
$this->queuePath = $this->modernConfig['queue']['path'] ?? 'user-data://scheduler/queue';
|
||||
$this->queuePath = $locator->findResource($this->queuePath, true, true);
|
||||
|
||||
$this->historyPath = $this->modernConfig['history']['path'] ?? 'user-data://scheduler/history';
|
||||
$this->historyPath = $locator->findResource($this->historyPath, true, true);
|
||||
|
||||
// Create directories if they don't exist
|
||||
if (!file_exists($this->queuePath)) {
|
||||
Folder::create($this->queuePath);
|
||||
}
|
||||
|
||||
if (!file_exists($this->historyPath)) {
|
||||
Folder::create($this->historyPath);
|
||||
}
|
||||
|
||||
// Initialize job queue (always enabled)
|
||||
$this->jobQueue = new JobQueue($this->queuePath);
|
||||
|
||||
// Initialize scheduler logger
|
||||
$this->initializeLogger($locator);
|
||||
|
||||
// Configure workers (default to 4 for concurrent processing)
|
||||
$this->maxWorkers = $this->modernConfig['workers'] ?? 4;
|
||||
|
||||
// Configure webhook
|
||||
$this->webhookEnabled = $this->modernConfig['webhook']['enabled'] ?? false;
|
||||
$this->webhookToken = $this->modernConfig['webhook']['token'] ?? null;
|
||||
|
||||
// Configure health check
|
||||
$this->healthEnabled = $this->modernConfig['health']['enabled'] ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the job queue
|
||||
*
|
||||
* @return JobQueue|null
|
||||
*/
|
||||
public function getQueue(): ?JobQueue
|
||||
{
|
||||
return $this->jobQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the scheduler logger
|
||||
*
|
||||
* @param $locator
|
||||
* @return void
|
||||
*/
|
||||
protected function initializeLogger($locator): void
|
||||
{
|
||||
$this->logger = new Logger('scheduler');
|
||||
|
||||
// Single scheduler log file - all levels
|
||||
$logFile = $locator->findResource('log://scheduler.log', true, true);
|
||||
$this->logger->pushHandler(new StreamHandler($logFile, Logger::DEBUG));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the scheduler logger
|
||||
*
|
||||
* @return Logger|null
|
||||
*/
|
||||
public function getLogger(): ?Logger
|
||||
{
|
||||
return $this->logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if webhook is enabled
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isWebhookEnabled(): bool
|
||||
{
|
||||
return $this->webhookEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active trigger methods
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getActiveTriggers(): array
|
||||
{
|
||||
$triggers = [];
|
||||
|
||||
$cronStatus = $this->isCrontabSetup();
|
||||
if ($cronStatus === 1) {
|
||||
$triggers[] = 'cron';
|
||||
}
|
||||
|
||||
// Check if webhook is enabled
|
||||
if ($this->isWebhookEnabled()) {
|
||||
$triggers[] = 'webhook';
|
||||
}
|
||||
|
||||
return $triggers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a job for execution in the correct queue.
|
||||
*
|
||||
@@ -444,4 +678,410 @@ class Scheduler
|
||||
|
||||
return $job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process jobs using multiple workers
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function processJobsWithWorkers(): void
|
||||
{
|
||||
if (!$this->jobQueue) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Process all queued jobs
|
||||
while (!$this->jobQueue->isEmpty()) {
|
||||
// Wait if we've reached max workers
|
||||
while (count($this->workers) >= $this->maxWorkers) {
|
||||
foreach ($this->workers as $workerId => $worker) {
|
||||
$process = null;
|
||||
if (is_array($worker) && isset($worker['process'])) {
|
||||
$process = $worker['process'];
|
||||
} elseif ($worker instanceof Process) {
|
||||
$process = $worker;
|
||||
}
|
||||
|
||||
if ($process instanceof Process && !$process->isRunning()) {
|
||||
// Finalize job if needed
|
||||
if (is_array($worker) && isset($worker['job'])) {
|
||||
$worker['job']->finalize();
|
||||
|
||||
// Save job state
|
||||
$this->saveJobState($worker['job']);
|
||||
|
||||
// Update queue status
|
||||
if (isset($worker['queueId']) && $this->jobQueue) {
|
||||
if ($worker['job']->isSuccessful()) {
|
||||
$this->jobQueue->complete($worker['queueId']);
|
||||
} else {
|
||||
$this->jobQueue->fail($worker['queueId'], $worker['job']->getOutput() ?: 'Job failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($this->workers[$workerId]);
|
||||
}
|
||||
}
|
||||
if (count($this->workers) >= $this->maxWorkers) {
|
||||
usleep(100000); // Wait 100ms
|
||||
}
|
||||
}
|
||||
|
||||
// Get next job from queue
|
||||
$queueItem = $this->jobQueue->popWithId();
|
||||
if ($queueItem) {
|
||||
$this->executeJob($queueItem['job'], $queueItem['id']);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all remaining workers to complete
|
||||
foreach ($this->workers as $workerId => $worker) {
|
||||
if (is_array($worker) && isset($worker['process'])) {
|
||||
$process = $worker['process'];
|
||||
if ($process instanceof Process) {
|
||||
$process->wait();
|
||||
|
||||
// Finalize and save state for background jobs
|
||||
if (isset($worker['job'])) {
|
||||
$worker['job']->finalize();
|
||||
$this->saveJobState($worker['job']);
|
||||
|
||||
// Log background job completion
|
||||
if ($this->logger) {
|
||||
$job = $worker['job'];
|
||||
$jobId = $job->getId();
|
||||
$command = is_string($job->getCommand()) ? $job->getCommand() : 'Closure';
|
||||
|
||||
if ($job->isSuccessful()) {
|
||||
$execTime = method_exists($job, 'getExecutionTime') ? $job->getExecutionTime() : null;
|
||||
$timeStr = $execTime ? sprintf(' (%.2fs)', $execTime) : '';
|
||||
$this->logger->info("Job '{$jobId}' completed successfully{$timeStr}", [
|
||||
'command' => $command,
|
||||
'background' => true
|
||||
]);
|
||||
} else {
|
||||
$error = trim($job->getOutput()) ?: 'Unknown error';
|
||||
$this->logger->error("Job '{$jobId}' failed: {$error}", [
|
||||
'command' => $command,
|
||||
'background' => true
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update queue status for background jobs
|
||||
if (isset($worker['queueId']) && $this->jobQueue) {
|
||||
$job = $worker['job'];
|
||||
if ($job->isSuccessful()) {
|
||||
$this->jobQueue->complete($worker['queueId']);
|
||||
} else {
|
||||
$this->jobQueue->fail($worker['queueId'], $job->getOutput() ?: 'Job execution failed');
|
||||
}
|
||||
}
|
||||
|
||||
unset($this->workers[$workerId]);
|
||||
}
|
||||
} elseif ($worker instanceof Process) {
|
||||
// Legacy format
|
||||
$worker->wait();
|
||||
unset($this->workers[$workerId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process existing queued jobs
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function processQueuedJobs(): void
|
||||
{
|
||||
if (!$this->jobQueue) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Process any existing queued jobs from previous runs
|
||||
while (!$this->jobQueue->isEmpty() && count($this->workers) < $this->maxWorkers) {
|
||||
$job = $this->jobQueue->pop();
|
||||
if ($job) {
|
||||
$this->executeJob($job);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a job
|
||||
*
|
||||
* @param Job $job
|
||||
* @param string|null $queueId Queue ID if job came from queue
|
||||
* @return void
|
||||
*/
|
||||
protected function executeJob(Job $job, ?string $queueId = null): void
|
||||
{
|
||||
$job->run();
|
||||
$this->jobs_run[] = $job;
|
||||
|
||||
// Save job state after execution
|
||||
$this->saveJobState($job);
|
||||
|
||||
// Check if job runs in background
|
||||
if ($job->runInBackground()) {
|
||||
// Background job - track it for later completion
|
||||
$process = $job->getProcess();
|
||||
if ($process && $process->isStarted()) {
|
||||
$this->workers[] = [
|
||||
'process' => $process,
|
||||
'job' => $job,
|
||||
'queueId' => $queueId
|
||||
];
|
||||
// Don't update queue status yet - will be done when process completes
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Foreground job or background job that didn't start - update queue status immediately
|
||||
if ($queueId && $this->jobQueue) {
|
||||
// Job has already been finalized if it ran in foreground
|
||||
if (!$job->runInBackground()) {
|
||||
$job->finalize();
|
||||
}
|
||||
|
||||
if ($job->isSuccessful()) {
|
||||
// Move from processing to completed
|
||||
$this->jobQueue->complete($queueId);
|
||||
} else {
|
||||
// Move from processing to failed
|
||||
$this->jobQueue->fail($queueId, $job->getOutput() ?: 'Job execution failed');
|
||||
}
|
||||
}
|
||||
|
||||
// Log foreground jobs immediately
|
||||
if (!$job->runInBackground() && $this->logger) {
|
||||
$jobId = $job->getId();
|
||||
$command = is_string($job->getCommand()) ? $job->getCommand() : 'Closure';
|
||||
|
||||
if ($job->isSuccessful()) {
|
||||
$execTime = method_exists($job, 'getExecutionTime') ? $job->getExecutionTime() : null;
|
||||
$timeStr = $execTime ? sprintf(' (%.2fs)', $execTime) : '';
|
||||
$this->logger->info("Job '{$jobId}' completed successfully{$timeStr}", [
|
||||
'command' => $command
|
||||
]);
|
||||
} else {
|
||||
$error = trim($job->getOutput()) ?: 'Unknown error';
|
||||
$this->logger->error("Job '{$jobId}' failed: {$error}", [
|
||||
'command' => $command
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save state for a single job
|
||||
*
|
||||
* @param Job $job
|
||||
* @return void
|
||||
*/
|
||||
protected function saveJobState(Job $job): void
|
||||
{
|
||||
$grav = Grav::instance();
|
||||
$locator = $grav['locator'];
|
||||
$statusFile = $locator->findResource('user-data://scheduler/status.yaml', true, true);
|
||||
|
||||
$status = [];
|
||||
if (file_exists($statusFile)) {
|
||||
$status = Yaml::parseFile($statusFile) ?: [];
|
||||
}
|
||||
|
||||
// Update job status
|
||||
$status[$job->getId()] = [
|
||||
'state' => $job->isSuccessful() ? 'success' : 'failure',
|
||||
'last-run' => time(),
|
||||
];
|
||||
|
||||
// Add error if job failed
|
||||
if (!$job->isSuccessful()) {
|
||||
$output = $job->getOutput();
|
||||
if ($output) {
|
||||
$status[$job->getId()]['error'] = $output;
|
||||
} else {
|
||||
$status[$job->getId()]['error'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
file_put_contents($statusFile, Yaml::dump($status));
|
||||
}
|
||||
|
||||
/**
|
||||
* Save job execution history
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function saveJobHistory(): void
|
||||
{
|
||||
if (!$this->historyPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
$history = [];
|
||||
foreach ($this->jobs_run as $job) {
|
||||
$history[] = [
|
||||
'id' => $job->getId(),
|
||||
'executed_at' => date('c'),
|
||||
'success' => $job->isSuccessful(),
|
||||
'output' => substr($job->getOutput(), 0, 1000),
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($history)) {
|
||||
$filename = $this->historyPath . '/' . date('Y-m-d') . '.json';
|
||||
$existing = file_exists($filename) ? json_decode(file_get_contents($filename), true) : [];
|
||||
$existing = array_merge($existing, $history);
|
||||
file_put_contents($filename, json_encode($existing, JSON_PRETTY_PRINT));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last run timestamp
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function updateLastRun(): void
|
||||
{
|
||||
$lastRunFile = $this->status_path . '/last_run.txt';
|
||||
file_put_contents($lastRunFile, date('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get health status
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getHealthStatus(): array
|
||||
{
|
||||
$lastRunFile = $this->status_path . '/last_run.txt';
|
||||
$lastRun = file_exists($lastRunFile) ? file_get_contents($lastRunFile) : null;
|
||||
|
||||
// Initialize system jobs if not already done
|
||||
$grav = Grav::instance();
|
||||
if (count($this->jobs) === 0) {
|
||||
// Trigger event to load system jobs (cache-purge, cache-clear, backups, etc.)
|
||||
$grav->fireEvent('onSchedulerInitialized', new \RocketTheme\Toolbox\Event\Event(['scheduler' => $this]));
|
||||
}
|
||||
|
||||
// Load custom jobs
|
||||
$this->loadSavedJobs();
|
||||
|
||||
// Get only enabled jobs for health status
|
||||
[$background, $foreground] = $this->getQueuedJobs(false);
|
||||
$enabledJobs = array_merge($background, $foreground);
|
||||
|
||||
$now = new DateTime('now');
|
||||
$dueJobs = 0;
|
||||
|
||||
foreach ($enabledJobs as $job) {
|
||||
if ($job->isDue($now)) {
|
||||
$dueJobs++;
|
||||
}
|
||||
}
|
||||
|
||||
$health = [
|
||||
'status' => 'healthy',
|
||||
'last_run' => $lastRun,
|
||||
'last_run_age' => null,
|
||||
'queue_size' => 0,
|
||||
'failed_jobs_24h' => 0,
|
||||
'scheduled_jobs' => count($enabledJobs),
|
||||
'jobs_due' => $dueJobs,
|
||||
'webhook_enabled' => $this->webhookEnabled,
|
||||
'health_check_enabled' => $this->healthEnabled,
|
||||
'timestamp' => date('c'),
|
||||
];
|
||||
|
||||
// Calculate last run age
|
||||
if ($lastRun) {
|
||||
$lastRunTime = new DateTime($lastRun);
|
||||
$health['last_run_age'] = $now->getTimestamp() - $lastRunTime->getTimestamp();
|
||||
}
|
||||
|
||||
// Determine status based on whether jobs are due
|
||||
if ($dueJobs > 0) {
|
||||
// Jobs are due but haven't been run
|
||||
if ($health['last_run_age'] === null || $health['last_run_age'] > 300) { // No run or older than 5 minutes
|
||||
$health['status'] = 'warning';
|
||||
$health['message'] = $dueJobs . ' job(s) are due to run';
|
||||
}
|
||||
} else {
|
||||
// No jobs are due - this is healthy
|
||||
$health['status'] = 'healthy';
|
||||
$health['message'] = 'No jobs currently due';
|
||||
}
|
||||
|
||||
// Add queue stats if available
|
||||
if ($this->jobQueue) {
|
||||
$stats = $this->jobQueue->getStatistics();
|
||||
$health['queue_size'] = $stats['pending'] ?? 0;
|
||||
$health['failed_jobs_24h'] = $stats['failed'] ?? 0;
|
||||
}
|
||||
|
||||
return $health;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process webhook trigger
|
||||
*
|
||||
* @param string|null $token
|
||||
* @param string|null $jobId
|
||||
* @return array
|
||||
*/
|
||||
public function processWebhookTrigger($token = null, $jobId = null): array
|
||||
{
|
||||
if (!$this->webhookEnabled) {
|
||||
return ['success' => false, 'message' => 'Webhook triggers are not enabled'];
|
||||
}
|
||||
|
||||
if ($this->webhookToken && $token !== $this->webhookToken) {
|
||||
return ['success' => false, 'message' => 'Invalid webhook token'];
|
||||
}
|
||||
|
||||
// Initialize system jobs if not already done
|
||||
$grav = Grav::instance();
|
||||
if (count($this->jobs) === 0) {
|
||||
// Trigger event to load system jobs (cache-purge, cache-clear, backups, etc.)
|
||||
$grav->fireEvent('onSchedulerInitialized', new \RocketTheme\Toolbox\Event\Event(['scheduler' => $this]));
|
||||
}
|
||||
|
||||
// Load custom jobs
|
||||
$this->loadSavedJobs();
|
||||
|
||||
if ($jobId) {
|
||||
// Force run specific job
|
||||
$job = $this->getJob($jobId);
|
||||
if ($job) {
|
||||
$job->inForeground()->run();
|
||||
$this->jobs_run[] = $job;
|
||||
$this->saveJobStates();
|
||||
$this->updateLastRun();
|
||||
|
||||
return [
|
||||
'success' => $job->isSuccessful(),
|
||||
'message' => $job->isSuccessful() ? 'Job force-executed successfully' : 'Job execution failed',
|
||||
'job_id' => $jobId,
|
||||
'forced' => true,
|
||||
'output' => $job->getOutput(),
|
||||
];
|
||||
} else {
|
||||
return ['success' => false, 'message' => 'Job not found: ' . $jobId];
|
||||
}
|
||||
} else {
|
||||
// Run all due jobs
|
||||
$this->run();
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Scheduler executed (due jobs only)',
|
||||
'jobs_run' => count($this->jobs_run),
|
||||
'timestamp' => date('c'),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
270
system/src/Grav/Common/Scheduler/SchedulerController.php
Normal file
270
system/src/Grav/Common/Scheduler/SchedulerController.php
Normal file
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Grav\Common\Scheduler
|
||||
*
|
||||
* @copyright Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.
|
||||
* @license MIT License; see LICENSE file for details.
|
||||
*/
|
||||
|
||||
namespace Grav\Common\Scheduler;
|
||||
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Common\Utils;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
/**
|
||||
* Scheduler Controller for handling HTTP endpoints
|
||||
*
|
||||
* @package Grav\Common\Scheduler
|
||||
*/
|
||||
class SchedulerController
|
||||
{
|
||||
/** @var Grav */
|
||||
protected $grav;
|
||||
|
||||
/** @var ModernScheduler */
|
||||
protected $scheduler;
|
||||
|
||||
/**
|
||||
* SchedulerController constructor
|
||||
*
|
||||
* @param Grav $grav
|
||||
*/
|
||||
public function __construct(Grav $grav)
|
||||
{
|
||||
$this->grav = $grav;
|
||||
|
||||
// Get scheduler instance
|
||||
$scheduler = $grav['scheduler'];
|
||||
if ($scheduler instanceof ModernScheduler) {
|
||||
$this->scheduler = $scheduler;
|
||||
} else {
|
||||
// Create ModernScheduler instance if not already
|
||||
$this->scheduler = new ModernScheduler();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle health check endpoint
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function health(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$config = $this->grav['config']->get('scheduler.modern', []);
|
||||
|
||||
// Check if health endpoint is enabled
|
||||
if (!($config['health']['enabled'] ?? true)) {
|
||||
return $this->jsonResponse(['error' => 'Health check disabled'], 403);
|
||||
}
|
||||
|
||||
// Get health status
|
||||
$health = $this->scheduler->getHealthStatus();
|
||||
|
||||
return $this->jsonResponse($health);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle webhook trigger endpoint
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function webhook(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$config = $this->grav['config']->get('scheduler.modern', []);
|
||||
|
||||
// Check if webhook is enabled
|
||||
if (!($config['webhook']['enabled'] ?? false)) {
|
||||
return $this->jsonResponse(['error' => 'Webhook triggers disabled'], 403);
|
||||
}
|
||||
|
||||
// Get authorization header
|
||||
$authHeader = $request->getHeaderLine('Authorization');
|
||||
$token = null;
|
||||
|
||||
if (preg_match('/Bearer\s+(.+)$/i', $authHeader, $matches)) {
|
||||
$token = $matches[1];
|
||||
}
|
||||
|
||||
// Get query parameters
|
||||
$params = $request->getQueryParams();
|
||||
$jobId = $params['job'] ?? null;
|
||||
|
||||
// Process webhook
|
||||
$result = $this->scheduler->processWebhookTrigger($token, $jobId);
|
||||
|
||||
$statusCode = $result['success'] ? 200 : 400;
|
||||
return $this->jsonResponse($result, $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle statistics endpoint
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function statistics(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
// Check if user is admin
|
||||
$user = $this->grav['user'] ?? null;
|
||||
if (!$user || !$user->authorize('admin.super')) {
|
||||
return $this->jsonResponse(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$stats = $this->scheduler->getStatistics();
|
||||
|
||||
return $this->jsonResponse($stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle admin AJAX requests for scheduler status
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function adminStatus(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
// Check if user is admin
|
||||
$user = $this->grav['user'] ?? null;
|
||||
if (!$user || !$user->authorize('admin.scheduler')) {
|
||||
return $this->jsonResponse(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$health = $this->scheduler->getHealthStatus();
|
||||
|
||||
// Format for admin display
|
||||
$response = [
|
||||
'health' => $this->formatHealthStatus($health),
|
||||
'triggers' => $this->formatTriggers($health['trigger_methods'] ?? [])
|
||||
];
|
||||
|
||||
return $this->jsonResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format health status for display
|
||||
*
|
||||
* @param array $health
|
||||
* @return string
|
||||
*/
|
||||
protected function formatHealthStatus(array $health): string
|
||||
{
|
||||
$status = $health['status'] ?? 'unknown';
|
||||
$lastRun = $health['last_run'] ?? null;
|
||||
$queueSize = $health['queue_size'] ?? 0;
|
||||
$failedJobs = $health['failed_jobs_24h'] ?? 0;
|
||||
$jobsDue = $health['jobs_due'] ?? 0;
|
||||
$message = $health['message'] ?? '';
|
||||
|
||||
$statusBadge = match($status) {
|
||||
'healthy' => '<span class="badge badge-success">Healthy</span>',
|
||||
'warning' => '<span class="badge badge-warning">Warning</span>',
|
||||
'critical' => '<span class="badge badge-danger">Critical</span>',
|
||||
default => '<span class="badge badge-secondary">Unknown</span>'
|
||||
};
|
||||
|
||||
$html = '<div class="scheduler-health">';
|
||||
$html .= '<p>Status: ' . $statusBadge;
|
||||
if ($message) {
|
||||
$html .= ' - ' . htmlspecialchars($message);
|
||||
}
|
||||
$html .= '</p>';
|
||||
|
||||
if ($lastRun) {
|
||||
$lastRunTime = new \DateTime($lastRun);
|
||||
$now = new \DateTime();
|
||||
$diff = $now->diff($lastRunTime);
|
||||
|
||||
$timeAgo = '';
|
||||
if ($diff->d > 0) {
|
||||
$timeAgo = $diff->d . ' day' . ($diff->d > 1 ? 's' : '') . ' ago';
|
||||
} elseif ($diff->h > 0) {
|
||||
$timeAgo = $diff->h . ' hour' . ($diff->h > 1 ? 's' : '') . ' ago';
|
||||
} elseif ($diff->i > 0) {
|
||||
$timeAgo = $diff->i . ' minute' . ($diff->i > 1 ? 's' : '') . ' ago';
|
||||
} else {
|
||||
$timeAgo = 'Less than a minute ago';
|
||||
}
|
||||
|
||||
$html .= '<p>Last Run: <strong>' . $timeAgo . '</strong></p>';
|
||||
} else {
|
||||
$html .= '<p>Last Run: <strong>Never</strong></p>';
|
||||
}
|
||||
|
||||
$html .= '<p>Jobs Due: <strong>' . $jobsDue . '</strong></p>';
|
||||
$html .= '<p>Queue Size: <strong>' . $queueSize . '</strong></p>';
|
||||
|
||||
if ($failedJobs > 0) {
|
||||
$html .= '<p class="text-danger">Failed Jobs (24h): <strong>' . $failedJobs . '</strong></p>';
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format triggers for display
|
||||
*
|
||||
* @param array $triggers
|
||||
* @return string
|
||||
*/
|
||||
protected function formatTriggers(array $triggers): string
|
||||
{
|
||||
if (empty($triggers)) {
|
||||
return '<div class="alert alert-warning">No active triggers detected. Please set up cron, systemd, or webhook triggers.</div>';
|
||||
}
|
||||
|
||||
$html = '<div class="scheduler-triggers">';
|
||||
$html .= '<ul class="list-unstyled">';
|
||||
|
||||
foreach ($triggers as $trigger) {
|
||||
$icon = match($trigger) {
|
||||
'cron' => '⏰',
|
||||
'systemd' => '⚙️',
|
||||
'webhook' => '🔗',
|
||||
'external' => '🌐',
|
||||
default => '•'
|
||||
};
|
||||
|
||||
$label = match($trigger) {
|
||||
'cron' => 'Cron Job',
|
||||
'systemd' => 'Systemd Timer',
|
||||
'webhook' => 'Webhook Triggers',
|
||||
'external' => 'External Triggers',
|
||||
default => ucfirst($trigger)
|
||||
};
|
||||
|
||||
$html .= '<li>' . $icon . ' <strong>' . $label . '</strong> <span class="badge badge-success">Active</span></li>';
|
||||
}
|
||||
|
||||
$html .= '</ul>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create JSON response
|
||||
*
|
||||
* @param array $data
|
||||
* @param int $statusCode
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
protected function jsonResponse(array $data, int $statusCode = 200): ResponseInterface
|
||||
{
|
||||
$response = $this->grav['response'] ?? new \Nyholm\Psr7\Response();
|
||||
|
||||
$response = $response->withStatus($statusCode)
|
||||
->withHeader('Content-Type', 'application/json');
|
||||
|
||||
$body = $response->getBody();
|
||||
$body->write(json_encode($data));
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
namespace Grav\Common\Service;
|
||||
|
||||
use Grav\Common\Scheduler\Scheduler;
|
||||
use Grav\Common\Scheduler\JobQueue;
|
||||
use Grav\Common\Scheduler\JobWorker;
|
||||
use Pimple\Container;
|
||||
use Pimple\ServiceProviderInterface;
|
||||
|
||||
@@ -25,8 +27,38 @@ class SchedulerServiceProvider implements ServiceProviderInterface
|
||||
*/
|
||||
public function register(Container $container)
|
||||
{
|
||||
$container['scheduler'] = function () {
|
||||
return new Scheduler();
|
||||
$container['scheduler'] = function ($c) {
|
||||
$config = $c['config'];
|
||||
$scheduler = new Scheduler();
|
||||
|
||||
// Configure modern features if enabled
|
||||
$modernConfig = $config->get('scheduler.modern', []);
|
||||
if ($modernConfig['enabled'] ?? false) {
|
||||
// Initialize components
|
||||
$queuePath = $c['locator']->findResource('user-data://scheduler/queue', true, true);
|
||||
$statusPath = $c['locator']->findResource('user-data://scheduler/status.yaml', true, true);
|
||||
|
||||
// Set modern configuration on scheduler
|
||||
$scheduler->setModernConfig($modernConfig);
|
||||
|
||||
// Initialize job queue if enabled
|
||||
if ($modernConfig['queue']['enabled'] ?? false) {
|
||||
$jobQueue = new JobQueue($queuePath);
|
||||
$scheduler->setJobQueue($jobQueue);
|
||||
}
|
||||
|
||||
// Initialize workers if enabled
|
||||
if ($modernConfig['workers']['enabled'] ?? false) {
|
||||
$workerCount = $modernConfig['workers']['count'] ?? 2;
|
||||
$workers = [];
|
||||
for ($i = 0; $i < $workerCount; $i++) {
|
||||
$workers[] = new JobWorker("worker-{$i}");
|
||||
}
|
||||
$scheduler->setWorkers($workers);
|
||||
}
|
||||
}
|
||||
|
||||
return $scheduler;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
namespace Grav\Console\Cli;
|
||||
|
||||
use Cron\CronExpression;
|
||||
use Dragonmantank\Cron\CronExpression;
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Common\Utils;
|
||||
use Grav\Common\Scheduler\Scheduler;
|
||||
|
||||
Reference in New Issue
Block a user