dolibarr.globalnotify/class/actions_globalnotify.class.php
data b4a6f534ba fix: Auto-dismiss cron notifications for disabled jobs
- Add cleanupDisabledCronNotifications() method
- Automatically mark notifications as read when cronjob is disabled
- Fixes issue where "Cron-Job verpasst" kept reappearing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-03 12:09:42 +01:00

536 lines
16 KiB
PHP
Executable file

<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
/**
* \file globalnotify/class/actions_globalnotify.class.php
* \ingroup globalnotify
* \brief Hook class for displaying notifications in top bar
*/
require_once DOL_DOCUMENT_ROOT.'/core/class/commonhookactions.class.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php';
dol_include_once('/globalnotify/class/globalnotify.class.php');
/**
* Class ActionsGlobalNotify
* Hook actions for GlobalNotify module
*/
class ActionsGlobalNotify extends CommonHookActions
{
/**
* @var DoliDB Database handler
*/
public $db;
/**
* @var string Error message
*/
public $error = '';
/**
* @var array Error messages
*/
public $errors = array();
/**
* @var array Results
*/
public $results = array();
/**
* @var string Returned string
*/
public $resprints = '';
/**
* Constructor
*
* @param DoliDB $db Database handler
*/
public function __construct($db)
{
$this->db = $db;
}
/**
* Hook to add floating notification widget
*
* @param array $parameters Parameters
* @param object $object Object
* @param string $action Action
* @return int 0=OK, >0=KO
*/
public function printTopRightMenu($parameters, &$object, &$action)
{
global $conf, $user, $langs;
if (!$user->admin) {
return 0; // Only show to admins
}
$langs->load('globalnotify@globalnotify');
// Check for stuck cron jobs on each page load (with caching)
$this->checkStuckCronJobs();
$notify = new GlobalNotify($this->db);
$unreadNotifications = $notify->getAllNotifications($user->id, true);
$readNotifications = $notify->getReadNotifications($user->id, 20);
$unreadCount = count($unreadNotifications);
$readCount = count($readNotifications);
$totalCount = $unreadCount + $readCount;
// Check for urgent notifications
$hasUrgent = false;
foreach ($unreadNotifications as $n) {
if (in_array($n['type'], array('error', 'action'))) {
$hasUrgent = true;
break;
}
}
// Build floating widget HTML
$html = '';
// Floating button (always visible, bottom-left corner)
$html .= '<div id="globalnotify-widget" class="globalnotify-widget">';
// Draggable FAB button (click to open, drag to move)
// Gray when empty, red when has notifications, pulsing when urgent
$fabClasses = 'globalnotify-fab';
if ($unreadCount > 0) {
$fabClasses .= ' globalnotify-fab-active';
}
if ($hasUrgent) {
$fabClasses .= ' globalnotify-fab-urgent';
}
$html .= '<div id="globalnotify-fab" class="'.$fabClasses.'" title="'.$langs->trans('Notifications').' - Ziehen zum Verschieben">';
$html .= '<span class="fa fa-bell"></span>';
if ($unreadCount > 0) {
$html .= '<span class="globalnotify-fab-badge">'.$unreadCount.'</span>';
}
$html .= '</div>';
// Floating panel (expandable)
$html .= '<div id="globalnotify-panel" class="globalnotify-panel" style="display:none;">';
// Panel header with drag handle
$html .= '<div class="globalnotify-panel-header" id="globalnotify-drag-handle">';
$html .= '<span class="globalnotify-panel-title">'.$langs->trans('Notifications').' ('.$unreadCount.'/'.$totalCount.')</span>';
$html .= '<div class="globalnotify-panel-actions">';
if ($unreadCount > 0) {
$html .= '<span class="globalnotify-action-link" onclick="GlobalNotify.markAllRead()" title="'.$langs->trans('MarkAllRead').'"><span class="fa fa-check-double"></span></span>';
}
$html .= '<span class="globalnotify-action-link" onclick="GlobalNotify.toggle()" title="Schließen"><span class="fa fa-times"></span></span>';
$html .= '</div>';
$html .= '</div>';
// Unread notifications section
$html .= '<div class="globalnotify-section">';
if (empty($unreadNotifications)) {
$html .= '<div class="globalnotify-empty">';
$html .= '<span class="fa fa-check-circle"></span><br>';
$html .= $langs->trans('NoNotifications');
$html .= '</div>';
} else {
foreach ($unreadNotifications as $notif) {
$html .= $this->renderNotificationItem($notif, false);
}
}
$html .= '</div>';
// History section (collapsible)
if (!empty($readNotifications)) {
$html .= '<div class="globalnotify-history-toggle" onclick="GlobalNotify.toggleHistory()">';
$html .= '<span class="fa fa-history"></span> Historie ('.$readCount.')';
$html .= '<span class="fa fa-chevron-down globalnotify-chevron"></span>';
$html .= '</div>';
$html .= '<div id="globalnotify-history" class="globalnotify-history">';
foreach ($readNotifications as $notif) {
$html .= $this->renderNotificationItem($notif, true);
}
$html .= '</div>';
}
// Footer link
$html .= '<div class="globalnotify-panel-footer">';
$html .= '<a href="'.dol_buildpath('/globalnotify/admin/setup.php', 1).'">';
$html .= '<span class="fa fa-cog"></span> Alle anzeigen';
$html .= '</a>';
$html .= '</div>';
$html .= '</div>'; // panel
$html .= '</div>'; // widget
$this->resprints = $html;
return 0;
}
/**
* Render a single notification item
*
* @param array $notif Notification data
* @param bool $isRead Is this a read notification
* @return string HTML
*/
private function renderNotificationItem($notif, $isRead = false)
{
$typeClass = 'globalnotify-item globalnotify-item-'.$notif['type'];
if ($isRead) {
$typeClass .= ' globalnotify-item-read';
}
$html = '<div class="'.$typeClass.'" data-id="'.dol_escape_htmltag($notif['id']).'">';
// Checkbox for quick action
$html .= '<div class="globalnotify-checkbox" onclick="GlobalNotify.toggleItem(\''.$notif['id'].'\', event)">';
if ($isRead) {
$html .= '<span class="fa fa-check-square globalnotify-checked"></span>';
} else {
$html .= '<span class="fa fa-square-o globalnotify-unchecked"></span>';
}
$html .= '</div>';
// Content area (clickable if has action)
$hasAction = !empty($notif['action_url']);
$contentClass = 'globalnotify-item-content'.($hasAction ? ' globalnotify-clickable' : '');
$onclick = $hasAction ? ' onclick="GlobalNotify.goToAction(\''.$notif['id'].'\', \''.dol_escape_htmltag($notif['action_url']).'\')"' : '';
$html .= '<div class="'.$contentClass.'"'.$onclick.'>';
// Icon
$icon = 'info-circle';
if ($notif['type'] == 'error') $icon = 'exclamation-circle';
elseif ($notif['type'] == 'warning') $icon = 'exclamation-triangle';
elseif ($notif['type'] == 'success') $icon = 'check-circle';
elseif ($notif['type'] == 'action') $icon = 'hand-point-right';
$html .= '<div class="globalnotify-item-icon"><span class="fa fa-'.$icon.'"></span></div>';
$html .= '<div class="globalnotify-item-text">';
$html .= '<div class="globalnotify-item-title">'.dol_escape_htmltag($notif['title']).'</div>';
$html .= '<div class="globalnotify-item-message">'.dol_escape_htmltag($notif['message']).'</div>';
$html .= '<div class="globalnotify-item-meta">';
$html .= '<span class="globalnotify-module-tag">'.dol_escape_htmltag($notif['module']).'</span>';
$html .= '<span class="globalnotify-time">'.dol_print_date($notif['created'], 'dayhour').'</span>';
$html .= '</div>';
$html .= '</div>';
$html .= '</div>'; // content
$html .= '</div>'; // item
return $html;
}
/**
* Hook called on every page load - collect notifications from modules
*
* @param array $parameters Parameters
* @param object $object Object
* @param string $action Action
* @return int 0=OK
*/
public function addMoreActionsButtons($parameters, &$object, &$action)
{
global $conf, $user;
if (!$user->admin) {
return 0;
}
// Check ALL stuck cron jobs (generic check)
$this->checkStuckCronJobs();
// Check BankImport cron status (module-specific)
if (isModEnabled('bankimport')) {
$this->checkBankImportStatus();
}
// Check ImportZugferd cron status (module-specific)
if (isModEnabled('importzugferd')) {
$this->checkImportZugferdStatus();
}
return 0;
}
/**
* Check all cron jobs for stuck/hanging state
* This is the generic check that works for all modules
*/
private function checkStuckCronJobs()
{
global $conf;
// Get configurable check interval (default 60 seconds)
$checkInterval = getDolGlobalInt('GLOBALNOTIFY_CRON_CHECK_INTERVAL', 60);
if ($checkInterval < 10) {
$checkInterval = 10;
}
// Only check based on configured interval (cache)
$lastCheck = getDolGlobalInt('GLOBALNOTIFY_CRON_LASTCHECK');
if ($lastCheck > (time() - $checkInterval)) {
return;
}
dolibarr_set_const($this->db, 'GLOBALNOTIFY_CRON_LASTCHECK', time(), 'chaine', 0, '', $conf->entity);
$notify = new GlobalNotify($this->db);
// Clean up: Remove notifications for disabled cronjobs
$this->cleanupDisabledCronNotifications($notify);
// Find all stuck cron jobs (processing=1 for more than 30 minutes)
$sql = "SELECT rowid, label, module_name, datelastrun, processing
FROM ".MAIN_DB_PREFIX."cronjob
WHERE processing = 1
AND status = 1
AND datelastrun < DATE_SUB(NOW(), INTERVAL 30 MINUTE)";
$resql = $this->db->query($sql);
if (!$resql) {
return;
}
while ($obj = $this->db->fetch_object($resql)) {
$module = !empty($obj->module_name) ? strtolower($obj->module_name) : 'cron';
$lastRun = $this->db->jdate($obj->datelastrun);
$stuckMinutes = round((time() - $lastRun) / 60);
// Check if we already have an active notification for this job
$notifKey = 'cronjob_stuck_'.$obj->rowid;
$existing = $notify->getModuleNotifications('cron');
$hasNotif = false;
foreach ($existing as $n) {
if (strpos($n['id'], $notifKey) !== false && !$n['read']) {
$hasNotif = true;
break;
}
}
if (!$hasNotif) {
$notify->addNotification(
'cron',
GlobalNotify::TYPE_ERROR,
'Cron-Job hängt: '.$obj->label,
"Der Job läuft seit {$stuckMinutes} Minuten ohne Antwort. Modul: {$module}",
dol_buildpath('/cron/list.php', 1),
'Cron-Jobs anzeigen',
10, // highest priority
0
);
}
}
// Also check for jobs that haven't run when they should have
// (datenextrun is in the past but job hasn't started)
$sql = "SELECT rowid, label, module_name, datenextrun, datelastrun
FROM ".MAIN_DB_PREFIX."cronjob
WHERE processing = 0
AND status = 1
AND datenextrun < DATE_SUB(NOW(), INTERVAL 2 HOUR)";
$resql = $this->db->query($sql);
if ($resql) {
while ($obj = $this->db->fetch_object($resql)) {
$module = !empty($obj->module_name) ? strtolower($obj->module_name) : 'cron';
$missedTime = $this->db->jdate($obj->datenextrun);
$missedHours = round((time() - $missedTime) / 3600);
$existing = $notify->getModuleNotifications('cron');
$hasNotif = false;
foreach ($existing as $n) {
if (strpos($n['message'], $obj->label) !== false && strpos($n['title'], 'verpasst') !== false && !$n['read']) {
$hasNotif = true;
break;
}
}
if (!$hasNotif) {
$notify->addNotification(
'cron',
GlobalNotify::TYPE_WARNING,
'Cron-Job verpasst',
"Job '{$obj->label}' hätte vor {$missedHours} Stunden laufen sollen. Prüfe Cron-Aufruf.",
dol_buildpath('/cron/list.php', 1),
'Cron-Jobs prüfen',
7,
0
);
}
}
}
}
/**
* Remove cron notifications for disabled cronjobs
* This ensures users can dismiss notifications by disabling the cronjob
*
* @param GlobalNotify $notify GlobalNotify instance
*/
private function cleanupDisabledCronNotifications($notify)
{
global $conf;
$existing = $notify->getModuleNotifications('cron');
if (empty($existing)) {
return;
}
// Get all disabled cronjobs
$disabledJobs = array();
$sql = "SELECT rowid, label FROM ".MAIN_DB_PREFIX."cronjob WHERE status != 1";
$resql = $this->db->query($sql);
if ($resql) {
while ($obj = $this->db->fetch_object($resql)) {
$disabledJobs[$obj->rowid] = $obj->label;
}
}
if (empty($disabledJobs)) {
return;
}
// Mark notifications for disabled jobs as read
$changed = false;
foreach ($existing as &$notif) {
if (!empty($notif['read'])) {
continue;
}
// Check if notification is about a disabled job
foreach ($disabledJobs as $jobId => $jobLabel) {
if (strpos($notif['message'], $jobLabel) !== false || strpos($notif['title'], $jobLabel) !== false) {
$notif['read'] = true;
$changed = true;
break;
}
}
}
if ($changed) {
$key = 'GLOBALNOTIFY_CRON';
dolibarr_set_const($this->db, $key, json_encode($existing), 'chaine', 0, '', $conf->entity);
}
}
/**
* Check BankImport module for issues
*/
private function checkBankImportStatus()
{
dol_include_once('/bankimport/class/bankimportcron.class.php');
if (!class_exists('BankImportCron')) {
return;
}
$status = BankImportCron::getCronStatus();
$notify = new GlobalNotify($this->db);
// Clear old notifications first
$existing = $notify->getModuleNotifications('bankimport');
// Check for issues
if ($status['paused']) {
// Check if we already have this notification
$hasNotif = false;
foreach ($existing as $n) {
if ($n['type'] == 'warning' && strpos($n['title'], 'pausiert') !== false && !$n['read']) {
$hasNotif = true;
break;
}
}
if (!$hasNotif) {
GlobalNotify::warning(
'bankimport',
'BankImport Cron pausiert',
$status['pause_reason'],
dol_buildpath('/bankimport/admin/cronmonitor.php', 1),
'Cron Monitor öffnen'
);
}
}
if (!empty($status['notification'])) {
$notifType = $status['notification']['type'];
$messages = array(
'tan_required' => array('TAN erforderlich', 'Bank-Login erfordert TAN-Bestätigung'),
'login_error' => array('Login-Fehler', 'Bank-Login fehlgeschlagen'),
'fetch_error' => array('Abruf-Fehler', 'Kontoauszüge konnten nicht abgerufen werden'),
'session_expired' => array('Session abgelaufen', 'Bank-Session ist abgelaufen, neuer Login erforderlich'),
);
if (isset($messages[$notifType])) {
$hasNotif = false;
foreach ($existing as $n) {
if (strpos($n['title'], $messages[$notifType][0]) !== false && !$n['read']) {
$hasNotif = true;
break;
}
}
if (!$hasNotif) {
if ($notifType == 'tan_required') {
GlobalNotify::actionRequired(
'bankimport',
$messages[$notifType][0],
$messages[$notifType][1],
dol_buildpath('/bankimport/fetch.php', 1),
'TAN eingeben'
);
} else {
GlobalNotify::error(
'bankimport',
$messages[$notifType][0],
$messages[$notifType][1],
dol_buildpath('/bankimport/admin/cronmonitor.php', 1),
'Details anzeigen'
);
}
}
}
}
}
/**
* Check ImportZugferd module for issues
*/
private function checkImportZugferdStatus()
{
// Similar check for ImportZugferd can be added here
// Check if cron job is stuck
$sql = "SELECT processing, datelastrun FROM ".MAIN_DB_PREFIX."cronjob WHERE label = 'ImportZugferdScheduled' AND processing = 1";
$resql = $this->db->query($sql);
if ($resql && $obj = $this->db->fetch_object($resql)) {
$lastRun = $this->db->jdate($obj->datelastrun);
// If running for more than 30 minutes, it's probably stuck
if ($lastRun < (time() - 1800)) {
$notify = new GlobalNotify($this->db);
$existing = $notify->getModuleNotifications('importzugferd');
$hasNotif = false;
foreach ($existing as $n) {
if (strpos($n['title'], 'hängt') !== false && !$n['read']) {
$hasNotif = true;
break;
}
}
if (!$hasNotif) {
GlobalNotify::error(
'importzugferd',
'ImportZugferd Cron hängt',
'Der Cron-Job läuft seit '.dol_print_date($lastRun, 'dayhour').' und reagiert nicht mehr.',
dol_buildpath('/importzugferd/admin/setup.php', 1),
'Einstellungen öffnen'
);
}
}
}
}
}