From 4011ff8de070b5adc8fa8110073398655889f3de Mon Sep 17 00:00:00 2001
From: hahn <axel.hahn@iml.unibe.ch>
Date: Tue, 20 Sep 2022 18:47:20 +0200
Subject: [PATCH] enable shell plugins (WIP)

---
 .../deployment/classes/plugins.class.php      | 200 +++++----
 .../classes/plugins_renderer.class.php        | 119 +++++
 public_html/deployment/inc_functions.php      |   6 +-
 public_html/deployment/index.php              |  69 ++-
 public_html/deployment/js/addi.js             | 410 ++++++++++++++++++
 public_html/deployment/js/functions.js        |  11 +
 public_html/deployment/js/ubd.class.js        | 209 +++++++++
 public_html/deployment/main.css               |  33 +-
 .../deployment/plugins/build/tgz/info.json    |   1 +
 .../deployment/plugins/build/zip/info.json    |   1 +
 .../deployment/plugins/doc_reminder.md        |  55 +++
 .../deployment/plugins/shellcmd/getdata.php   | 134 +-----
 .../plugins/shellcmd/load/config.json         |   7 +
 .../plugins/shellcmd/load/info.json           |  11 +
 .../plugins/shellcmd/load/plugin.php          |  14 +-
 .../plugins/shellcmd/load/render.js           |  43 +-
 .../shellcmd/plugins_shellcmd.class.php       |  11 +-
 .../plugins/shellcmd/processes/command.php    |   0
 .../plugins/shellcmd/processes/config.json    |   7 +
 .../plugins/shellcmd/processes/info.json      |  11 +
 .../plugins/shellcmd/processes/plugin.php     |  73 ++++
 .../plugins/shellcmd/processes/render.js      |  42 ++
 22 files changed, 1243 insertions(+), 224 deletions(-)
 create mode 100644 public_html/deployment/classes/plugins_renderer.class.php
 create mode 100644 public_html/deployment/js/addi.js
 create mode 100644 public_html/deployment/js/ubd.class.js
 create mode 100644 public_html/deployment/plugins/doc_reminder.md
 create mode 100644 public_html/deployment/plugins/shellcmd/load/config.json
 create mode 100644 public_html/deployment/plugins/shellcmd/load/info.json
 delete mode 100644 public_html/deployment/plugins/shellcmd/processes/command.php
 create mode 100644 public_html/deployment/plugins/shellcmd/processes/config.json
 create mode 100644 public_html/deployment/plugins/shellcmd/processes/info.json
 create mode 100644 public_html/deployment/plugins/shellcmd/processes/plugin.php
 create mode 100644 public_html/deployment/plugins/shellcmd/processes/render.js

diff --git a/public_html/deployment/classes/plugins.class.php b/public_html/deployment/classes/plugins.class.php
index 26c5869d..1a52f30c 100644
--- a/public_html/deployment/classes/plugins.class.php
+++ b/public_html/deployment/classes/plugins.class.php
@@ -13,29 +13,61 @@
  *        // print_r($CI_plugins->getPlugins());
  *        print_r($CI_plugins->getPlugins('build'));
  * 
