- Admin-Seite mit Einstellung für Cron-Prüfintervall (10-3600 Sek) - checkStuckCronJobs() nutzt jetzt konfigurierbare Einstellung - README.md mit vollständiger Integrations-Dokumentation - Sichere Nutzung ohne Fatal Error wenn Modul nicht installiert - Version 1.3.0 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
482 lines
14 KiB
PHP
482 lines
14 KiB
PHP
<?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);
|
|
|
|
// 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;
|
|
}
|
|
|
|
$notify = new GlobalNotify($this->db);
|
|
|
|
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
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|