Skip to content
Snippets Groups Projects
Select Git revision
  • 7d2f7ba58b9e43411038cbd89f95475fc606dd8f
  • master default protected
  • Legacy_Php7
3 results

inc_functions.php

Blame
  • cronlog-renderer.class.php 30.87 KiB
    <?php
    require_once 'cronlog.class.php';
    /**
     * ______________________________________________________________________
     * 
     *  _____                 _       _             _                        
     * /  __ \               (_)     | |           (_)                       
     * | /  \/_ __ ___  _ __  _  ___ | |__   __   ___  _____      _____ _ __ 
     * | |   | '__/ _ \| '_ \| |/ _ \| '_ \  \ \ / / |/ _ \ \ /\ / / _ \ '__|
     * | \__/\ | | (_) | | | | | (_) | |_) |  \ V /| |  __/\ V  V /  __/ |   
     *  \____/_|  \___/|_| |_| |\___/|_.__/    \_/ |_|\___| \_/\_/ \___|_|   
     *                      _/ |                                             
     *                     |__/                                              
     * ______________________________________________________________________
     * 
     * The cronjob viewer for centralized monitoring of cronjobs using
     * Axels cronrwapper (see <https://github.com/axelhahn/cronwrapper>).
     * 
     * You can browse all servers to see 
     * - the last status of all cronjobs
     * - results an execution times of running jonbs
     * - a timeline for all jobs running > 60s
     * 
     * 
     * Free software. Open Source. GNU GPL 3.
     * SOURCE: <https://git-repo.iml.unibe.ch/iml-open-source/cronlog-viewer>
     * 
     * ______________________________________________________________________
     * 
     * The class cronlog-renderer contains visual methods to render html
     * @see cronlog.class.php
     *
     * @license GNU GPL 3.0
     * @author Axel Hahn <axel.hahn@iml.unibe.ch>
     * 
     * 2024-10-01  <axel.hahn@unibe.ch>  added type declarations; update php docs
     */
    class cronlogrenderer extends cronlog
    {
    
        /**
         * minimal length for execution time of a job to be rendered in the timeline; value is in seconds
         * @var integer
         */
        protected int $_iMinTime4Timeline = 60;
    
        /**
         * Max count of entries in history table to prevent freezing of the screen
         * when displaying a lot of entries, eg all logs of 100+ servers
         * @var int
         */
        protected int $_iHistoryLimit = 1000;
    
        /**
         * Show date of last data and last access; used in rendering methods to display it on top
         * @param integer $iLast  unix timestamp of last log entry
         * @return string
         */
        protected function _renderAccessAndAge(int $iLast): string
        {
            if (!$iLast) {
                return '';
            }
            $iAge = round((date('U') - $iLast) / 60);
            $sAge = ($iAge > 60*24 ? '<span class="message-error">'.$iAge.'</span>' : $iAge);
            return '<div class="accessandage">'
                . sprintf($this->t("request-time"), date("Y-m-d H:i:s")) . '<br>'
                . sprintf($this->t("last-entry"), $sAge)
                /*
                .($iAge > 60*24
                    ? " <a href=\"?deleteserverlogs=$this->_sActiveServer\" class=\"btn btn-danger\">Delete server</a>"
                    : ''
                )
                */
                . '</div>'
            ;
        }
    
        /**
         * Get onclick value to filter a datatable
         * @param  string  $sDatatable   id of table
         * @param  string  $sFiltertext  text to filter
         * @return string
         * 
         */
        protected function _filterDatatable($sDatatable, $sFiltertext)
        {
            return '$(\'#' . $sDatatable . '\').dataTable().fnFilter(\'' . $sFiltertext . '\'); return false;';
        }
    
        /**
         * Helper function to be used in methods that render datatable tables
         * Get javascript code to be added in init options and set language specifi texts
         * @return string
         */
        protected function _getDatatableLanguage(): string
        {
            return $this->t("dt-USE")
                ? ', "oLanguage":{
                    "sProcessing":"' . $this->t("dt-sProcessing") . '",
                    "sLengthMenu":"' . $this->t("dt-sLengthMenu") . '",
                    "sZeroRecords":"' . $this->t("dt-sZeroRecords") . '",
                    "sInfo":"' . $this->t("dt-sInfo") . '",
                    "sInfoEmpty":"' . $this->t("dt-sInfoEmpty") . '",
                    "sInfoFiltered":"' . $this->t("dt-sInfoFiltered") . '",
                    "sInfoPostFix":"' . $this->t("dt-sInfoPostFix") . '",
                    "sSearch":"' . $this->t("dt-sSearch") . '",
                    "sUrl":"' . $this->t("dt-sUrl") . '",
                    "oPaginate":{
                        "sFirst":"' . $this->t("dt-sFirst") . '",
                        "sPrevious":"' . $this->t("dt-sPrevious") . '",
                        "sNext":"' . $this->t("dt-sNext") . '",
                        "sLast":"' . $this->t("dt-sLast") . '"
                    }
            }'
                : ''
            ;
        }
        /**
         * Get html code for a table with events of executed cronjobs
         * 
         * @param array  $aData   result of $this->getServerLogs()
         * @return string
         */
        public function renderCronlogs(array $aData = []): string
        {
            $sTaskId = __FUNCTION__ . '-' . $this->_sActiveServer;
            $sHtml = $this->_getCacheData($sTaskId);
            if ($sHtml) {
                return $sHtml;
            }
            $sHtml = '';
    
            if (!count($aData)) {
                $aData = array_merge($this->getRunningJobs(), $this->getServersLastLog());
            }
            $sTblHead = '';
            $iOK = 0;
            $iErrors = 0;
            $iLast = false;
            // Array ( [SCRIPTNAME] => apt-get update [SCRIPTTTL] => 1440 [SCRIPTSTARTTIME] => 2016-06-21 06:00:02, 1466481602 [SCRIPTLABEL] => apt-get [SCRIPTENDTIME] => 2016-06-21 06:00:49, 1466481649 [SCRIPTEXECTIME] => 47 s [SCRIPTRC] => 0 )
            foreach ($aData as $sDtakey => $aEntry) {
                if (!$sTblHead) {
                    foreach ([
                        $this->t("col-starting-time"), 
                        $this->t("col-label"), 
                        $this->t("col-server"), 
                        $this->t("col-duration"), 
                        $this->t("col-ttl"), 
                        $this->t("col-rc"), 
                        $this->t("col-expired"), 
                        $this->t("col-status"), 
                    ] as $sKey) {
                        $sTblHead .= "<th>$sKey</th>";
                    }
                }
                // $sViewerUrl='viewer.php?host='.$aEntry['host'].'&job='.$aEntry['job'];
                // $sClass='message-'.($aEntry['SCRIPTRC']?'error':'ok');
                $iLast = max([$iLast, date("U", $aEntry['SCRIPTSTARTTIME'])]);
    
                $aErrors = [];
                $iTtlUsed = max($aEntry['SCRIPTTTL'], $this->_iMinTtl);
                $iNextRun = $aEntry['SCRIPTSTARTTIME'] + ((int) $aEntry['SCRIPTTTL'] * 60);
                $iNextRunWarn = $aEntry['SCRIPTSTARTTIME'] + ((int) $iTtlUsed * 60);
                $iNextRunErr = $aEntry['SCRIPTSTARTTIME'] + (((int) $aEntry['SCRIPTTTL'] + (int) $this->_iExpiredJobsFailAfter) * 60);
    
                // ticket #5850 - check hostname vs. servername in logfile
                $sServerFromLogfile = preg_replace('/_.*/', '', basename($aEntry['logfile']));
                if ($sServerFromLogfile != $aEntry['server']) {
                    $aErrors[] = [
                        $this->t('error-host-in-log-differs-servername-label'),
                        sprintf($this->t('error-host-in-log-differs-servername-description'), $sServerFromLogfile, $aEntry['server']),
                    ];
                }
    
                if (!strstr($sServerFromLogfile, ".")) {
                    $aErrors[] = [
                        $this->t('error-no-fqdn-label'),
                        sprintf($this->t('error-no-fqdn-description'), $sServerFromLogfile),
                    ];
                }
    
                if ($iNextRunErr < date("U")) {
                    $aErrors[] = [
                        $this->t('error-expired-label'),
                        $this->t('error-expired-description'),
                    ];
                }
                if ($aEntry['SCRIPTRC'] > 0) {
                    $aErrors[] = [
                        sprintf($this->t('error-exitcode-label'), (int) $aEntry['SCRIPTRC']),
                        $this->t('error-exitcode-description'),
                    ];
                }
                if (!$aEntry['SCRIPTLABEL']) {
                    $aErrors[] = [
                        $this->t('error-no-label-label'),
                        $this->t('error-no-label-description'),
                    ];
                }
    
    
                if (count($aErrors)) {
                    $iErrors++;
                } else {
                    $iOK++;
                }
    
                // human readable TTL value ... SCRIPTTTL is in [min]
                $sTtlHr = '';
                if ((int) $aEntry['SCRIPTTTL'] > 60 * 24 * 3) {
                    $sTtlHr .= '=' . round((int) $aEntry['SCRIPTTTL'] / 60 / 24) . 'd ';
                } else if ((int) $aEntry['SCRIPTTTL'] > 60 * 3) {
                    $sTtlHr .= '=' . round((int) $aEntry['SCRIPTTTL'] / 60) . 'h';
                }
                $sTtlHr = $sTtlHr ? '(' . $sTtlHr . ')' : '';
    
                $sColStatus = '';
                $bIsRunning = false;
                if ($aEntry['SCRIPTRC']) {
                    if (count($aErrors)) {
                        foreach ($aErrors as $aErr) {
                            $sColStatus .= '<li><abbr title="' . $aErr[1] . '">' . $this->t('status-error') . ': ' . $aErr[0] . '</abbr></li>';
                        }
                        $sColStatus = '<ul>' . $sColStatus . '</ul>';
                    } else {
                        $sColStatus .= $this->t('status-ok');
                    }
                } else {
                    $bIsRunning = true;
                    $sColStatus .= $this->t('status-running');
                }
    
                // execution time of time of currently running job
                $iExectime = $aEntry['SCRIPTEXECTIME']
                    ? (int) $aEntry['SCRIPTEXECTIME']
                    : date('U') - $aEntry['SCRIPTSTARTTIME']
                ;
    
                // render table of last logfile per cron job
                $sHtml .= '<tr onclick="showFile(\'' . $aEntry['logfile'] . '\');" title="' . sprintf($this->t('row-click-show-logfile'), $aEntry['logfile']) . '"'
                    . ($bIsRunning ? ' class="message-running"' : '')
                    . '>'
                    . '<td>' . date("Y-m-d H:i:s", $aEntry['SCRIPTSTARTTIME']) . '</td>'
                    // . '<td>'.$aEntry['SCRIPTNAME'].'</td>'
                    . '<td>' . $aEntry['SCRIPTLABEL'] . '</td>'
                    . '<td>' . $aEntry['server'] . '</td>'
                    . '<td align="right"'
                    . ((int) $aEntry['SCRIPTEXECTIME'] > $this->_iMinTime4Timeline ? ' class="message-warning"' : '')
                    . '>'
                    . '<span style="display: none">' . str_pad((int) $aEntry['SCRIPTEXECTIME'], 6, '0', STR_PAD_LEFT) . '</span>'
    
                    . $iExectime . 's'
                    . ($iExectime > 100 ? ' (' . round($iExectime / 60) . 'min)' : '')
                    . '</td>'
                    . '<td align="right"'
                    . ($aEntry['SCRIPTTTL'] < $this->_iMinTtl ? ' class="message-warning" title="(using minimal TTL = ' . $this->_iMinTtl . ' min)"' : '')
                    . '>'
                    . '<span style="display: none">' . str_pad((int) $aEntry['SCRIPTTTL'], 6, '0', STR_PAD_LEFT) . '</span>'
                    . $aEntry['SCRIPTTTL']
                    . $sTtlHr
                    . '</td>'
                    . '<td align="right" class="' . ($aEntry['SCRIPTRC'] ? ($aEntry['SCRIPTRC'] > 0 ? 'message-error' : 'message-ok') : '') . '">'
                    . ($aEntry['SCRIPTRC'] ? $aEntry['SCRIPTRC'] : '⏳')
                    . '</td>'
                    . '<td class="' . ($iNextRunWarn < date("U")
                    ? ($iNextRunErr < date("U") ? 'message-error' : 'message-warning')
                    : '') . '">'
                    . date("Y-m-d H:i", $iNextRun) . '</td>'
                    . '<td' . (count($aErrors) ? ' class="message-error" title=""' : '') . '>'
                    . $sColStatus . '</td>'
                    // .(count($aErrors) ? 'FEHLER' : 'OK').'</td>'
                    // . '<td><button onclick="showFile(\''.$aEntry['logfile'].'\');">Ansehen</button></td>'
                    . '</tr>'
                ;
            }
            $sIdTable = 'datatable1';
            $sHtml = '
                <!-- START ' . __METHOD__ . ' -->
                '
    
                . '<h3>' . $this->t('logs-head') . '</h3>'
                . '<p class="hint">'
                . $this->t('logs-hint')
                . '</p>'
                . (count($aData)
                    ? '<div>'
                    . $this->_renderAccessAndAge($iLast)
                    . ($iErrors ? '<a href="#" class="btn bg-danger" onclick="' . $this->_filterDatatable($sIdTable, $this->t('status-error')) . '"><i class="fas fa-exclamation-circle"></i> &nbsp; ' . $iErrors . '</a> ' : '')
                    . ($iOK ? '<a href="#" class="btn bg-success" onclick="' . $this->_filterDatatable($sIdTable, $this->t('status-ok')) . '"><i class="fas fa-check"></i> &nbsp; ' . $iOK . '</a>' : '')
                    . ($iErrors && $iOK ? ' ... ' . $this->t('total') . ': <a href="#" class="btn bg-gray" onclick="' . $this->_filterDatatable($sIdTable, "") . '"><i class="fas fa-th-large"></i> &nbsp; ' . count($aData) . '</a>' : '')
                    . '</div><br>'
                    . '<table id="' . $sIdTable . '" class="table-striped">'
                    . '<thead><tr>' . $sTblHead . '</tr></thead>'
                    . '<tbody>'
                    . $sHtml
                    . '</tbody>'
                    . '</table>'
                    . '<script>'
                    . '$(document).ready( function () { $(\'#' . $sIdTable . '\').DataTable({
                                "retrieve": true, 
                                "bPaginate":false, 
                                "aaSorting":[[0,"desc"]]
                                ' . $this->_getDatatableLanguage() . '
                             }); 
                           });'
                    . '</script>'
                    : ''
                )
    
                // init datatable
    
                . '
                <!-- ENDE ' . __METHOD__ . ' -->
                '
            ;
            $this->_writeCacheData($sTaskId, $sHtml);
            return $sHtml;
        }
    
        /**
         * Get html code for a table with events of executed cronjobs for ALL servers
         * 
         * @param array  $aData   result of $this->getServerLogs()
         * @return string
         */
        public function renderCronlogsOfAllServers(): string
        {
            $aData = [];
            foreach (array_keys($this->getServers()) as $sServer) {
                $this->setServer($sServer);
                $aData = array_merge($aData, $this->getRunningJobs(), $this->getServersLastLog());
            }
            $this->setServer('ALL');
            return $this->renderCronlogs($aData);
        }
    
        /**
         * Get html code for a switcher of multiple instances.
         * It returns an empty string if there is no / only one instance
         * 
         * @param array  $aData   result of $oCL->getServerLogs()
         * @return string
         */
        public function renderInstances(): string
        {
            if (count($this->_aInstances) < 2) {
                return '';
            }
            $sReturn = '';
            $sServer = $_SERVER['SERVER_NAME'] ?: false;
            $sReturn .= '<li class="nav-item"><a class="nav-link nav-link" data-widget="pushmenu" href="#" role="button"><i class="fas fa-bars"></i></a></li>'
                . '<li class="nav-item d-none d-sm-inline-block"><a href="#" class="nav-link">' . $this->t('instances') . ':</a></li>';
    
            foreach ($this->_aInstances as $sInstance => $sUrl) {
                $sHost = parse_url($sUrl, PHP_URL_HOST);
                $sClass = ($sServer && $sServer == $sHost) ? 'active bg-gray' : '';
                // $sReturn.='<a class="'.$sClass.'" href="'.$sUrl.'" title="'.$sUrl.'">'.$sInstance.'</a> ';
                $sReturn .= '<li class="nav-item d-none d-sm-inline-block ' . $sClass . '"><a href="' . $sUrl . '" class="nav-link">' . $sInstance . '</a></li>';
            }
            return $sReturn;
        }
        /**
         * Get html code for a table with history of executed cronjobs
         * 
         * @param array  $aData   result of $oCL->getServerLogs()
         * @return string
         */
        public function renderHistoryTable(array $aData = []): string
        {
            $sTaskId = __FUNCTION__ . '-' . $this->_sActiveServer;
            $sHtml = $this->_getCacheData($sTaskId);
            if ($sHtml) {
                return $sHtml;
            }
            $sHtml = '';
    
            if (!$aData) {
                $aData = $this->getServerJobHistory();
            }
    
            // sort by starting time - especially before cutting the list
            $aTmp=[];
            foreach($aData as $sKey => $aEntry) {
                $aTmp[$aEntry['start'].'__'.$aEntry['host'].'__'.$aEntry['job']]=$aEntry;
            }
            krsort($aTmp);
            $aData = array_values($aTmp);
    
            // render table
            $sTblHead = '';
            $iOK = 0;
            $iErrors = 0;
            $iLast = false;
            $iCounter = 0;
            // job=dok-kvm-instances:host=kalium:start=1538358001:end=1538358001:exectime=0:ttl=60:rc=0
            foreach ($aData as $aEntry) {
                $iCounter++;
                if($iCounter>$this->_iHistoryLimit) {
                    break;
                }
                if (!$sTblHead) {
                    foreach ([
                        $this->t("col-starting-time"), 
                        $this->t("col-label"), 
                        $this->t("col-server"), 
                        $this->t("col-duration"), 
                        $this->t("col-ttl"), 
                        $this->t("col-rc"), 
                        $this->t("col-status"), 
                    ] as $sKey) {
                        $sTblHead .= '<th>' . $sKey . '</th>';
                    }
                }
                $iLast = max([$iLast, date("U", $aEntry['start'])]);
    
                if ($aEntry['rc']) {
                    $iErrors++;
                } else {
                    $iOK++;
                }
    
                $sHtml .= '<tr>'
                    . '<td>' . date("Y-m-d H:i:s", $aEntry['start']) . '</td>'
                    // . '<td>'.date("Y-m-d H:i:s", $aEntry['end']).'</td>'
                    . '<td>' . $aEntry['job'] . '</td>'
                    . '<td>' . $aEntry['host'] . '</td>'
                    . '<td align="right"'
                    . ($aEntry['exectime'] > $this->_iMinTime4Timeline ? ' class="message-warning"' : '')
                    . '>'
                    . $aEntry['exectime'] . 's'
                    . ($aEntry['exectime'] > 100 ? ' (' . round($aEntry['exectime'] / 60) . 'min)' : '')
                    . '</td>'
                    . '<td align="right">' . $aEntry['ttl'] . '</td>'
                    . '<td  align="right" class="'
                    . ($aEntry['rc'] > 0 ? 'message-error' : 'message-ok')
                    . '">' . $aEntry['rc'] . '</td>'
                    . '<td>' . ($aEntry['rc'] ? $this->t('status-error') : $this->t('status-ok')) . '</td>'
                    . '</tr>'
                ;
            }
            $sIdTable = 'datatable2';
            $sHtml = '
                <!-- START ' . __METHOD__ . ' -->
                '
                . '<h3>' . $this->t('history-head') . '</h3>'
                . '<p class="hint">'
                . $this->t('history-hint')
                . '</p>'
                . '<div>'
                . $this->_renderAccessAndAge($iLast)
                . ($iErrors ? '<a href="#" class="btn bg-danger" onclick="' . $this->_filterDatatable($sIdTable, $this->t('status-error')) . '"><i class="fas fa-exclamation-circle"></i> &nbsp; ' . $iErrors . '</a> ' : '')
                . ($iOK ? '<a href="#" class="btn bg-success" onclick="' . $this->_filterDatatable($sIdTable, $this->t('status-ok')) . '"><i class="fas fa-check"></i> &nbsp; ' . $iOK . '</a>' : '')
                . ($iErrors && $iOK ? ' ... ' . $this->t('total') . ': <a href="#" class="btn bg-gray" onclick="' . $this->_filterDatatable($sIdTable, "") . '"><i class="fas fa-th-large"></i> &nbsp; ' . count($aData) . '</a>' : '')
                . '</div>'
                . '<br>'
    
                . '<table id="' . $sIdTable . '">'
                . '<thead><tr>' . $sTblHead . '</tr></thead>'
                . '<tbody>'
                . $sHtml
                . '</tbody>'
                . '</table>'
    
                // init datatable
                . (count($aData)
                    ? '<script>'
                    . '$(document).ready( function () {$(\'#' . $sIdTable . '\').DataTable({
                                "retrieve": true, 
                                "aaSorting":[[0,"desc"]], 
                                "aLengthMenu":[[25,100,-1],[25,100,"---"]]
                                ' . $this->_getDatatableLanguage() . '
                            });} );'
                    . '</script>'
                    : ''
                )
    
                . '
                <!-- ENDE ' . __METHOD__ . ' -->
                '
            ;
            $this->_writeCacheData($sTaskId, $sHtml);
            return $sHtml;
        }
    
        /**
         * get html code for a joblist of the selected server
         * it uses the filter for hidden joblog entries (aHidelogs in config)
         * 
         * @return string
         */
        public function renderHistoryOfAllServers(): string
        {
            $aData = [];
            foreach (array_keys($this->getServers()) as $sServer) {
                $this->setServer($sServer);
                $aData = array_merge($aData, $this->getServerJobHistory());
            }
            $this->setServer('ALL');
            return $this->renderHistoryTable($aData);
        }
    
        /**
         * get html code for a timeline with events of executed cronjobs
         * 
         * TODO:
         * for rendering of several hosts ... see grouping in
         * http://visjs.org/examples/timeline/groups/groupsOrdering.html
         * 
         * @param array  $aData   result of $oCL->getServerLogs()
         * @return string
         */
        public function renderJobGraph(array $aData = []): string
        {
            $sTaskId = __FUNCTION__ . '-' . $this->_sActiveServer;
            $sHtml = $this->_getCacheData($sTaskId);
            if ($sHtml) {
                return $sHtml;
            }
            $sHtml = '';
            static $iGraphCounter;
            if (!isset($iGraphCounter)) {
                $iGraphCounter = 0;
            }
            $iGraphCounter++;
            if (!count($aData)) {
                $aData = $this->getServerJobHistory(false);
            }
            $sDivId = 'vis-timeline-' . $iGraphCounter;
    
            $aDataset = [];
            $iLast = false;
            $iEntry = 0;
            foreach ($aData as $aEntry) {
                if ($aEntry['exectime'] > $this->_iMinTime4Timeline) {
                    $iEntry++;
                    $iLast = max([$iLast, date("U", $aEntry['start'])]);
                    $aDataset[] = [
                        'id' => $iEntry,
                        /*
                        'start'=>(int)date("U", $aEntry['start']),
                        'end'=>(int)date("U", $aEntry['end']),
                         * 
                        'start'=>'Date'.date('Y-m-d\TH:i:s.000\Z', $aEntry['start']),
                        'end'=>'Date'.date('Y-m-d\TH:i:s.000\Z', $aEntry['end']),
                         * 
                        'start'=>date('Y-m-d\TH:i:s.000\Z', $aEntry['start']),
                        'end'=>date('Y-m-d\TH:i:s.000\Z', $aEntry['end']),
                         * 
                         */
                        'start' => (int) date("U", $aEntry['start']) * 1000,
                        'end' => (int) date("U", $aEntry['end']) * 1000,
                        'content' => $aEntry['job'] . '@' . $aEntry['host'],
                        'className' => 'timeline-result-' . ($aEntry['rc'] ? 'error' : 'ok'),
                        'title' => '<strong>' . $aEntry['job'] . '@' . $aEntry['host'] . '</strong><br>'
                            . 'start: ' . date("Y-m-d H:i:s", $aEntry['start']) . '<br>'
                            . 'end: ' . date("Y-m-d H:i:s", $aEntry['end']) . '<br>'
                            . 'exectime: '
                            . $aEntry['exectime'] . 's'
                            . ($aEntry['exectime'] > 100 ? ' (' . round($aEntry['exectime'] / 60) . 'min)' : '')
                            . '<br>'
                            . 'rc = ' . $aEntry['rc'] . '<br>'
                        ,
                    ];
                    // if($iEntry>=265){break;}
                }
            }
    
            $sHtml .= '
            
            <!-- START ' . __METHOD__ . ' -->
    
            '
                . '<h3>' . $this->t('timeline-head') . '</h3>'
                . '<p class="hint">'
                . sprintf($this->t('timeline-hint'), $this->_iMinTime4Timeline)
                . '</p>
            <p>'
                . sprintf($this->t('graph-rendered-jobs'), $this->_iMinTime4Timeline) . ': <strong>' . count($aDataset) . '</strong> '
                . '(' . $this->t('total') . ': ' . count($aData) . ')<br><br>'
                . (count($aDataset) ? $this->_renderAccessAndAge($iLast) : '')
                . '</p>'
                . (count($aDataset) ?
                    '<div id="' . $sDivId . '"></div>
    
                <script type="text/javascript">
                  // DOM element where the Timeline will be attached
                  var container = document.getElementById("' . $sDivId . '");
    
                  // Create a DataSet (allows two way data-binding)
                  var items = new vis.DataSet(' . json_encode($aDataset) . ');
    
                  // Configuration for the Timeline
                  var options = {
                        zoomMin: 1000 * 60 * 60,           // an hour
                        zoomMax: 1000 * 60 * 60 * 24 * 14  // 2 weeks
                  };
    
                  // Create a Timeline
                  var timeline = new vis.Timeline(container, items, options);
    
                  // set focus to newest jobs
                  // timeline.moveTo("'.date("Y-m-d H:i:s", time()-60*60*24*4).'");
                  
                  // fix: some timelines do not properly work ... but I make them visible
                  $(\'#' . $sDivId . ' .vis-timeline\').css(\'visibility\', \'visible\');
                </script>
                '
                    : $this->t('graph-no-data') . '<br>'
                )
                . '
    
            <!-- ENDE ' . __METHOD__ . '-->
    
            ';
            $this->_writeCacheData($sTaskId, $sHtml);
    
            return ''
                . $sHtml
                // . strlen($sHtml).' byte - ' . '<pre>'.htmlentities($sHtml).'</pre><br>'
            ;
        }
    
        /**
         * Get html code for the job graph of all servers
         * @return string
         */
        public function renderJobGraphOfAllServers(): string
        {
            $aData = [];
            foreach (array_keys($this->getServers()) as $sServer) {
                $this->setServer($sServer);
                $aData = array_merge($aData, $this->getServerJobHistory());
            }
            $this->setServer('ALL');
            return $this->renderJobGraph($aData);
        }
    
        /**
         * generate an array of javascript lang texts
         * used in config/page-replacements.php
         * @return string
         */
        public function renderJSLang(): string
        {
            $aReturn = [];
    
            foreach ($this->_aLang as $sKey => $sText) {
                if (preg_match('/^JS_/', $sKey)) {
                    $aReturn[preg_replace('/^JS_/', '', $sKey)] = $sText;
                }
            }
            return "\n"
                . '// generated by ' . __METHOD__ . "\n"
                . 'var aLang=' . json_encode($aReturn, JSON_PRETTY_PRINT) . '; ' . "\n"
            ;
        }
    
        /**
         * Get html code to show a single log file with syntax highligt an marking the reeturn code
         * 
         * @param string $sLogfile  logfile; [server]/[filename.log]
         * @return string
         */
        public function renderLogfile(string $sLogfile): string
        {
            $sHtml = ''
                . '<button style="position: fixed;" onclick="closeOverlay();" class="btn btn-default"><i class="fas fa-chevron-left"></i> ' . $this->t('back') . '</button><br><br>'
                . '<h3>' . $this->t('logfile') . ' ' . basename($sLogfile) . '</h3>'
            ;
            if (!$sLogfile) {
                return $sHtml . $this->t('error-nologfile');
            }
            if (strstr($sLogfile, '..')) {
                return $sHtml . $this->t('error-dots-not-allowed');
            }
            // $sMyFile=$this->_getServerlogDir().'/'.$sLogfile;
            $sMyFile = $this->_sDataDir . '/' . $sLogfile;
            if (!file_exists($sMyFile)) {
                return $sHtml . sprintf($this->t('error-logfile-not-found'), $sMyFile);
            }
    
            if ($fileHandle = fopen($sMyFile, "r")) {
                $sHtml .= '<div style="float: left;"><pre>';
                while (($line = fgets($fileHandle)) !== false) {
                    # do same stuff with the $line
                    $bIsComment = strstr($line, 'REM ');
                    if ($bIsComment) {
                        $sHtml .= '<div class="log-rem">' . $line . '</div>';
                    } else {
                        $sKey = trim(preg_replace('/=.*/', '', $line));
                        $sValue = preg_replace('/^([A-Z]*\=)/', '', $line);
                        $sDivClass = '';
                        switch ($sKey) {
                            case 'SCRIPTRC':
                                $sDivClass = (int) $sValue === 0 ? 'message-ok' : 'message-error';
                                break;
                            case 'JOBEXPIRE':
                                $sDivClass = date('U') < (int) $sValue ? 'message-ok' : 'message-error';
                                break;
                        }
                        $sValue = preg_replace('/(rc=0)/', '<span class="message-ok">$1</span>', $sValue);
                        $sValue = preg_replace('/(rc=[1-9][0-9]*)/', '<span class="message-error">$1</span>', $sValue);
                        $sValue = preg_replace('/(rc=[1-9])/', '<span class="message-error">$1</span>', $sValue);
                        // remove terminal color
                        $sValue = preg_replace('/(\[[0-9]{1,3}m)/', '', $sValue);
    
                        $sHtml .= '<div' . ($sDivClass ? ' class="' . $sDivClass . '"' : '')
                            // . ' title="'.$sKey.'='.$sValue.'" '
                            . '><span class="log-var">' . $sKey . '</span>=<span class="log-value">' . $sValue . '</span></div>';
                    }
                }
                $sHtml .= '</pre></div>';
            }
            return $sHtml;
        }
    
        /**
         * Get html code for a select box with all servers
         * @return string
         */
        public function renderServerlist(string $sSelectedItem = ''): string
        {
            $sHtml = '';
            $iMaxItemsToShow = 30;
            $sHtml .= '<option value="ALL"'
                . ($sSelectedItem === '' || $sSelectedItem === 'ALL' ? ' selected="selected"' : '')
                . '>[' . $this->t('ALL') . ' (' . count($this->getServers()) . ')]</option>';
            foreach ($this->getServers() as $sServer => $aData) {
                $sHtml .= '<option value="' . $sServer . '"'
                    . ($sSelectedItem === $sServer ? ' selected="selected"' : '')
                    . '>' . $sServer . '</option>';
            }
            $sHtml = $sHtml
                ? ''
                /*
                .'<input id="serverfiltertext" type="text" placeholder="filter server" value=""
                onchange="filterServers();"
                onkeypress="filterServers();"
                onkeyup="filterServers();"
                ><button onclick="$(\'#serverfiltertext\').val(\'\'); filterServers();">X</button><br><br>'
                */
                . '<select'
                . ' size="' . (min([count($this->getServers()) + 1, $iMaxItemsToShow])) . '"'
                // . ' size="1"'
                . ' onchange="setServer(this.value); return false;"'
                . '>' . $sHtml . '</select>'
                : false;
            return $sHtml;
        }
    
    }