- *        $CI_plugins->setPlugin('tgz', 'build');
+ *        $CI_plugins->setPlugin('tgz', 'build'); // plugin name + type
  *
  * 
  * @author axel
  */
 class ciplugins {
     
+    /**
+     * start path of all plugin types (as subdirs)
+     * @var string
+     */
     protected $_sPlugindir=false;
 
     /**
+     * path of the currently set plugin
+     * @var string
+     */
+    protected $_sSelfdir=false;
+
+    /**
+     * url of set plugin
+     * @var string
+     */
+    protected $_sSelfurl=false;
+
+    /**
+     * current plugin type - can be set via setType or setPlugin
      * @var string
      */
     protected $_sType=false;
 
     /**
+     * current plugin name - can be set via setPlugin
      * @var string
      */
     protected $_sPluginname=false;
 
-
+    /**
+     * plugin language
+     * @var string
+     */
     protected $_sLang = "en-en";
+
+    /**
+     * plugin language texts (lang*.json)
+     * @var array
+     */
     protected $_aLang = [];
 
+    /**
+     * plugin configuration data (config.json)
+     * @var array
+     */
+    protected $_aConfig = [];
+
 
     // ---------------------------------------------------------------
     // CONSTRUCTOR
@@ -51,43 +83,46 @@ class ciplugins {
 
         return true;
     }
-    
+
     // ---------------------------------------------------------------
-    // LANGUAGE TEXTS
+    // FOR LISTING :: GETTER
     // ---------------------------------------------------------------
-    
+
     /**
-     * get a translated text from lang_XX.json in plugin dir;
-     * If the key is missed it returns "[KEY :: LANG]"
-     * 
-     * @see setLang()
-     * @param string $sKey  key to find in lang file
-     * @return string
+     * get an array of available plugin types read from filesystem
+     * @return array
      */
-    protected function _t($sKey){
-        return (isset($this->_aLang[$sKey]) && $this->_aLang[$sKey])
-                ? $this->_aLang[$sKey]
-                : "[ $sKey :: $this->_sLang ]"
-        ;
+    public function getPluginTypes(){
+        $aReturn=[];
+        foreach(glob($this->_sPlugindir.'/*', GLOB_ONLYDIR) as $sMydir){
+            $aReturn[]=basename($sMydir);
+        }
+        return $aReturn;
     }
 
     /**
-     * set language for output of formdata and other texts.
-     * This method loads the language file into a hash. The output of 
-     * translated texts can be done with $this->_t("your_key")
-     * 
-     * @see _t()
-     * @param string   $sLang  language code, i.e. "de"
-     * @return boolean
+     * get an array of available plugins read from filesystem
+     * @return array
      */
-    public function setLang($sLang=false){
-        $this->_sLang=$sLang ? $sLang : $this->_sLang;
-        
-        $oReflection=new ReflectionClass($this);
-        $sFile=dirname($oReflection->getFileName()) . '/lang_'.$this->_sLang.'.json';
-        $this->_aLang=(file_exists($sFile)) ? json_decode(file_get_contents($sFile), 1) : $this->_aLang;
-        return true;
+    public function getPlugins($sType=false){
+        $aReturn=[];
+        if($sType){
+            if (!$this->setType($sType)){
+                return $aReturn;
+            }
+        }
+        foreach(glob($this->_sPlugindir.'/'.$this->_sType.'/*', GLOB_ONLYDIR) as $sMydir){
+            $aReturn[]=basename($sMydir);
+        }
+        return $aReturn;
     }
+
+    // ---------------------------------------------------------------
+    // 
+    // BELOW ARE METHODS FOR A SET SPECIFIC PLUGIN AND TYPE
+    // 
+    // ---------------------------------------------------------------
+
     // ---------------------------------------------------------------
     // SETTER
     // ---------------------------------------------------------------
@@ -97,6 +132,7 @@ class ciplugins {
      * @param  {string}  $sType  Name of a plugin type, e.g. build|rollout
      */
     public function setType($sType){
+        $this->_sType=false;
         if(!$sType || !is_dir($this->_sPlugindir.'/'.$sType)){
             return false;
         }
@@ -104,74 +140,88 @@ class ciplugins {
     }
 
     /**
-     * set a plugin with autoload 
+     * reset vars before setting a new plugin;
+     * called in testPlugin()
+     * @return boolean
+     */
+    protected function _resetPluginData(){
+        $this->_sPluginname=false;
+        $this->_sSelfdir=false;
+        $this->_sSelfurl=false;
+        $this->_aLang=[];
+        $this->_aConfig=[];
+        return true;
+    }
+
+    /**
+     * set a plugin without autoload of its php class
+     * It returns the path of php class for true 
+     * or boolean false if it does not exist
+     * 
+     * This can be used standalone to embed html code 
+     * without loading any php code of the plugin class.
      * 
      * @param  {string}  $sPluginName  name of the plugin
-     * @param  {string}  $sType        optuional: set a type
-     * @return bool
+     * @param  {string}  $sType        optional: set a type
+     * @return bool|string
      */
-    public function setPlugin($sPluginName,$sType=false){
+    public function testPlugin($sPluginName,$sType=false){
+        $this->_resetPluginData();
         if($sType){
             if (!$this->setType($sType)){
                 return false;
             }
         }
-        $sFile=$this->getPluginFilename($sPluginName);
+        $this->_sSelfdir=$this->_sPlugindir.'/'.$this->_sType.'/'.$sPluginName;
+        $sFile=$this->_sSelfdir.'/plugin.php';
         if(!file_exists($sFile)){
+            // die(' MISS '.$sFile);
+            $this->_sSelfdir=false;
             return false;
         }
-        include_once $sFile;
-        return $this->_sPluginname=$sPluginName;
+        $this->_sPluginname=$sPluginName;
+        $this->_sSelfurl='/deployment/plugins/'.$this->_sType.'/'.$sPluginName;
+        return $sFile;
     }
 
-    // ---------------------------------------------------------------
-    // GETTER
-    // ---------------------------------------------------------------
-
-
     /**
-     * get a location of a plugin file with full path
-     * The type must be initialized first with setType()
+     * set a plugin with autoload of its php class
+     * It returns a boolean
      * 
-     * @param string  $sPluginName   optional: Name of plugin
-     * @return string
-     */
-    public function getPluginFilename($sPluginName=false){
-        if(!$sPluginName){
-            $sPluginName=$this->_sPluginname;
-        }
-        return $this->_sPlugindir.'/'.$this->_sType.'/'.$sPluginName.'/'.$this->_sType.'_'.$sPluginName.'.php';
-    }
-
-
-    /**
-     * get an array of available plugin types read from filesystem
-     * @return array
+     * @param  {string}  $sPluginName  name of the plugin
+     * @param  {string}  $sType        optional: set a type
+     * @return bool
      */
-    public function getPluginTypes(){
-        $aReturn=[];
-        foreach(glob($this->_sPlugindir.'/*', GLOB_ONLYDIR) as $sMydir){
-            $aReturn[]=basename($sMydir);
+    public function setPlugin($sPluginName,$sType=false){
+        $sFile=$this->testPlugin($sPluginName,$sType);
+        if(!$sFile){
+            return false;
         }
-        return $aReturn;
+        include_once $sFile;
+        return true;
     }
-
+    // ---------------------------------------------------------------
+    // getter for plugin
+    // ---------------------------------------------------------------
     /**
-     * get an array of available plugins read from filesystem
+     * get plugin config from its config.json
+     * works with
+     *   - shellcmd plugin
      * @return array
      */
-    public function getPlugins($sType=false){
-        $aReturn=[];
-        if($sType){
-            if (!$this->setType($sType)){
-                return $aReturn;
-            }
+    public function getPluginConfig(){
+        if(count($this->_aConfig)){
+            return $this->_aConfig;
         }
-        foreach(glob($this->_sPlugindir.'/'.$this->_sType.'/*', GLOB_ONLYDIR) as $sMydir){
-            $aReturn[]=basename($sMydir);
-        }
-        return $aReturn;
+        $this->_aConfig=(file_exists($this->_sSelfdir.'/config.json'))
+            ? json_decode(file_get_contents($this->_sSelfdir.'/config.json'), 1)
+            : ["error" => "config.json not found in ".$this->_sSelfdir]
+        ;
+        return $this->_aConfig;
     }
+    // ---------------------------------------------------------------
+    // access plugin php class
+    // ---------------------------------------------------------------
 
     /**
      * get a location of a plugin file with full path
diff --git a/public_html/deployment/classes/plugins_renderer.class.php b/public_html/deployment/classes/plugins_renderer.class.php
new file mode 100644
index 00000000..288452c9
--- /dev/null
+++ b/public_html/deployment/classes/plugins_renderer.class.php
@@ -0,0 +1,119 @@
+<?php
+require_once('plugins.class.php');
+/**
+ * WIP
+ * base class for all plugin types to read available plugins
+ * and its metadata
+ * 
+ * @example
+ *        $CI_plugins=new ciplugins();
+ *        print_r($CI_plugins->getPluginTypes());
+ * 
+ *        // $CI_plugins->setType('build');
+ *        // print_r($CI_plugins->getPlugins());
+ *        print_r($CI_plugins->getPlugins('build'));
+ * 
+ *        $CI_plugins->setPlugin('tgz', 'build'); // plugin name + type
+ *
+ * 
+ * @author axel
+ */
+class plugin_renderer extends ciplugins {
+    
+    // ---------------------------------------------------------------
+    // 
+    // BELOW ARE METHODS FOR A SET SPECIFIC PLUGIN AND TYPE
+    // 
+    // ---------------------------------------------------------------
+
+
+    // ---------------------------------------------------------------
+    // LANGUAGE TEXTS (needed in ui only)
+    // ---------------------------------------------------------------
+    
+    /**
+     * get a translated text from lang_XX.json in plugin dir;
+     * If the key is missed it returns "[KEY :: LANG]"
+     * 
+     * @see setLang()
+     * @param string $sKey  key to find in lang file
+     * @return string
+     */
+    protected function _t($sKey){
+        return (isset($this->_aLang[$sKey]) && $this->_aLang[$sKey])
+                ? $this->_aLang[$sKey]
+                : "[ $sKey :: $this->_sLang ]"
+        ;
+    }
+
+    /**
+     * set language for output of formdata and other texts.
+     * This method loads the language file into a hash. The output of 
+     * translated texts can be done with $this->_t("your_key")
+     * 
+     * @see _t()
+     * @param string   $sLang  language code, i.e. "de"
+     * @return boolean
+     */
+    public function setLang($sLang=false){
+        $this->_sLang=$sLang ? $sLang : $this->_sLang;
+        
+        $oReflection=new ReflectionClass($this);
+        $sFile=dirname($oReflection->getFileName()) . '/lang_'.$this->_sLang.'.json';
+        $this->_aLang=(file_exists($sFile)) ? json_decode(file_get_contents($sFile), 1) : $this->_aLang;
+        return true;
+    }
+    // ---------------------------------------------------------------
+    // SETTER
+    // ---------------------------------------------------------------
+
+
+    // ---------------------------------------------------------------
+    // prepare html code
+    // ---------------------------------------------------------------
+
+    /**
+     * for shellcmd plugins: get html code to load javascript file
+     * The file must exist in the plugin directory
+     * @params  string  $sFile  (basename of) filename, eg. render.js
+     */
+    public function getHtmlLoadScript($sFile){
+        return (file_exists($this->_sSelfdir.'/'.$sFile))
+            ? '<script src="'.$this->_sSelfurl.'/'.$sFile.'"></script>'."\n"
+            : ''
+        ;
+    }
+
+    /**
+     * get id for an output div
+     * @return string
+     */
+    public function getHtmlOutId(){
+        return $this->_sPluginname ? 'divPlugin'.$this->_sType.''.$this->_sPluginname : false;
+    }
+    /**
+     * get id for the wrapper div of an output div
+     * @return string
+     */
+    public function getHtmlOutIdWrapper(){
+        return $this->getHtmlOutId().'Wrapper';
+    }
+    public function getHtmlOutwindow(){
+        $aConfig=$this->getPluginConfig();
+        return '<div id="'.$this->getHtmlOutIdWrapper().'" class="cmdoutbox draggable draggable-onpage">'
+        // .'<button class="btn-close float-right">X</button>'
+        .'<div class="header"><i class="'.$aConfig['icon'].'"></i> '.$this->_sPluginname.'</div>'
+        .'<div id="'.$this->getHtmlOutId().'" '
+        .'class="out'
+            .(isset($aConfig['window-cols'])  && $aConfig['window-cols']  ? ' cmd-cols-'.$aConfig['window-cols'] : ''   )
+            .(isset($aConfig['window-lines']) && $aConfig['window-lines'] ? ' cmd-lines-'.$aConfig['window-lines'] : '' )
+            .'"></div>'
+        .'</div>';
+    }
+
+    // ---------------------------------------------------------------
+    // access plugin php class
+    // ---------------------------------------------------------------
+
+
+}
diff --git a/public_html/deployment/inc_functions.php b/public_html/deployment/inc_functions.php
index aa27cdd8..3ec77ca9 100644
--- a/public_html/deployment/inc_functions.php
+++ b/public_html/deployment/inc_functions.php
@@ -196,7 +196,7 @@ function aGotop($sClass = "scroll-link btn btn-default") {
  * @global type $aParams
  * @return type
  */
-function getTopArea() {
+function getTopArea($aEmbed=[]) {
     global $aParams, $oHtml;
     $sReturn = '';
     require_once("./classes/project.class.php");
@@ -391,6 +391,10 @@ function getTopArea() {
               </ul>
             
             <ul class="nav navbar-nav navbar-right">'
+                .(isset($aEmbed['right'])
+                    ? $aEmbed['right']
+                    : ''
+                )
                 . ($oUser->getUsername() 
                     ? '
                     <!-- userdata -->
diff --git a/public_html/deployment/index.php b/public_html/deployment/index.php
index c9a84309..c6d7c2f1 100644
--- a/public_html/deployment/index.php
+++ b/public_html/deployment/index.php
@@ -23,6 +23,7 @@ ini_set('display_startup_errors', 1);
 error_reporting(E_ALL);
 
 require_once("./classes/page.class.php");
+require_once("./classes/plugins_renderer.class.php");
 
 // detect first run
 $bFirstRun=!file_exists("../../config/config_custom.php") || !file_exists("../../config/inc_user2roles.php");
@@ -40,7 +41,9 @@ $oHtml=new htmlguielements();
 $sPrj = "";
 $sAction = "overview";
 
-// ------ check parameters
+// ----------------------------------------------------------------------
+// check params
+// ----------------------------------------------------------------------
 
 if (array_key_exists("prj", $aParams)) {
     $sPrj = $aParams["prj"];
@@ -60,15 +63,55 @@ if($bFirstRun){
     $sAction='installer';
 }
     
-// ------ Ausgabe
-$sHeader = '<style>';
+// ----------------------------------------------------------------------
+// html header
+// ----------------------------------------------------------------------
+
+$sHeader = "\n<!-- generated CSS for phases -->\n<style>\n";
 foreach ($aConfig["phases"] as $sPhase => $aData) {
     $sHeader.=array_key_exists("bgdark", $aData["css"]) ? 'th.' . $sPhase . '{' . $aData["css"]["bgdark"] . '}' : '';
     $sHeader.=array_key_exists("bglight", $aData["css"]) ? 'td.' . $sPhase . ', div.' . $sPhase . '{' . $aData["css"]["bglight"] . '}' : '';
     $sHeader.=array_key_exists("bgbutton", $aData["css"]) ? 'a.' . $sPhase . ',a.' . $sPhase . ':hover,button.' . $sPhase . ',button.' . $sPhase . ':hover{' . $aData["css"]["bgbutton"] . '}' : '';
 }
-$sHeader.='</style>';
-$sTopArea=getTopArea();
+$sHeader.="</style>\n";
+
+// add shellcmd files
+$sShellOuptut='';
+$sTopRight='';
+
+$CI_plugins=new plugin_renderer();
+$CI_plugins->setType('shellcmd');
+$aPluginsShellcmd=$CI_plugins->getPlugins();
+$sHeader = "\n<!-- shellcmd plugins :: js files -->\n";
+$sHeader.='<script src="/deployment/js/ubd.class.js"></script>'."\n"
+    .'<script src="/deployment/js/addi.js"></script>'."\n"
+    ;
+foreach ($CI_plugins->getPlugins('shellcmd') as $sPlugin){
+    if ($CI_plugins->testPlugin($sPlugin)){
+        $aPluginConfig=$CI_plugins->getPluginConfig();
+        $sHeader.=$CI_plugins->getHtmlLoadScript('render.js');
+        $sShellOuptut.=$CI_plugins->getHtmlOutwindow();
+        $sTopRight.=''
+            .'<li >'
+            .$oHtml->getLink(array(
+                'href'=>'#',
+                // 'onclick'=>'$(\'#'.$CI_plugins->getHtmlOutIdWrapper().'\').slideToggle(100);',
+                'onclick'=>'toggleShellWindow(\''.$CI_plugins->getHtmlOutIdWrapper().'\', this);',
+                'role'=>'button',
+                'aria-expanded'=>'false',
+                'icon'=> (isset($aPluginConfig['icon']) ? $aPluginConfig['icon'] : ''),
+                'label'=>$sPlugin,
+            ))
+            .'</li>'
+        ;
+    }
+}
+
+// ----------------------------------------------------------------------
+// html body
+// ----------------------------------------------------------------------
+
+$sTopArea=getTopArea(['right'=>$sTopRight]);
 $sBanner=isset($aConfig['banner']) && $aConfig['banner'] ? '<div class="alert alert-info">'.$aConfig['banner'].'</div>' : '';
 $sTopAction=getAction();
 
@@ -109,11 +152,18 @@ if ($oUser->hasPermission('page_'.$sAction)){
     // return false;
 }
 
+// ----------------------------------------------------------------------
+// render page
+// ----------------------------------------------------------------------
+
 $oCLog->add("Finally: rendering page ...");
 
 $sPhpOut = '
     <br>
-    ' . $sTopArea  .'
+    ' 
+    . $sTopArea  
+    . $sShellOuptut
+    .'
     <div id="content">
         ' . $sBanner . $sTopAction . '
         ' . $sPhpOut . '
@@ -122,11 +172,8 @@ $sPhpOut = '
         '.t("menu-brand").' &copy; 2013-' . date("Y") . ' <a href="https://git-repo.iml.unibe.ch/iml-open-source/imldeployment/" target="_blank">Institut f&uuml;r Medizinische Lehre; Universit&auml;t Bern</a>
     </div>
 
-    <!--
-    <script src="/deployment/plugins/shellcmd/load/render.js" />
-    -->
-
-    '.$oCLog->render();
+    '
+    .$oCLog->render();
 
 $oPage = new Page();
 $oPage->addResponseHeader("Pragma: no-cache");
diff --git a/public_html/deployment/js/addi.js b/public_html/deployment/js/addi.js
new file mode 100644
index 00000000..102226a0
--- /dev/null
+++ b/public_html/deployment/js/addi.js
@@ -0,0 +1,410 @@
+/**
+ *  
+ * ADDI = Axels Drag and drop implementation
+ * 
+ *   create draggable divs on screen if they have a defined class
+ *   (named "draggable" by default - but you can use any other name)
+ * 
+ * @author    Axel Hahn
+ * @version   1.0
+ *
+ * @this addi
+ * 
+ * @example
+ * <pre>
+ * // make all divs with class "draggable" be movable on screen<br>
+ * addi.init();
+ * </pre>
+ * 
+ * @constructor
+ * @return nothing
+ */
+ var addi = function(){
+
+    return {
+        _saveData: [],
+        _dragClass: 'draggable',
+        _draggingClass: 'isdragging',
+
+        // last z-index value of last activated div 
+        _addi_zIndex: 100,
+        
+         // fixed rectangle earea whe a div can be moved
+         oFence: {
+            bFullscreen: true,
+            top: 0,
+            left: 0,
+            width: window.innerWidth,
+            height: window.innerHeight
+        },
+        
+        // override existing style values while moving the div
+        _savstyles:{
+            transition: 'auto'
+        },
+        
+        /**
+         * detect all draggable objects on a page and init each
+         * 
+         * @see initDiv()
+         * @param {string} sClass  optional: class of draggable elements; default: "draggable"
+         * @returns {undefined}
+         */
+        init(sClass){
+            if(sClass){
+                this._dragClass=sClass;
+            }
+            // scan all elements with class draggable and make them movable
+            var oList = document.getElementsByClassName(this._dragClass);
+            if(oList && oList.length){
+                for (var i = 0; i < oList.length; i++) {
+                    this.initDiv(oList[i], false);
+                }
+            }
+        },
+        // ------------------------------------------------------------
+        // private functions
+        // ------------------------------------------------------------
+        /**
+         * get a top left position {xpos, ypos} to fix current position and 
+         * make a div fully visible
+         * @see move()
+         * 
+         * @private
+         * @param {object} oDiv2Drag  movable object
+         * @param {type} xpos
+         * @param {type} ypos
+         * @returns {object}
+         */
+        _fixVisiblePosition(oDiv2Drag,xpos,ypos){
+            
+            this._updateFence(oDiv2Drag.style.paddingLeft);
+            var aStyles = window.getComputedStyle(oDiv2Drag);
+
+            var divDeltaX=0
+                + parseInt(aStyles.borderLeftWidth)
+                + parseInt(aStyles.borderRightWidth)
+                + parseInt(aStyles.marginLeft)
+                + parseInt(aStyles.marginRight)
+                + parseInt(aStyles.paddingLeft)
+                + parseInt(aStyles.paddingRight)
+                - (window.innerWidth - document.documentElement.clientWidth) // scrollbar
+                ;
+            var divDeltaY=0
+                + parseInt(aStyles.borderTopWidth)
+                + parseInt(aStyles.borderBottomWidth)
+                + parseInt(aStyles.marginTop)
+                + parseInt(aStyles.marginBottom)
+                // + parseInt(aStyles.paddingTop)
+                //+ parseInt(aStyles.paddingBottom)
+                ;
+
+            xpos=Math.max(this.oFence.left,xpos);
+            xpos=Math.min(this.oFence.left+this.oFence.width-oDiv2Drag.clientWidth-divDeltaX,xpos);
+
+            ypos=Math.max(this.oFence.top,ypos);
+            if (aStyles.position==='fixed') {                
+                ypos=Math.min(this.oFence.top+this.oFence.height-oDiv2Drag.clientHeight-divDeltaY,ypos);
+            }
+            
+            return {
+                xpos: xpos,
+                ypos: ypos
+            };
+            
+        },
+        /**
+         * helper: get a varname for localstorage to save / get last position
+         * 
+         * @private
+         * @param {string} s   id of movable element
+         * @returns {String}
+         */
+        _getVarname(s){
+            return 'addi.saveddiv-'+s;
+        },
+        /**
+         * save position of the given dom object
+         * 
+         * @private
+         * @param {object} oDiv2Drag  movable object
+         * @returns {undefined}
+         */
+         _save(oDiv2Drag){
+            aData={
+                left: oDiv2Drag.style.left.replace('px',''),
+                top:  oDiv2Drag.style.top.replace('px','')
+            };
+            localStorage.setItem(this._getVarname(oDiv2Drag.id),JSON.stringify(aData));
+        },
+
+        /**
+         * helper: save attributes in a variable: transition
+         * it is used for dragging: a transition slows down all movements
+         * @see _styleRestore()
+         * 
+         * @private
+         * @param {object} oDiv2Drag  movable object
+         * @returns {Boolean}
+         */
+        _styleSave(oDiv2Drag){
+            var aStyles = window.getComputedStyle(oDiv2Drag);
+            // create subitem for div id
+            if(this._saveData[oDiv2Drag.id] === undefined){
+                this._saveData[oDiv2Drag.id]=new Object();
+            };
+            for (var sAttr in this._savstyles){
+                // store value
+                this._saveData[oDiv2Drag.id][sAttr]=aStyles.getPropertyValue(sAttr);
+                // apply temp value
+                oDiv2Drag.style[sAttr]=this._savstyles[sAttr];
+            }
+            return true;
+        },
+        /**
+         * helper: retore attributes after moving
+         * @see _styleSave()
+         * @private
+         * @param {object} oDiv2Drag  movable object
+         * @returns {Boolean}
+         */
+        _styleRestore(oDiv2Drag){
+            for (var sAttr in this._savstyles){
+                oDiv2Drag.style[sAttr]=this._saveData[oDiv2Drag.id][sAttr];
+            }
+            return true;
+        },
+        /**
+         * helper: update size of this.oFence if it is set to fullscreen in 
+         * case of resizing of the browser window
+         * 
+         * @private
+         * @returns {undefined}
+         */
+        _updateFence(){
+            if(this.oFence.bFullscreen){
+                this.oFence={
+                    bFullscreen: true,
+                    top: 0,
+                    left: 0,
+                    width: window.innerWidth,
+                    height: window.innerHeight
+                };
+            };
+        },
+
+        /**
+         * generate an id for a draggable div that has no id yet
+         * 
+         * @private
+         * @returns {string}
+         */
+        _generateId(){
+            var sPrefix='generatedId-';
+            var iCount=1;
+            while (document.getElementById(sPrefix+iCount)){
+                iCount++;
+            }
+            return sPrefix+iCount;
+        },
+        
+        // ------------------------------------------------------------
+        // public functions
+        // ------------------------------------------------------------
+        
+        /**
+         * make a single div movable: 
+         * - add listener and 
+         * - restore last saved position (if class "saveposition" exists)
+         * 
+         * @param {object} oDiv2Drag   movable object
+         * @param {object} oDiv2Click  optional: clickable object
+         * @returns {undefined}
+         */
+        initDiv(oDiv2Drag,oDiv2Click){
+            var sDivId=oDiv2Drag.id;
+            if(!sDivId){
+                sDivId=this._generateId();
+                oDiv2Drag.id=sDivId;
+            }
+
+            // force position: fixed
+            var aStyles = oDiv2Drag.currentStyle || window.getComputedStyle(oDiv2Drag);
+
+            console.log(oDiv2Drag.id + ' position: ' + aStyles.position);
+            if(!aStyles.position || aStyles.position=='static'  /* || aStyles.position!='fixed' */){
+                // oDiv2Drag.style.position='fixed';
+            }
+
+            // add events
+            // using atributes instead of addEventListener
+            o=(oDiv2Click ? oDiv2Click : oDiv2Drag);
+            o.onmousedown = function (event) {
+                addi._isDragging=true;
+                addi.startMoving(document.getElementById(sDivId),event);
+            };
+            o.onmouseup = function () {
+                addi._isDragging=false;
+                addi.stopMoving(document.getElementById(sDivId));
+            };
+            
+            // restore last position
+            if (oDiv2Drag.className.indexOf('saveposition')!==false){
+                this.load(oDiv2Drag);
+            }
+        },
+        /**
+         * load stored position and apply it to given dom object
+         * 
+         * @param {object} oDiv2Drag  movable object
+         * @returns {Array|Object|Boolean}
+         */
+         load(oDiv2Drag){
+            var id=this._getVarname(oDiv2Drag.id);
+
+            // detect the highest z-index
+            var aStyles = oDiv2Drag.currentStyle || window.getComputedStyle(oDiv2Drag);
+            if(aStyles.zIndex && aStyles.zIndex>0){
+                this._addi_zIndex=Math.max(parseInt(aStyles.zIndex), this._addi_zIndex);
+            }
+            oDiv2Drag.style.zIndex=this._addi_zIndex++;
+
+            var aData=localStorage.getItem(id) ? JSON.parse(localStorage.getItem(id)) : false;
+            if(aData && aData.left && aData.top){
+                this.move(oDiv2Drag,aData.left,aData.top, true);
+            }
+            return aData;
+        },
+        /**
+         * move obj to new position and store position in localstorage
+         * called from startMoving() and load()
+         * 
+         * @param {object} oDiv2Drag  movable object
+         * @param {integer} xpos      position x
+         * @param {integer} ypos      position y
+         * @param {boolean} bNoFix    flag: skip fixing the position based on window size (is set to true in the load method)
+         * @returns {undefined}
+         */
+        move(oDiv2Drag,xpos,ypos, bNoFix){
+            oDiv2Drag.style.bottom = 'auto';
+            
+            var aPos=bNoFix ? { 'xpos':xpos, 'ypos':ypos } : this._fixVisiblePosition(oDiv2Drag,xpos,ypos);
+            oDiv2Drag.style.left = aPos['xpos'] + 'px';
+            oDiv2Drag.style.top = aPos['ypos'] + 'px';            
+            this._save(oDiv2Drag);
+            return true;
+        },
+
+        /**
+         * called by onmousedown event
+         * 
+         * @param {object} oDiv2Drag  movable object
+         * @param {object} evt        event
+         * @returns {undefined}
+         */
+        startMoving(oDiv2Drag,evt){
+            if (oDiv2Drag.className.indexOf(this._dragClass)===false){
+                return false;
+            }
+            
+            evt = evt || window.event;
+            var posX = evt.clientX,
+                posY = evt.clientY;
+
+            // save some styles
+            this._styleSave(oDiv2Drag);
+
+
+            if(oDiv2Drag.className.indexOf(this._draggingClass)<0){
+                oDiv2Drag.className+= ' '+this._draggingClass;
+            }
+
+            // for FF only:
+            // if (navigator.appCodeName==='Mozilla' && navigator.userAgent.indexOf('Firefox/')>0){
+            document.body.style.userSelect='none';
+
+            iDivWidth = parseInt(oDiv2Drag.style.width),
+            iDivHeight = parseInt(oDiv2Drag.style.height);
+
+            oDiv2Drag.style.cursor='move';
+            oDiv2Drag.style.zIndex=this._addi_zIndex++;
+            
+            iDivLeft = oDiv2Drag.style.left ? oDiv2Drag.style.left.replace('px','') : oDiv2Drag.offsetLeft;
+            iDivTop  = oDiv2Drag.style.top? oDiv2Drag.style.top.replace('px','')  : oDiv2Drag.offsetTop;
+            var diffX = posX - iDivLeft,
+                diffY = posY - iDivTop;
+            document.onmousemove = function(evt){
+                evt = evt || window.event;
+                var posX = evt.clientX,
+                    posY = evt.clientY,
+                    aX = posX - diffX,
+                    aY = posY - diffY;
+                addi.move(oDiv2Drag,aX,aY);
+            };
+            return true;
+        },
+        /**
+         * called on mouse up event
+         * 
+         * @param {object} oDiv2Drag  movable object
+         * @returns {undefined}
+         */
+        stopMoving(oDiv2Drag){
+            oDiv2Drag.style.cursor='default';
+            // retore styles
+            this._styleRestore(oDiv2Drag);
+            document.body.style.userSelect='auto';
+            oDiv2Drag.className=oDiv2Drag.className.replace(' '+this._draggingClass, '');
+            oDiv2Drag.className=oDiv2Drag.className.replace(this._draggingClass, '');
+            
+            document.onmousemove = function(){};
+        },
+        /**
+         * reset position 
+         * 
+         * @param {bool}   bRemoveLocalstorage  flag: remove saved local variable too
+         * @returns {undefined}
+         */
+         resetPos(){
+            var oList = document.getElementsByClassName(this._dragClass);
+            if(oList && oList.length){
+                for (var i = 0; i < oList.length; i++) {
+                    oList[i].style = '';
+                    this._save(oList[i]);
+                }
+            }
+        },
+        /**
+         * reset style, onmousedown, onmouseup to make divs unmovable again
+         * 
+         * @param {bool}   bRemoveLocalstorage  flag: remove saved local variable too
+         * @returns {undefined}
+         */
+        reset(bRemoveLocalstorage){
+            // scan all elements with class draggable and reset
+            var oList = document.getElementsByClassName(this._dragClass);
+            if(oList && oList.length){
+                for (var i = 0; i < oList.length; i++) {
+                    this._resetDiv(oList[i],bRemoveLocalstorage);
+                }
+            }
+        },
+        /**
+         * reset a single div and make it unmovable
+         * 
+         * @private
+         * @param {object} oDiv2Drag            movable object 
+         * @param {bool}   bRemoveLocalstorage  flag: remove saved local variable too
+         * @returns {undefined}
+         */
+        _resetDiv(oDiv2Drag, bRemoveLocalstorage){
+            oDiv2Drag.onmousemove = null;
+            oDiv2Drag.onmouseup = null;
+            oDiv2Drag.onmousedown = null;
+            oDiv2Drag.style = '';
+            if(bRemoveLocalstorage){
+                localStorage.removeItem(this._getVarname(oDiv2Drag.id));
+            }
+        }
+    };
+}();
diff --git a/public_html/deployment/js/functions.js b/public_html/deployment/js/functions.js
index e95d9e44..a653e7bb 100644
--- a/public_html/deployment/js/functions.js
+++ b/public_html/deployment/js/functions.js
@@ -42,12 +42,23 @@ function hideModalMessage(){
    return false;
 }
 
+/**
+ * shellcmd plugin ... toggle output window
+ * @param {string} idWrapperDiv  id of the wrapper
+ * @param {object} oLink         a tag in the navbar with link for toggling window 
+ */
+function toggleShellWindow(idWrapperDiv, oLink){
+    $('#'+idWrapperDiv).slideToggle(100);
+    $(oLink).parent().toggleClass('active');
+}
+
 // ----------------------------------------------------------------------
 // general init in each page
 // ----------------------------------------------------------------------
 
 $(document).ready(function() {
     initSoftscroll();
+    addi.init();
     // $(".optionName").popover({trigger: "hover"});
     // $("#content").hide().fadeIn(300);
 });
diff --git a/public_html/deployment/js/ubd.class.js b/public_html/deployment/js/ubd.class.js
new file mode 100644
index 00000000..63d102ab
--- /dev/null
+++ b/public_html/deployment/js/ubd.class.js
@@ -0,0 +1,209 @@
+/**
+ * ======================================================================
+ * 
+ *        U B D
+ * 
+ *        Url bound to a dom id
+ * 
+ * ----------------------------------------------------------------------
+ * 
+ * This is a helper class to update the content of a dom object by a 
+ * given Url after a ttl.
+ * 
+ * ----------------------------------------------------------------------
+ * 2022-06-26  www.axel-hahn.de  first lines...
+ * 2022-09-19                    do not update if domid is hidden using _iWaitIfHidden
+ * ======================================================================
+ */
+
+/**
+ * Url bound to a dom id
+ * @return class
+ */
+class ubd {
+    /**
+     * initialize data for a dom object
+     * @param  {object}  oConfig    optional config object with those subkeys:
+     *                                  domid    - id of a dom object
+     *                                  url      - url to an api
+     *                                  header   - http request header data
+     *                                  renderer - renderer function to visualize data
+     *                                  ttl      - ttl in sec (TODO)
+     * @returns {undefined}
+     */
+    constructor(oConfig) {
+
+        this._sDomId = '';
+        this._oDomObject = false;
+        this._sUrl2Fetch = false; // static value or reference of a function
+        this._oHeader = {};
+        this._sRenderfunction = false;
+        this._iTTL = false;
+        this._iWaitIfHidden = 500;
+
+        this._oTimer = false;
+
+        this._body = '';
+
+        oConfig = arguments ? arguments[0] : false;
+
+
+        if (oConfig) {
+            if (oConfig['domid']) {
+                this.setDomid(oConfig['domid']);
+            }
+            if (oConfig['url']) {
+                this.setUrl(oConfig['url']);
+            }
+            if (oConfig['header']) {
+                this.setHeaders(oConfig['header']);
+            }
+            if (oConfig['renderer']) {
+                this.setRenderfunction(oConfig['renderer']);
+            }
+            if (oConfig['ttl']) {
+                this.setTtl(oConfig['ttl']);
+            }
+        }
+    }
+
+    // ----------------------------------------------------------------------
+    // public SETTER for properties
+    // ----------------------------------------------------------------------
+
+    /**
+     * set domid that will by updated
+     * @param {string} sDomid if of a domobject
+     */
+    setDomid(sDomid) {
+            if (document.getElementById(sDomid)) {
+                this._sDomId = sDomid;
+                this._oDomObject = document.getElementById(sDomid);
+            } else {
+                this._sDomId = false;
+                this._oDomObject = false;
+                console.error('ERROR: setDomid("' + sDomid + '") got an invalid string - this domid does not exist.');
+            }
+    }
+
+    /**
+     * set a rendering function that visualized data after a http request
+     * @param {string|function} oFunction reference to a function ... or false to disable rendering
+     */
+    setRenderfunction (oFunction) {
+        this._sRenderfunction = oFunction;
+    }
+
+    /**
+     * Set time to live in seconds
+     * @param {int} iTTL
+     */
+    setTtl(iTTL) {
+        this._iTTL = iTTL / 1;
+        this.resetTimer();
+    }
+
+    /**
+     * set an url to be requested
+     * @param {string|function} sUrl  static value or reference of a function
+     */
+    setUrl(sUrl) {
+        this._sUrl2Fetch = sUrl;
+    }
+
+    /**
+     * set header obejct for 2nd param in javascript  fetch() function
+     * @param {object} oHeader
+     */
+    setHeaders(oHeader) {
+        this._oHeader = oHeader;
+    }
+
+    /**
+     * helper: dump current object instance to console
+     */
+    dumpme() {
+        console.log('---------- DUMP ubd');
+        console.log(this);
+        console.log('---------- /DUMP ubd');
+    }
+    // ----------------------------------------------------------------------
+    // public ACTIONS
+    // ----------------------------------------------------------------------
+    /**
+     * show rendered html content into set domid using the render function.
+     * If no rendering function was set then the response will be written
+     * directly.
+     * You can override both by giving a parameter (a string with html)
+     * to write that one directly. It can be used to show an error message.
+     *
+     * TODO:
+     * other output places than innerHTML by detecting the tag e.g.
+     * to use input or textarea.
+     *
+     * @param {string} sHtml  optional: htmlcode of an error message
+     */
+    render(sHtml) {
+        let out = sHtml ? sHtml :
+            (this._sRenderfunction
+                ? this._sRenderfunction(this._body)
+                : this._body
+            );
+        this._oDomObject.innerHTML = out;
+    }
+
+    /**
+     * reset timer to update the content in dom id after reaching TTL
+     * used in setTtl
+     */
+    resetTimer() {
+        clearTimeout(this._oTimer);
+        // clearInterval(this._oTimer);
+        if (this._iTTL) {
+            var bIsVisble=this._oDomObject.offsetParent !== null;
+            var iTtl=bIsVisble ? this._iTTL * 1000 : this._iWaitIfHidden;
+            let self = this;
+            self._oTimer = window.setTimeout(function () { self.update(); }, iTtl);
+            // self._oTimer=window.setInterval(self.update, this._iTTL*1000);
+        }
+    }
+
+    /**
+     * make http request and call the renderer
+     */
+    async update() {
+        var bIsVisble=this._oDomObject.offsetParent !== null;
+
+        let self = this;
+        let url = (typeof this._sUrl2Fetch == "function") ? this._sUrl2Fetch() : this._sUrl2Fetch;
+        if (url == undefined) {
+            console.error("SKIP update - there is no url in this object instance (anymore) :-/");
+            this.dumpme();
+            return 0;
+        }
+        if(bIsVisble){
+            try {
+                let response = await fetch(url, this._oHeader);
+                if (response.ok) {
+                    this._body = await response.json();
+
+                    this.render();
+                } else {
+                    this.render('<div class="app result1">'
+                        + 'ERROR ' + response.status + ': ' + response.statusText + ' - '
+                        + url
+                        + '</div>');
+                }
+            } catch (e) {
+                this.render('<div class="app result1">'
+                    + 'UNKNOWN: no response from '
+                    + this._sUrl2Fetch
+                    + '</div>');
+                console.error(e);
+            }
+        }
+        this.resetTimer();
+    }
+
+ 
+    }
\ No newline at end of file
diff --git a/public_html/deployment/main.css b/public_html/deployment/main.css
index ced1a280..3a2fb3dc 100644
--- a/public_html/deployment/main.css
+++ b/public_html/deployment/main.css
@@ -282,10 +282,41 @@ input[type="radio"]:checked+label, input[type="checkbox"]:checked+label{
 
 .tab-pane p {margin: 1em 10px;}
 
+/* ----- plugins ----- */
+.draggable-onpage{position: absolute;}
+.draggable-onscreen{position: fixed;}
+.isdragging{opacity: 0.9;}
+
+.cmdoutbox{background: rgba(0,0,0,0.9); color: #f0f0f0; box-shadow: 0.2em 0.2em 0.5em rgba(0,0,0,0.4); display: none;}
+.cmdoutbox .header{background:#628;padding: 0.1em 0.5em;}
+.cmdoutbox .out{color:#ccf; ;padding: 0.5em; font-family: monospace; overflow: scroll; white-space: nowrap; }
+.cmdoutbox button.btn-close{background:#e55; color: #fff; border: none;}
+
+.cmdoutbox .bar{background: rgba(255,255,255,0.1);}
+.cmdoutbox .progress{background: #85a;}
+
+.float-right{float: right;}
+
+.cmd-lines-10{height: 10em; max-height: 10em;}
+.cmd-lines-20{height: 20em; max-height: 20em;}
+.cmd-lines-25{height: 25em; max-height: 25em;}
+.cmd-lines-30{height: 30em; max-height: 30em;}
+.cmd-lines-35{height: 35em; max-height: 35em;}
+
+.cmd-cols-10{width: 10em; max-width: 10em;}
+.cmd-cols-20{width: 20em; max-width: 20em;}
+.cmd-cols-40{width: 40em; max-width: 40em;}
+.cmd-cols-60{width: 60em; max-width: 60em;}
+.cmd-cols-80{width: 80em; max-width: 80em;}
+.cmd-cols-100{width: 100em; max-width: 100em;}
+
 /* ----- visualized process ----- */
 .visualprocess{float: left; padding: 1em; box-shadow: 0 0 0em #ddd;}
 .visualprocess .process{float:left; text-align: center; padding: 0 0 5px; }
-.visualprocess .process.box{border: 2px dashed #ddd; padding: 1em; min-height: 25em;}
+.visualprocess .process.box{border: 80 dashed #ddd; 80ding: 1em; min-80m;} ng: 1em; min-25em;} 
+
+.visualprocess .process.box{border: 80 dashed #ddd; 80ding: 1em; min-80m;} ng: 1em; min-25em;} 
+.visualprocess .process img{}
 .visualprocess .process img{}
 .visualprocess .action{float:left;padding: 3em 1em 1em 1em; background: #fff;}
 .visualprocess .process .title{margin-bottom: 2em; font-weight: bold; font-size: 150%; color:#aaa;}
diff --git a/public_html/deployment/plugins/build/tgz/info.json b/public_html/deployment/plugins/build/tgz/info.json
index dfff108b..c158b6ef 100644
--- a/public_html/deployment/plugins/build/tgz/info.json
+++ b/public_html/deployment/plugins/build/tgz/info.json
@@ -4,6 +4,7 @@
     "author": "Axel Hahn; University of Bern; Institute for Medical education",
     
     "version": "1.0",
+    "date": "2022-09-20",
     "url": "[included]",
     "license": "GNU GPL 3.0",
 
diff --git a/public_html/deployment/plugins/build/zip/info.json b/public_html/deployment/plugins/build/zip/info.json
index 0d4f6b9a..3357e801 100644
--- a/public_html/deployment/plugins/build/zip/info.json
+++ b/public_html/deployment/plugins/build/zip/info.json
@@ -4,6 +4,7 @@
     "author": "Axel Hahn; University of Bern; Institute for Medical education",
     
     "version": "1.0",
+    "date": "2022-09-20",
     "url": "[included]",
     "license": "GNU GPL 3.0",
 
diff --git a/public_html/deployment/plugins/doc_reminder.md b/public_html/deployment/plugins/doc_reminder.md
new file mode 100644
index 00000000..30fe339d
--- /dev/null
+++ b/public_html/deployment/plugins/doc_reminder.md
@@ -0,0 +1,55 @@
+# CI server Plugins
+
+## Structure
+
+The plugin base dir is `public_html/deployment/plugins`
+
+Subdirs are the different plugin types
+
+```txt
+build/
+rollout/
+shellcmd/
+```
+
+In the 2nd level is 1 subdir per plugin.
+
+```txt
+.
+├── build
+│   ├── tgz
+│   │   ├── build_tgz.php
+│   │   ├── info.json
+│   │   ├── lang_de-de.json
+│   │   └── lang_en-en.json
+│   └── zip
+│       ├── build_zip.php
+│       ├── info.json
+│       ├── lang_de-de.json
+│       └── lang_en-en.json
+├── rollout
+:   ...
+└── shellcmd
+    ...
+```
+
+Bisher hatte ich für Build und Rollout je 1 Klasse gehabt. 
+Shellcmd Plugins kommen gerade dazu.
+
+Alle sollen von 1 Plugin-Klasse handhabbar sein - und einer Plugin-Renderere-Klasse.
+
+## Files in a plugin folder
+
+* **info.json** - meta data with plugin infos
+* **plugin.php** - plugin class
+* **config.json** - configuration data
+* **render.js** - javascript file to load
+* **lang_*.js** - language specific texts
+
+## Config
+
+For plugins that can handle project data there is an override mechanism:
+
+* global configuration data are in the ci server config
+* the project setting can override / extend the global settings
+* each phase can override/ extend the project settings
diff --git a/public_html/deployment/plugins/shellcmd/getdata.php b/public_html/deployment/plugins/shellcmd/getdata.php
index 12b260e8..fcfd2ff0 100644
--- a/public_html/deployment/plugins/shellcmd/getdata.php
+++ b/public_html/deployment/plugins/shellcmd/getdata.php
@@ -1,6 +1,11 @@
 <?php
 /*
  * script to be used as ajax poll request to get current status of an action
+ * 
+ * WIP
+ * 
+ * Example:
+ * http://localhost:8002/deployment/plugins/shellcmd/getdata.php?plugin=load
  */
 
 header('Content-Type: application/json');
@@ -8,132 +13,3 @@ require_once('plugins_shellcmd.class.php');
 
 $oShell=new shellcmd();
 $oShell->sendResponse();
-
-/*
-
-// width of load=1
-$iMaxWidth=100;
-
-$sPlugin=isset($_GET['plugin']) && $_GET['plugin'] ? preg_replace('/^a-z0-9/', '', $_GET['plugin']) : false;
-
-// ----------------------------------------------------------------------
-// ----------------------------------------------------------------------
-function execCommand($sCmd){
-    echo "DEBUG: ".__FUNCTION__ . "($sCmd)<br>";
-    exec($sCmd, $aOut, $iResult);
-    return [
-        'command'=>$sCmd,
-        'exitcode'=>$iResult,
-        'output'=>$aOut,
-    ];
-}
-
-
-if (!$sPlugin){
-    echo "DEBUG: Missing param for a plugin.<br>";
-    return [ 'error' => 'Missing param for a plugin.' ];
-}
-
-$sPluginfile=$sPlugin.'/command.php';
-$sPluginclass='shellplugin_'.$sPlugin;
-
-echo "DEBUG: sPluginfile=$sPluginfile<br>";
-
-if (!file_exists($sPluginfile)){
-    echo "DEBUG: Plugin seems to be corrupt. File not found: '. $sPluginfile.'<br>";
-    return [ 'error' => 'Plugin seems to be corrupt. File not found: '. $sPluginfile ];
-}
-
-
-include($sPluginfile);
-
-$oPlugin=new $sPluginclass();
-
-echo "DEBUG: sCmd=$sCmd<br>";
-
-$aResult=execCommand($sCmd);
-if (function_exists("parsedata")){
-    $aResult=parsedata($aResult);
-}
-
-echo '<pre>$aResult = '.print_r($aResult, 1).'<pre>';
-return $aResult;
-
-
-// ----------------------------------------------------------------------
-function getLoad(){
-    $sCmd='uptime';
-    $aResult=execCommand($sCmd);
-    $aTmp1=explode(',', $aResult['output'][0]);
-    $aResult['data']=[
-        'uptime'=>$aTmp1[0],
-        'users'=>$aTmp1[1],
-        'load'=>preg_replace('/^.*:/', '', $aTmp1[2]),
-        'load5'=>$aTmp1[3],
-        'load15'=>$aTmp1[4],
-    ];
-    return $aResult;
-}
-
-function getProcesses($sFilter){
-    $sRegex=$sFilter ? $sFilter : 'SomethingThatWontMatchInProcessList';
-    $sCmd="ps -f --forest | egrep -v '($sRegex)' | fgrep -v 'ps -f' | fgrep -v grep";
-    return execCommand($sCmd);
-}
-
-
-// ----------------------------------------------------------------------
-// ----------------------------------------------------------------------
-function load(){
-    global $iMaxWidth;
-    $sReturn='';
-
-    $aData=getLoad()['data'];
-
-    $iMaxLoad=round(max($aData['load'], $aData['load5'], $aData['load15']))+1;
-    $iScale=$iMaxWidth/$iMaxLoad;
-
-    $sScalaPart=str_repeat('_', (round($iScale)-1)).'|';
-    $sScala=str_repeat($sScalaPart, $iMaxLoad).$iMaxLoad;
-
-
-    $sBar1=str_repeat('#', (int)($aData['load']*$iScale));
-    $sBar5=str_repeat(' ', (int)($aData['load5']*$iScale - 1)).'|';
-    $sBar15=str_repeat(' ', (int)($aData['load15']*$iScale - 1)).'^';
-
-    $sReturn.= 'LOAD '.$aData['load'].' .. '.$aData['load5'].' .. '.$aData['load15'].'<br>'
-        . $sScala.'<br>'
-        . substr($sBar1, 0, $iMaxWidth).'<br>'
-        . substr($sBar5, 0, $iMaxWidth).'<br>'
-        . substr($sBar15, 0, $iMaxWidth).'<br>'
-        ;
-
-    return $sReturn;
-}
-
-
-function processes(){
-
-    return 'PROCESSES:<br>'
-        .shell_exec("ps -f --forest | egrep -v '(apache|httpd)' | fgrep -v 'ps -f' | fgrep -v grep")
-        .'<hr>all processes:<br>'
-        .shell_exec("ps -f --forest")
-    ;
-}
-
-// ----------------------------------------------------------------------
-// MAIN
-// ----------------------------------------------------------------------
-
-$sRemove='apache|httpd|php-fpm';
-
-echo '<pre>'
-    . load()
-    // .'<hr size="1">'
-    
-    .'<hr size="1">'
-    .'API DATA'
-    .'<pre>getLoad()='.print_r(getLoad(), 1).'</pre>'
-    .'<pre>getProcesses(\''.$sRemove.'\')='.print_r(getProcesses($sRemove), 1).'</pre>'
-    ;
-*/
\ No newline at end of file
diff --git a/public_html/deployment/plugins/shellcmd/load/config.json b/public_html/deployment/plugins/shellcmd/load/config.json
new file mode 100644
index 00000000..63c956c5
--- /dev/null
+++ b/public_html/deployment/plugins/shellcmd/load/config.json
@@ -0,0 +1,7 @@
+{
+    "command": "uptime",
+    "icon": "fas fa-tachometer-alt",
+    "interval": 2000,
+    "window-cols": 20,
+    "window-lines": false
+}
\ No newline at end of file
diff --git a/public_html/deployment/plugins/shellcmd/load/info.json b/public_html/deployment/plugins/shellcmd/load/info.json
new file mode 100644
index 00000000..5d4dd8c4
--- /dev/null
+++ b/public_html/deployment/plugins/shellcmd/load/info.json
@@ -0,0 +1,11 @@
+{
+    "name": "Load",
+    "description": "Show system load",
+    "author": "Axel Hahn; University of Bern; Institute for Medical education",
+    
+    "version": "1.0",
+    "date": "2022-09-20",
+    "url": "[included]",
+    "license": "GNU GPL 3.0"
+}
+
diff --git a/public_html/deployment/plugins/shellcmd/load/plugin.php b/public_html/deployment/plugins/shellcmd/load/plugin.php
index ec933804..0188143f 100644
--- a/public_html/deployment/plugins/shellcmd/load/plugin.php
+++ b/public_html/deployment/plugins/shellcmd/load/plugin.php
@@ -20,7 +20,13 @@ class shellcmd_load {
     public function __constructor(){
         return $this->getCommand();
     }
-
+    /**
+     * get column width
+     * @return string
+     */
+    public function getColumns(){
+        return false;
+    }
     /**
      * get command
      * @return string
@@ -38,7 +44,11 @@ class shellcmd_load {
         $aTmp1=array_reverse(explode(',', $aResult['output'][0]));
         // print_r($aTmp1);
         $aResult['data']=[
-            'uptime'=>trim($aTmp1[4]),
+            'uptime'=>(isset($aTmp1[5])
+                ? trim(substr($aTmp1[5], 10) . $aTmp1[4])
+                : trim(substr($aTmp1[4], 10))
+            ).' h'
+            ,
             'users'=>trim(str_replace(' users', '', $aTmp1[3])),
             'load'=>trim(preg_replace('/^.*:/', '', $aTmp1[2])),
             'load5'=>trim($aTmp1[1]),
diff --git a/public_html/deployment/plugins/shellcmd/load/render.js b/public_html/deployment/plugins/shellcmd/load/render.js
index f4d31060..4bb3ddd4 100644
--- a/public_html/deployment/plugins/shellcmd/load/render.js
+++ b/public_html/deployment/plugins/shellcmd/load/render.js
@@ -1,7 +1,42 @@
+/**
+ * 
+ * @param {array} aData 
+ * @returns 
+ */
+
+var _iMaxLoad=5;
+
+/**
+ * callback function for ubd: render html code
+ * @param {array} aData  json object
+ * @returns {string}
+ */
 function load_render(aData){
     var sReturn='';
-    sReturn+=aData['command']+"<br>"
-        + "Load: "+aData["data"]['load']+"<br>"
+
+    var iWidth=Math.round(aData["data"]['load']/_iMaxLoad*100);
+    iWidth=(iWidth>100) ? 100 : iWidth;
+    var sBar='<div class="bar"><div class="progress" style="width: '+iWidth+'%;"></div></div>';
+
+    return ""
+        +'<span style="float: right">'+aData['time']+'</span>'
+        + "Load: "+aData["data"]['load'] + '<br>'// +" /"+aData["data"]['load5']+"<br>"
+        + sBar
+        + aData["data"]['uptime']
         ;
-    return sReturn;
-}
\ No newline at end of file
+}
+
+function load_init(){   
+    oUbdLoad=new ubd(
+        {
+            "domid":     "divPluginshellcmdload",
+            "url":       "/deployment/plugins/shellcmd/getdata.php?plugin=load",
+            "renderer":  load_render,
+            "ttl":       2,
+        }
+    );
+    // oUbdLoad.render('waiting for first data...');
+    oUbdLoad.update();
+}
+
+window.setTimeout("load_init()", 200);
\ No newline at end of file
diff --git a/public_html/deployment/plugins/shellcmd/plugins_shellcmd.class.php b/public_html/deployment/plugins/shellcmd/plugins_shellcmd.class.php
index 938c8c57..747d07f1 100644
--- a/public_html/deployment/plugins/shellcmd/plugins_shellcmd.class.php
+++ b/public_html/deployment/plugins/shellcmd/plugins_shellcmd.class.php
@@ -1,4 +1,12 @@
 <?php
+/**
+ * GENERAL CLASS TO FETCH DATA FROM A SHELL COMMAND 
+ * FOR THE WEB UI
+ * 
+ * Used in ./getdata.php
+ * 
+ * TODO: replace this class with classes/plugins.class.php
+ */
 
 class shellcmd {
 
@@ -63,10 +71,11 @@ class shellcmd {
      * @return
      */
     protected function _execCommand($sCmd){
-        exec($sCmd, $aOut, $iResult);
+        exec("$sCmd", $aOut, $iResult);
         return [
             'command'=>$sCmd,
             'exitcode'=>$iResult,
+            'time'=>date("H:i:s"),
             'output'=>$aOut,
         ];
     }
diff --git a/public_html/deployment/plugins/shellcmd/processes/command.php b/public_html/deployment/plugins/shellcmd/processes/command.php
deleted file mode 100644
index e69de29b..00000000
diff --git a/public_html/deployment/plugins/shellcmd/processes/config.json b/public_html/deployment/plugins/shellcmd/processes/config.json
new file mode 100644
index 00000000..f95219d5
--- /dev/null
+++ b/public_html/deployment/plugins/shellcmd/processes/config.json
@@ -0,0 +1,7 @@
+{
+    "command": "ps -f --forest | egrep -v '[/\\ ](apache|httpd|php-fpm)' | fgrep -v 'ps -f' | fgrep -v grep",
+    "icon": "far fa-list-alt",
+    "interval": 2000,
+    "window-cols": 80,
+    "window-lines": 30
+}
\ No newline at end of file
diff --git a/public_html/deployment/plugins/shellcmd/processes/info.json b/public_html/deployment/plugins/shellcmd/processes/info.json
new file mode 100644
index 00000000..586c7af7
--- /dev/null
+++ b/public_html/deployment/plugins/shellcmd/processes/info.json
@@ -0,0 +1,11 @@
+{
+    "name": "Processes",
+    "description": "Show processes of the ci server",
+    "author": "Axel Hahn; University of Bern; Institute for Medical education",
+    
+    "version": "1.0",
+    "date": "2022-09-20",
+    "url": "[included]",
+    "license": "GNU GPL 3.0"
+}
+
diff --git a/public_html/deployment/plugins/shellcmd/processes/plugin.php b/public_html/deployment/plugins/shellcmd/processes/plugin.php
new file mode 100644
index 00000000..ae008a51
--- /dev/null
+++ b/public_html/deployment/plugins/shellcmd/processes/plugin.php
@@ -0,0 +1,73 @@
+<?php
+/**
+ * 
+ * SHELLCMD PLUGIN :: Processes
+ * 
+ * ----------------------------------------------------------------------
+ * 2022-09-19  axel.hahn@iml.unibe.ch
+ */
+class shellcmd_processes {
+    /**
+     * @var command line to exectute
+     */
+    protected $_command="ps -f --forest | egrep -v '[/\ ](apache|httpd|php-fpm)' | fgrep -v 'ps -f' | fgrep -v grep";
+    // protected $_command="ps -ef --forest ";
+
+    /**
+     * constructor ... returns command
+     * @return string
+     */
+    public function __constructor(){
+        return $this->getCommand();
+    }
+
+    /**
+     * get command
+     * @return string
+     */
+    public function getCommand(){
+        return $this->_command;
+    }
+
+    /**
+     * parse output and extract wanted values in section "data"
+     * @return array
+     */
+    public function parsedata($aResult){
+        $aResult['data']=[
+            'count'=>count($aResult['output'])-1,
+        ];
+        return $aResult;
+    }
+
+}
+/*
+
+EXAMPLE OUTPUT
+
+{
+    "command": "ps -ef --forest ",
+    "exitcode": 0,
+    "time": "12:02:47",
+    "output": [
+        "UID          PID    PPID  C STIME TTY          TIME CMD",
+        "www-data       1       0  0 11:21 ?        00:00:00 apache2 -DFOREGROUND",
+        "www-data      17       1  0 11:21 ?        00:00:00 apache2 -DFOREGROUND",
+        "www-data      20       1  0 11:21 ?        00:00:00 apache2 -DFOREGROUND",
+        "www-data      21       1  0 11:21 ?        00:00:00 apache2 -DFOREGROUND",
+        "www-data      24       1  0 11:21 ?        00:00:00 apache2 -DFOREGROUND",
+        "www-data     270       1  0 11:27 ?        00:00:00 apache2 -DFOREGROUND",
+        "www-data    2929     270  0 12:02 ?        00:00:00  \\_ sh -c ps -ef --forest",
+        "www-data    2930    2929  0 12:02 ?        00:00:00      \\_ ps -ef --forest",
+        "www-data     278       1  0 11:27 ?        00:00:00 apache2 -DFOREGROUND",
+        "www-data     279       1  0 11:27 ?        00:00:00 apache2 -DFOREGROUND",
+        "www-data    2569       1  0 11:59 ?        00:00:00 apache2 -DFOREGROUND",
+        "www-data    2570       1  0 11:59 ?        00:00:00 apache2 -DFOREGROUND",
+        "www-data    2571       1  0 11:59 ?        00:00:00 apache2 -DFOREGROUND"
+    ],
+    "data": {
+        "count": 13
+    }
+}
+
+*/
\ No newline at end of file
diff --git a/public_html/deployment/plugins/shellcmd/processes/render.js b/public_html/deployment/plugins/shellcmd/processes/render.js
new file mode 100644
index 00000000..ae6d2d96
--- /dev/null
+++ b/public_html/deployment/plugins/shellcmd/processes/render.js
@@ -0,0 +1,42 @@
+/**
+ * 
+ * @param {array} aData 
+ * @returns 
+ */
+
+var _iMaxLoad=5;
+
+/**
+ * callback function for ubd: render html code
+ * @param {array} aData  json object
+ * @returns {string}
+ */
+function processes_render(aData){
+    var sReturn='';
+    sReturn='<span style="float: right">'+aData['time']+'</span>'; // +' '+aData['command']+'</span>';
+    if(aData['data']['count']==0){
+        sReturn+='No activity<br><br>Command:<br>'
+        +aData['command'];
+    } else {
+        for(var i=0; i<aData['output'].length; i++){
+            // sReturn+='<div>'+aData['output'][i]+"</div>";
+            sReturn+=aData['output'][i]+"<br>";
+        }
+    }
+    return sReturn;
+}
+
+function processes_init(){   
+    oUbdProcesses=new ubd(
+        {
+            "domid":     "divPluginshellcmdprocesses",
+            "url":       "/deployment/plugins/shellcmd/getdata.php?plugin=processes",
+            "renderer":  processes_render,
+            "ttl":       2,
+        }
+    );
+    // oUbdLoad.render('waiting for first data...');
+    oUbdProcesses.update();
+}
+
+window.setTimeout("processes_init()", 200);
\ No newline at end of file
-- 
GitLab