Skip to content
Snippets Groups Projects
cronlog-renderer.class.php 30.88 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 = isset($_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;
    }

}