Index: /trunk/src/VBox/ValidationKit/testmanager/cgi/rest.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/cgi/rest.py	(revision 84599)
+++ /trunk/src/VBox/ValidationKit/testmanager/cgi/rest.py	(revision 84599)
@@ -0,0 +1,71 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# $Id$
+
+"""
+CGI - REST - sPath=path variant.
+"""
+
+__copyright__ = \
+"""
+Copyright (C) 2012-2020 Oracle Corporation
+
+This file is part of VirtualBox Open Source Edition (OSE), as
+available from http://www.virtualbox.org. This file is free software;
+you can redistribute it and/or modify it under the terms of the GNU
+General Public License (GPL) as published by the Free Software
+Foundation, in version 2 as it comes in the "COPYING" file of the
+VirtualBox OSE distribution. VirtualBox OSE is distributed in the
+hope that it will be useful, but WITHOUT ANY WARRANTY of any kind.
+
+The contents of this file may alternatively be used under the terms
+of the Common Development and Distribution License Version 1.0
+(CDDL) only, as it comes in the "COPYING.CDDL" file of the
+VirtualBox OSE distribution, in which case the provisions of the
+CDDL are applicable instead of those of the GPL.
+
+You may elect to license modified versions of this file under the
+terms and conditions of either the GPL or the CDDL or both.
+"""
+__version__ = "$Revision$"
+
+
+# Standard python imports.
+import os
+import sys
+
+# Only the main script needs to modify the path.
+g_ksValidationKitDir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))));
+sys.path.append(g_ksValidationKitDir);
+
+# Validation Kit imports.
+from testmanager                        import config;
+from testmanager.core.webservergluecgi  import WebServerGlueCgi;
+from testmanager.core.restdispatcher    import RestMain, RestDispException;
+
+
+def main():
+    """
+    Main function a la C/C++. Returns exit code.
+    """
+
+    oSrvGlue = WebServerGlueCgi(g_ksValidationKitDir, fHtmlOutput = False);
+    try:
+        oMain = RestMain(oSrvGlue);
+        oMain.dispatchRequest();
+        oSrvGlue.flush();
+    except RestDispException as oXcpt:
+        oSrvGlue.setStatus(oXcpt.iStatus);
+        oSrvGlue.setHeaderField('tm-error-message', str(oXcpt));
+        oSrvGlue.write('error: ' + str(oXcpt));
+        oSrvGlue.flush();
+    except Exception as oXcpt:
+        return oSrvGlue.errorPage('Internal error: %s' % (str(oXcpt),),
+                                  sys.exc_info(),
+                                  config.g_ksTestBoxDispXpctLog);
+
+    return 0;
+
+if __name__ == '__main__':
+    sys.exit(main());
+
Index: /trunk/src/VBox/ValidationKit/testmanager/config.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/config.py	(revision 84598)
+++ /trunk/src/VBox/ValidationKit/testmanager/config.py	(revision 84599)
@@ -211,6 +211,6 @@
 ## Where to write the glue debug.
 # None indicates apache error log, string indicates a file.
-#g_ksSrcGlueDebugLogDst  = '/tmp/testmanager-srv-glue.log';
-g_ksSrcGlueDebugLogDst  = None;
+#g_ksSrvGlueDebugLogDst  = '/tmp/testmanager-srv-glue.log';
+g_ksSrvGlueDebugLogDst  = None;
 ## Whether to enable CGI trace back in the server glue.
 g_kfSrvGlueCgiTb        = False;
@@ -223,5 +223,5 @@
 ## Whether to dumping CGI script arguments.
 g_kfSrvGlueCgiDumpArgs  = False;
-## Enables task scheduler debug output to g_ksSrcGlueDebugLogDst.
+## Enables task scheduler debug output to g_ksSrvGlueDebugLogDst.
 g_kfSrvGlueDebugScheduler = False;
 
Index: /trunk/src/VBox/ValidationKit/testmanager/core/base.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/core/base.py	(revision 84598)
+++ /trunk/src/VBox/ValidationKit/testmanager/core/base.py	(revision 84599)
@@ -33,4 +33,6 @@
 # Standard python imports.
 import copy;
+import datetime;
+import json;
 import re;
 import socket;
@@ -1062,4 +1064,81 @@
              + ModelDataBase.formatSimpleNowAndPeriod(oDb, tsNow, sPeriodBack, sTablePrefix, sExpCol, sEffCol);
 
+
+    #
+    # JSON
+    #
+
+    @staticmethod
+    def stringToJson(sString):
+        """ Converts a string to a JSON value string. """
+        if not utils.isString(sString):
+            sString = utils.toUnicode(sString);
+            if not utils.isString(sString):
+                sString = str(sString);
+        return json.dumps(sString);
+
+    @staticmethod
+    def dictToJson(dDict, dOptions = None):
+        """ Converts a dictionary to a JSON string. """
+        sJson = u'{ ';
+        for i, oKey in enumerate(dDict):
+            if i > 0:
+                sJson += ', ';
+            sJson += '%s: %s' % (ModelDataBase.stringToJson(oKey),
+                                 ModelDataBase.genericToJson(dDict[oKey], dOptions));
+        return sJson + ' }';
+
+    @staticmethod
+    def listToJson(aoList, dOptions = None):
+        """ Converts list of something to a JSON string. """
+        sJson = u'[ ';
+        for i, oValue in enumerate(aoList):
+            if i > 0:
+                sJson += u', ';
+            sJson += ModelDataBase.genericToJson(oValue, dOptions);
+        return sJson + u' ]';
+
+    @staticmethod
+    def datetimeToJson(oDateTime, dOptions = None):
+        """ Converts a datetime instance to a JSON string. """
+        return '"%s"' % (oDateTime,);
+
+
+    @staticmethod
+    def genericToJson(oValue, dOptions = None):
+        """ Converts a generic object to a JSON string. """
+        if isinstance(oValue, ModelDataBase):
+            return oValue.toJson();
+        if isinstance(oValue, dict):
+            return ModelDataBase.dictToJson(oValue, dOptions);
+        if isinstance(oValue, (list, tuple, set, frozenset)):
+            return ModelDataBase.listToJson(oValue, dOptions);
+        if isinstance(oValue, datetime.datetime):
+            return ModelDataBase.datetimeToJson(oValue, dOptions)
+        return json.dumps(oValue);
+
+    def attribValueToJson(self, sAttr, oValue, dOptions = None):
+        """
+        Converts the attribute value to JSON.
+        Returns JSON (string).
+        """
+        _ = sAttr;
+        return self.genericToJson(oValue, dOptions);
+
+    def toJson(self, dOptions = None):
+        """
+        Converts the object to JSON.
+        Returns JSON (string).
+        """
+        sJson = u'{ ';
+        for iAttr, sAttr in enumerate(self.getDataAttributes()):
+            oValue = getattr(self, sAttr);
+            if iAttr > 0:
+                sJson += ', ';
+            sJson += u'"%s": ' % (sAttr,);
+            sJson += self.attribValueToJson(sAttr, oValue, dOptions);
+        return sJson + u' }';
+
+
     #
     # Sub-classes.
Index: /trunk/src/VBox/ValidationKit/testmanager/core/restdispatcher.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/core/restdispatcher.py	(revision 84599)
+++ /trunk/src/VBox/ValidationKit/testmanager/core/restdispatcher.py	(revision 84599)
@@ -0,0 +1,448 @@
+# -*- coding: utf-8 -*-
+# $Id$
+
+"""
+Test Manager Core - REST cgi handler.
+"""
+
+__copyright__ = \
+"""
+Copyright (C) 2012-2020 Oracle Corporation
+
+This file is part of VirtualBox Open Source Edition (OSE), as
+available from http://www.virtualbox.org. This file is free software;
+you can redistribute it and/or modify it under the terms of the GNU
+General Public License (GPL) as published by the Free Software
+Foundation, in version 2 as it comes in the "COPYING" file of the
+VirtualBox OSE distribution. VirtualBox OSE is distributed in the
+hope that it will be useful, but WITHOUT ANY WARRANTY of any kind.
+
+The contents of this file may alternatively be used under the terms
+of the Common Development and Distribution License Version 1.0
+(CDDL) only, as it comes in the "COPYING.CDDL" file of the
+VirtualBox OSE distribution, in which case the provisions of the
+CDDL are applicable instead of those of the GPL.
+
+You may elect to license modified versions of this file under the
+terms and conditions of either the GPL or the CDDL or both.
+"""
+__version__ = "$Revision$"
+
+
+# Standard python imports.
+import os;
+import sys;
+
+# Validation Kit imports.
+#from common                             import constants;
+from common                             import utils;
+from testmanager                        import config;
+#from testmanager.core                   import coreconsts;
+from testmanager.core.db                import TMDatabaseConnection;
+from testmanager.core.base              import TMExceptionBase, ModelDataBase;
+
+# Python 3 hacks:
+if sys.version_info[0] >= 3:
+    long = int;     # pylint: disable=redefined-builtin,invalid-name
+
+
+#
+# Exceptions
+#
+
+class RestDispException(TMExceptionBase):
+    """
+    Exception class for the REST dispatcher.
+    """
+    def __init__(self, sMsg, iStatus):
+        TMExceptionBase.__init__(self, sMsg);
+        self.iStatus = iStatus;
+
+# 400
+class RestDispException400(RestDispException):
+    """ A 400 error """
+    def __init__(self, sMsg):
+        RestDispException.__init__(self, sMsg, 400);
+
+class RestUnknownParameters(RestDispException400):
+    """ Unknown parameter(s). """
+    pass;                               # pylint: disable=unnecessary-pass
+
+# 404
+class RestDispException404(RestDispException):
+    """ A 404 error """
+    def __init__(self, sMsg):
+        RestDispException.__init__(self, sMsg, 404);
+
+class RestBadPathException(RestDispException404):
+    """ We've got a bad path. """
+    pass;                               # pylint: disable=unnecessary-pass
+
+class RestBadParameter(RestDispException404):
+    """ Bad parameter. """
+    pass;                               # pylint: disable=unnecessary-pass
+
+class RestMissingParameter(RestDispException404):
+    """ Missing parameter. """
+    pass;                               # pylint: disable=unnecessary-pass
+
+
+
+class RestMain(object): # pylint: disable=too-few-public-methods
+    """
+    REST main dispatcher class.
+    """
+
+    ksParam_sPath = 'sPath';
+
+
+    def __init__(self, oSrvGlue):
+        self._oSrvGlue          = oSrvGlue;
+        self._oDb               = TMDatabaseConnection(oSrvGlue.dprint);
+        self._iFirstHandlerPath = 0;
+        self._iNextHandlerPath  = 0;
+        self._sPath             = None; # _getStandardParams / dispatchRequest sets this later on.
+        self._asPath            = None; # _getStandardParams / dispatchRequest sets this later on.
+        self._sMethod           = None; # _getStandardParams / dispatchRequest sets this later on.
+        self._dParams           = None; # _getStandardParams / dispatchRequest sets this later on.
+        self._asCheckedParams   = [];
+        self._dGetTree          = {
+            'vcs': {
+                'changelog': self._handleVcsChangelog_Get,
+                'bugreferences': self._handleVcsBugReferences_Get,
+            },
+        };
+        self._dMethodTrees      = {
+            'GET': self._dGetTree,
+        }
+
+    #
+    # Helpers.
+    #
+
+    def _getStringParam(self, sName, asValidValues = None, fStrip = False, sDefValue = None):
+        """
+        Gets a string parameter (stripped).
+
+        Raises exception if not found and no default is provided, or if the
+        value isn't found in asValidValues.
+        """
+        if sName not in self._dParams:
+            if sDefValue is None:
+                raise RestMissingParameter('%s parameter %s is missing' % (self._sPath, sName));
+            return sDefValue;
+        sValue = self._dParams[sName];
+        if isinstance(sValue, list):
+            if len(sValue) == 1:
+                sValue = sValue[0];
+            else:
+                raise RestBadParameter('%s parameter %s value is not a string but list: %s'
+                                       % (self._sPath, sName, sValue));
+        if fStrip:
+            sValue = sValue.strip();
+
+        if sName not in self._asCheckedParams:
+            self._asCheckedParams.append(sName);
+
+        if asValidValues is not None and sValue not in asValidValues:
+            raise RestBadParameter('%s parameter %s value "%s" not in %s '
+                                   % (self._sPath, sName, sValue, asValidValues));
+        return sValue;
+
+    def _getBoolParam(self, sName, fDefValue = None):
+        """
+        Gets a boolean parameter.
+
+        Raises exception if not found and no default is provided, or if not a
+        valid boolean.
+        """
+        sValue = self._getStringParam(sName, [ 'True', 'true', '1', 'False', 'false', '0'], sDefValue = str(fDefValue));
+        return sValue in ('True', 'true', '1',);
+
+    def _getIntParam(self, sName, iMin = None, iMax = None):
+        """
+        Gets a string parameter.
+        Raises exception if not found, not a valid integer, or if the value
+        isn't in the range defined by iMin and iMax.
+        """
+        sValue = self._getStringParam(sName);
+        try:
+            iValue = int(sValue, 0);
+        except:
+            raise RestBadParameter('%s parameter %s value "%s" cannot be convert to an integer'
+                                   % (self._sPath, sName, sValue));
+
+        if   (iMin is not None and iValue < iMin) \
+          or (iMax is not None and iValue > iMax):
+            raise RestBadParameter('%s parameter %s value %d is out of range [%s..%s]'
+                                   % (self._sPath, sName, iValue, iMin, iMax));
+        return iValue;
+
+    def _getLongParam(self, sName, lMin = None, lMax = None, lDefValue = None):
+        """
+        Gets a string parameter.
+        Raises exception if not found, not a valid long integer, or if the value
+        isn't in the range defined by lMin and lMax.
+        """
+        sValue = self._getStringParam(sName, sDefValue = (str(lDefValue) if lDefValue is not None else None));
+        try:
+            lValue = long(sValue, 0);
+        except Exception as oXcpt:
+            raise RestBadParameter('%s parameter %s value "%s" cannot be convert to an integer (%s)'
+                                   % (self._sPath, sName, sValue, oXcpt));
+
+        if   (lMin is not None and lValue < lMin) \
+          or (lMax is not None and lValue > lMax):
+            raise RestBadParameter('%s parameter %s value %d is out of range [%s..%s]'
+                                   % (self._sPath, sName, lValue, lMin, lMax));
+        return lValue;
+
+    def _checkForUnknownParameters(self):
+        """
+        Check if we've handled all parameters, raises exception if anything
+        unknown was found.
+        """
+
+        if len(self._asCheckedParams) != len(self._dParams):
+            sUnknownParams = '';
+            for sKey in self._dParams:
+                if sKey not in self._asCheckedParams:
+                    sUnknownParams += ' ' + sKey + '=' + self._dParams[sKey];
+            raise RestUnknownParameters('Unknown parameters: ' + sUnknownParams);
+
+        return True;
+
+    def writeToMainLog(self, oTestSet, sText, fIgnoreSizeCheck = False):
+        """ Writes the text to the main log file. """
+
+        # Calc the file name and open the file.
+        sFile = os.path.join(config.g_ksFileAreaRootDir, oTestSet.sBaseFilename + '-main.log');
+        if not os.path.exists(os.path.dirname(sFile)):
+            os.makedirs(os.path.dirname(sFile), 0o755);
+        oFile = open(sFile, 'ab');
+
+        # Check the size.
+        fSizeOk = True;
+        if not fIgnoreSizeCheck:
+            oStat = os.fstat(oFile.fileno());
+            fSizeOk = oStat.st_size / (1024 * 1024) < config.g_kcMbMaxMainLog;
+
+        # Write the text.
+        if fSizeOk:
+            if sys.version_info[0] >= 3:
+                oFile.write(bytes(sText, 'utf-8'));
+            else:
+                oFile.write(sText);
+
+        # Done
+        oFile.close();
+        return fSizeOk;
+
+    def _getNextPathElementString(self, sName, oDefault = None):
+        """
+        Gets the next handler specific path element.
+        Returns unprocessed string.
+        Throws exception
+        """
+        i = self._iNextHandlerPath;
+        if i < len(self._asPath):
+            self._iNextHandlerPath = i + 1;
+            return self._asPath[i];
+        if oDefault is None:
+            raise RestBadPathException('Requires a "%s" element after "%s"' % (sName, self._sPath,));
+        return oDefault;
+
+    def _getNextPathElementInt(self, sName, iDefault = None, iMin = None, iMax = None):
+        """
+        Gets the next handle specific path element as an integer.
+        Returns integer value.
+        Throws exception if not found or not a valid integer.
+        """
+        sValue = self._getNextPathElementString(sName, oDefault = iDefault);
+        try:
+            iValue = int(sValue);
+        except:
+            raise RestBadPathException('Not an integer "%s" (%s)' % (sValue, sName,));
+        if iMin is not None and iValue < iMin:
+            raise RestBadPathException('Integer "%s" value (%s) is too small, min %s' % (sValue, sName, iMin));
+        if iMax is not None and iValue > iMax:
+            raise RestBadPathException('Integer "%s" value (%s) is too large, max %s' % (sValue, sName, iMax));
+        return iValue;
+
+    def _getNextPathElementLong(self, sName, iDefault = None, iMin = None, iMax = None):
+        """
+        Gets the next handle specific path element as a long integer.
+        Returns integer value.
+        Throws exception if not found or not a valid integer.
+        """
+        sValue = self._getNextPathElementString(sName, oDefault = iDefault);
+        try:
+            iValue = long(sValue);
+        except:
+            raise RestBadPathException('Not an integer "%s" (%s)' % (sValue, sName,));
+        if iMin is not None and iValue < iMin:
+            raise RestBadPathException('Integer "%s" value (%s) is too small, min %s' % (sValue, sName, iMin));
+        if iMax is not None and iValue > iMax:
+            raise RestBadPathException('Integer "%s" value (%s) is too large, max %s' % (sValue, sName, iMax));
+        return iValue;
+
+    def _checkNoMorePathElements(self):
+        """
+        Checks that there are no more path elements.
+        Throws exception if there are.
+        """
+        i = self._iNextHandlerPath;
+        if i < len(self._asPath):
+            raise RestBadPathException('Unknown subpath "%s" below "%s"' %
+                                       ('/'.join(self._asPath[i:]), '/'.join(self._asPath[:i]),));
+        return True;
+
+    def _doneParsingArguments(self):
+        """
+        Checks that there are no more path elements or unhandled parameters.
+        Throws exception if there are.
+        """
+        self._checkNoMorePathElements();
+        self._checkForUnknownParameters();
+        return True;
+
+    def _dataArrayToJsonReply(self, aoData, sName = 'aoData', dExtraFields = None, iStatus = 200):
+        """
+        Converts aoData into an array objects
+        return True.
+        """
+        self._oSrvGlue.setContentType('application/json');
+        self._oSrvGlue.setStatus(iStatus);
+        self._oSrvGlue.write(u'{\n');
+        if dExtraFields:
+            for sKey in dExtraFields:
+                self._oSrvGlue.write(u'  "%s": %s,\n' % (sKey, ModelDataBase.genericToJson(dExtraFields[sKey]),));
+        self._oSrvGlue.write(u'  "c%s": %u,\n' % (sName[2:],len(aoData),));
+        self._oSrvGlue.write(u'  "%s": [\n' % (sName,));
+        for i, oData in enumerate(aoData):
+            if i > 0:
+                self._oSrvGlue.write(u',\n');
+            self._oSrvGlue.write(ModelDataBase.genericToJson(oData));
+        self._oSrvGlue.write(u'  ]\n');
+        ## @todo if config.g_kfWebUiSqlDebug:
+        self._oSrvGlue.write(u'}\n');
+        self._oSrvGlue.flush();
+        return True;
+
+
+    #
+    # Handlers.
+    #
+
+    def _handleVcsChangelog_Get(self):
+        """ GET /vcs/changelog/{sRepository}/{iStartRev}[/{cEntriesBack}] """
+        # Parse arguments
+        sRepository  = self._getNextPathElementString('sRepository');
+        iStartRev    = self._getNextPathElementInt('iStartRev', iMin = 0);
+        cEntriesBack = self._getNextPathElementInt('cEntriesBack', iDefault = 32, iMin = 0, iMax = 8192);
+        self._checkNoMorePathElements();
+        self._checkForUnknownParameters();
+
+        # Execute it.
+        from testmanager.core.vcsrevisions import VcsRevisionLogic;
+        oLogic = VcsRevisionLogic(self._oDb);
+        return self._dataArrayToJsonReply(oLogic.fetchTimeline(sRepository, iStartRev, cEntriesBack), 'aoCommits',
+                                          { 'sTracChangesetUrlFmt':
+                                            config.g_ksTracChangsetUrlFmt.replace('%(sRepository)s', sRepository), } );
+
+    def _handleVcsBugReferences_Get(self):
+        """ GET /vcs/bugreferences/{sTrackerId}/{lBugId} """
+        # Parse arguments
+        sTrackerId   = self._getNextPathElementString('sTrackerId');
+        lBugId       = self._getNextPathElementLong('lBugId', iMin = 0);
+        self._checkNoMorePathElements();
+        self._checkForUnknownParameters();
+
+        # Execute it.
+        from testmanager.core.vcsbugreference import VcsBugReferenceLogic;
+        oLogic = VcsBugReferenceLogic(self._oDb);
+        oLogic.fetchForBug(sTrackerId, lBugId)
+        return self._dataArrayToJsonReply(oLogic.fetchForBug(sTrackerId, lBugId), 'aoCommits',
+                                          { 'sTracChangesetUrlFmt': config.g_ksTracChangsetUrlFmt, } );
+
+
+    #
+    # Dispatching.
+    #
+
+    def _dispatchRequestCommon(self):
+        """
+        Dispatches the incoming request after have gotten the path and parameters.
+
+        Will raise RestDispException on failure.
+        """
+
+        #
+        # Split up the path.
+        #
+        asPath = self._sPath.split('/');
+        self._asPath = asPath;
+
+        #
+        # Get the method and the corresponding handler tree.
+        #
+        try:
+            sMethod = self._oSrvGlue.getMethod();
+        except Exception as oXcpt:
+            raise RestDispException('Error retriving request method: %s' % (oXcpt,), 400);
+        self._sMethod = sMethod;
+
+        try:
+            dTree = self._dMethodTrees[sMethod];
+        except KeyError:
+            raise RestDispException('Unsupported method %s' % (sMethod,), 405);
+
+        #
+        # Walk the path till we find a handler for it.
+        #
+        iPath = 0;
+        while iPath < len(asPath):
+            try:
+                oTreeOrHandler = dTree[asPath[iPath]];
+            except KeyError:
+                raise RestBadPathException('Path element #%u "%s" not found (path="%s")' % (iPath, asPath[iPath], self._sPath));
+            iPath += 1;
+            if isinstance(oTreeOrHandler, dict):
+                dTree = oTreeOrHandler;
+            else:
+                #
+                # Call the handler.
+                #
+                self._iFirstHandlerPath = iPath;
+                self._iNextHandlerPath  = iPath;
+                return oTreeOrHandler();
+
+        raise RestBadPathException('Empty path (%s)' % (self._sPath,));
+
+    def dispatchRequest(self):
+        """
+        Dispatches the incoming request where the path is given as an argument.
+
+        Will raise RestDispException on failure.
+        """
+
+        #
+        # Get the parameters.
+        #
+        try:
+            dParams = self._oSrvGlue.getParameters();
+        except Exception as oXcpt:
+            raise RestDispException('Error retriving parameters: %s' % (oXcpt,), 500);
+        self._dParams = dParams;
+
+        #
+        # Get the path parameter.
+        #
+        if self.ksParam_sPath not in dParams:
+            raise RestDispException('No "%s" parameter in request (params: %s)' % (self.ksParam_sPath, dParams,), 500);
+        self._sPath = self._getStringParam(self.ksParam_sPath);
+        assert utils.isString(self._sPath);
+
+        return self._dispatchRequestCommon();
+
+
Index: /trunk/src/VBox/ValidationKit/testmanager/core/vcsbugreference.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/core/vcsbugreference.py	(revision 84598)
+++ /trunk/src/VBox/ValidationKit/testmanager/core/vcsbugreference.py	(revision 84599)
@@ -86,4 +86,22 @@
         self.sBugTracker        = sBugTracker;
         self.lBugNo             = lBugNo;
+        return self;
+
+
+class VcsBugReferenceDataEx(VcsBugReferenceData):
+    """
+    Extended version of VcsBugReferenceData that includes the commit details.
+    """
+    def __init__(self):
+        VcsBugReferenceData.__init__(self);
+        self.tsCreated          = None;
+        self.sAuthor            = None;
+        self.sMessage           = None;
+
+    def initFromDbRow(self, aoRow):
+        VcsBugReferenceData.initFromDbRow(self, aoRow);
+        self.tsCreated          = aoRow[4];
+        self.sAuthor            = aoRow[5];
+        self.sMessage           = aoRow[6];
         return self;
 
@@ -183,4 +201,29 @@
         return self._oDb.fetchOne()[0];
 
+    def fetchForBug(self, sBugTracker, lBugNo):
+        """
+        Fetches VCS revisions for a bug.
+
+        Returns an array (list) of VcsBugReferenceDataEx items, empty list if none.
+        Raises exception on error.
+        """
+        self._oDb.execute('''
+SELECT  VcsBugReferences.*,
+        VcsRevisions.tsCreated,
+        VcsRevisions.sAuthor,
+        VcsRevisions.sMessage
+FROM    VcsBugReferences
+LEFT OUTER JOIN VcsRevisions ON (    VcsRevisions.sRepository = VcsBugReferences.sRepository
+                                 AND VcsRevisions.iRevision   = VcsBugReferences.iRevision )
+WHERE   sBugTracker = %s
+    AND lBugNo      = %s
+ORDER BY VcsRevisions.tsCreated, VcsBugReferences.sRepository, VcsBugReferences.iRevision
+''', (sBugTracker, lBugNo,));
+
+        aoRows = [];
+        for _ in range(self._oDb.getRowCount()):
+            aoRows.append(VcsBugReferenceDataEx().initFromDbRow(self._oDb.fetchOne()));
+        return aoRows;
+
 
 #
Index: /trunk/src/VBox/ValidationKit/testmanager/core/webservergluebase.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/core/webservergluebase.py	(revision 84598)
+++ /trunk/src/VBox/ValidationKit/testmanager/core/webservergluebase.py	(revision 84599)
@@ -62,4 +62,71 @@
     ksUnknownUser = 'Unknown User';
 
+    ## HTTP status codes and their messages.
+    kdStatusMsgs = {
+        100: 'Continue',
+        101: 'Switching Protocols',
+        102: 'Processing',
+        103: 'Early Hints',
+        200: 'OK',
+        201: 'Created',
+        202: 'Accepted',
+        203: 'Non-Authoritative Information',
+        204: 'No Content',
+        205: 'Reset Content',
+        206: 'Partial Content',
+        207: 'Multi-Status',
+        208: 'Already Reported',
+        226: 'IM Used',
+        300: 'Multiple Choices',
+        301: 'Moved Permantently',
+        302: 'Found',
+        303: 'See Other',
+        304: 'Not Modified',
+        305: 'Use Proxy',
+        306: 'Switch Proxy',
+        307: 'Temporary Redirect',
+        308: 'Permanent Redirect',
+        400: 'Bad Request',
+        401: 'Unauthorized',
+        402: 'Payment Required',
+        403: 'Forbidden',
+        404: 'Not Found',
+        405: 'Method Not Allowed',
+        406: 'Not Acceptable',
+        407: 'Proxy Authentication Required',
+        408: 'Request Timeout',
+        409: 'Conflict',
+        410: 'Gone',
+        411: 'Length Required',
+        412: 'Precondition Failed',
+        413: 'Payload Too Large',
+        414: 'URI Too Long',
+        415: 'Unsupported Media Type',
+        416: 'Range Not Satisfiable',
+        417: 'Expectation Failed',
+        418: 'I\'m a teapot',
+        421: 'Misdirection Request',
+        422: 'Unprocessable Entity',
+        423: 'Locked',
+        424: 'Failed Dependency',
+        425: 'Too Early',
+        426: 'Upgrade Required',
+        428: 'Precondition Required',
+        429: 'Too Many Requests',
+        431: 'Request Header Fields Too Large',
+        451: 'Unavailable For Legal Reasons',
+        500: 'Internal Server Error',
+        501: 'Not Implemented',
+        502: 'Bad Gateway',
+        503: 'Service Unavailable',
+        504: 'Gateway Timeout',
+        505: 'HTTP Version Not Supported',
+        506: 'Variant Also Negotiates',
+        507: 'Insufficient Storage',
+        508: 'Loop Detected',
+        510: 'Not Extended',
+        511: 'Network Authentication Required',
+    };
+
 
     def __init__(self, sValidationKitDir, fHtmlDebugOutput = True):
@@ -70,6 +137,6 @@
         self._fHtmlDebugOutput = fHtmlDebugOutput; # For trace
         self._oDbgFile         = sys.stderr;
-        if config.g_ksSrcGlueDebugLogDst is not None and config.g_kfSrvGlueDebug is True:
-            self._oDbgFile = open(config.g_ksSrcGlueDebugLogDst, 'a');
+        if config.g_ksSrvGlueDebugLogDst is not None and config.g_kfSrvGlueDebug is True:
+            self._oDbgFile = open(config.g_ksSrvGlueDebugLogDst, 'a');
             if config.g_kfSrvGlueCgiDumpArgs:
                 self._oDbgFile.write('Arguments: %s\nEnvironment:\n' % (sys.argv,));
@@ -221,4 +288,5 @@
         Worker function which child classes can override.
         """
+        sys.stderr.write('_writeHeader: cch=%s "%s..."\n' % (len(sHeaderLine), sHeaderLine[0:10],))
         self.oOutputText.write(sHeaderLine);
         return True;
@@ -257,8 +325,19 @@
         return True;
 
+    def setStatus(self, iStatus, sMsg = None):
+        """ Sets the status code. """
+        if not sMsg:
+            sMsg = self.kdStatusMsgs[iStatus];
+        return self.setHeaderField('Status', '%u %s' % (iStatus, sMsg));
+
+    def setContentType(self, sType):
+        """ Sets the content type header field. """
+        return self.setHeaderField('Content-Type', sType);
+
     def _writeWorker(self, sChunkOfHtml):
         """
         Worker function which child classes can override.
         """
+        sys.stderr.write('_writeWorker: cch=%s "%s..."\n' % (len(sChunkOfHtml), sChunkOfHtml[0:10],))
         self.oOutputText.write(sChunkOfHtml);
         return True;
@@ -286,7 +365,7 @@
         """
         if self._sBodyType is None:
-            self._sBodyType = 'html';
-        elif self._sBodyType != 'html':
-            raise WebServerGlueException('Cannot use writeParameter when body type is "%s"' % (self._sBodyType, ));
+            self._sBodyType = 'raw';
+        elif self._sBodyType != 'raw':
+            raise WebServerGlueException('Cannot use writeRaw when body type is "%s"' % (self._sBodyType, ));
 
         self.flushHeader();
@@ -294,4 +373,5 @@
             self.flush();
 
+        sys.stderr.write('writeRaw: cb=%s\n' % (len(writeRaw),))
         self.oOutputRaw.write(abChunk);
         return True;
Index: /trunk/src/VBox/ValidationKit/testmanager/db/TestManagerDatabaseInit.pgsql
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/db/TestManagerDatabaseInit.pgsql	(revision 84598)
+++ /trunk/src/VBox/ValidationKit/testmanager/db/TestManagerDatabaseInit.pgsql	(revision 84599)
@@ -1313,4 +1313,28 @@
     UNIQUE (sRepository, iRevision)
 );
+
+
+--- @table VcsBugReferences
+-- This is for relating commits to a bug and vice versa.
+--
+-- This feature isn't so much for the test manager as a cheap way of extending
+-- bug trackers without VCS integration.  We just need to parse the commit
+-- messages when inserting them into the VcsRevisions table.
+--
+-- Same input, updating and history considerations as VcsRevisions.
+--
+CREATE TABLE VcsBugReferences (
+    --- The version control tree name.
+    sRepository         TEXT        NOT NULL,
+    --- The version control tree revision number.
+    iRevision           INTEGER     NOT NULL,
+    --- The bug tracker identifier - see g_kaBugTrackers in config.py.
+    sBugTracker         CHAR(4)     NOT NULL,
+    --- The bug number in the bug tracker.
+    lBugNo              BIGINT      NOT NULL,
+
+    UNIQUE (sRepository, iRevision, sBugTracker, lBugNo)
+);
+CREATE INDEX VcsBugReferencesLookupIdx ON VcsBugReferences (sBugTracker, lBugNo);
 
 
Index: /trunk/src/VBox/ValidationKit/testmanager/db/tmdb-r24-vcsbugreferences-1.pgsql
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/db/tmdb-r24-vcsbugreferences-1.pgsql	(revision 84599)
+++ /trunk/src/VBox/ValidationKit/testmanager/db/tmdb-r24-vcsbugreferences-1.pgsql	(revision 84599)
@@ -0,0 +1,49 @@
+-- $Id$
+--- @file
+-- VBox Test Manager Database - Creates a new VcsBugReferences table.
+--
+
+--
+-- Copyright (C) 2020 Oracle Corporation
+--
+-- This file is part of VirtualBox Open Source Edition (OSE), as
+-- available from http://www.virtualbox.org. This file is free software;
+-- you can redistribute it and/or modify it under the terms of the GNU
+-- General Public License (GPL) as published by the Free Software
+-- Foundation, in version 2 as it comes in the "COPYING" file of the
+-- VirtualBox OSE distribution. VirtualBox OSE is distributed in the
+-- hope that it will be useful, but WITHOUT ANY WARRANTY of any kind.
+--
+-- The contents of this file may alternatively be used under the terms
+-- of the Common Development and Distribution License Version 1.0
+-- (CDDL) only, as it comes in the "COPYING.CDDL" file of the
+-- VirtualBox OSE distribution, in which case the provisions of the
+-- CDDL are applicable instead of those of the GPL.
+--
+-- You may elect to license modified versions of this file under the
+-- terms and conditions of either the GPL or the CDDL or both.
+--
+
+-- Die on error from now on.
+\set ON_ERROR_STOP 1
+\set AUTOCOMMIT 0
+
+--
+-- Create the new VcsBugReferences table.
+--
+CREATE TABLE VcsBugReferences (
+    --- The version control tree name.
+    sRepository         TEXT        NOT NULL,
+    --- The version control tree revision number.
+    iRevision           INTEGER     NOT NULL,
+    --- The bug tracker identifier - see g_kaBugTrackers in config.py.
+    sBugTracker         CHAR(4)     NOT NULL,
+    --- The bug number in the bug tracker.
+    lBugNo              BIGINT      NOT NULL,
+
+    UNIQUE (sRepository, iRevision, sBugTracker, lBugNo)
+);
+CREATE INDEX VcsBugReferencesLookupIdx ON VcsBugReferences (sBugTracker, lBugNo);
+COMMIT;
+\d+ VcsBugReferences;
+
Index: /trunk/src/VBox/ValidationKit/testmanager/htdocs/css/common.css
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/htdocs/css/common.css	(revision 84598)
+++ /trunk/src/VBox/ValidationKit/testmanager/htdocs/css/common.css	(revision 84599)
@@ -1155,4 +1155,12 @@
 }
 
+.tmvcstooltipnew {
+    padding:    0px;
+    min-width:  50em;
+    overflow:   hidden;
+    border:     0px none;
+    background-color: #f9f9f9;
+}
+
 
 /*
Index: /trunk/src/VBox/ValidationKit/testmanager/htdocs/css/tooltip.css
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/htdocs/css/tooltip.css	(revision 84598)
+++ /trunk/src/VBox/ValidationKit/testmanager/htdocs/css/tooltip.css	(revision 84599)
@@ -28,9 +28,9 @@
  * Form the main divs in template-tooltip.html.
  */
-#tooltip {
+.tooltip-main {
     width:          100%;
 }
 
-#tooltip-inner {
+.tooltip-inner {
     clear:          both;
     border:         2px solid black;
Index: /trunk/src/VBox/ValidationKit/testmanager/htdocs/js/common.js
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/htdocs/js/common.js	(revision 84598)
+++ /trunk/src/VBox/ValidationKit/testmanager/htdocs/js/common.js	(revision 84599)
@@ -144,4 +144,123 @@
             return true;
     return false;
+}
+
+/**
+ * Parses a typical ISO timestamp, returing a Date object, reasonably
+ * forgiving, but will throw weird indexing/conversion errors if the input
+ * is malformed.
+ *
+ * @returns Date object.
+ * @param   sTs             The timestamp to parse.
+ * @sa      parseIsoTimestamp() in utils.py.
+ */
+function parseIsoTimestamp(sTs)
+{
+    /* YYYY-MM-DD */
+    var iYear  = parseInt(sTs.substring(0, 4), 10);
+    console.assert(sTs.charAt(4) == '-');
+    var iMonth = parseInt(sTs.substring(5, 7), 10);
+    console.assert(sTs.charAt(7) == '-');
+    var iDay   = parseInt(sTs.substring(8, 10), 10);
+
+    /* Skip separator */
+    var sTime = sTs.substring(10);
+    while ('Tt \t\n\r'.includes(sTime.charAt(0))) {
+        sTime = sTime.substring(1);
+    }
+
+    /* HH:MM:SS */
+    var iHour = parseInt(sTime.substring(0, 2), 10);
+    console.assert(sTime.charAt(2) == ':');
+    var iMin  = parseInt(sTime.substring(3, 5), 10);
+    console.assert(sTime.charAt(5) == ':');
+    var iSec  = parseInt(sTime.substring(6, 8), 10);
+
+    /* Fraction? */
+    var offTime = 8;
+    var iMicroseconds = 0;
+    if (offTime < sTime.length && '.,'.includes(sTime.charAt(offTime)))
+    {
+        offTime += 1;
+        var cchFraction = 0;
+        while (offTime + cchFraction < sTime.length && '0123456789'.includes(sTime.charAt(offTime + cchFraction)))
+            cchFraction += 1;
+        if (cchFraction > 0)
+        {
+            iMicroseconds = parseInt(sTime.substring(offTime, offTime + cchFraction), 10);
+            offTime += cchFraction;
+            while (cchFraction < 6)
+            {
+                iMicroseconds *= 10;
+                cchFraction += 1;
+            }
+            while (cchFraction > 6)
+            {
+                iMicroseconds = iMicroseconds / 10;
+                cchFraction -= 1;
+            }
+        }
+    }
+    var iMilliseconds = (iMicroseconds + 499) / 1000;
+
+    /* Naive? */
+    var oDate = new Date(Date.UTC(iYear, iMonth, iDay, iHour, iMin, iSec, iMilliseconds));
+    if (offTime >= sTime.length)
+        return oDate;
+
+    /* Zulu? */
+    if (offTime >= sTime.length || 'Zz'.includes(sTime.charAt(offTime)))
+        return oDate;
+
+    /* Some kind of offset afterwards. */
+    var chSign = sTime.charAt(offTime);
+    if ('+-'.includes(chSign))
+    {
+        offTime += 1;
+        var cMinTz = parseInt(sTime.substring(offTime, offTime + 2), 10) * 60;
+        offTime += 2;
+        if (offTime  < sTime.length && sTime.charAt(offTime) == ':')
+            offTime += 1;
+        if (offTime + 2 <= sTime.length)
+        {
+            cMinTz += parseInt(sTime.substring(offTime, offTime + 2), 10);
+            offTime += 2;
+        }
+        console.assert(offTime == sTime.length);
+        if (chSign == '-')
+            cMinTz = -cMinTz;
+
+        return new Date(oDate.getTime() + cMinTz * 60000);
+    }
+    console.assert(false);
+    return oDate;
+}
+
+/**
+ * Escapes special characters to HTML-safe sequences, for element use.
+ *
+ * @returns Escaped string suitable for HTML.
+ * @param   sText               Plain text to escape.
+ */
+function escapeElem(sText)
+{
+    sText = sText.replace(/&/g, '&amp;');
+    sText = sText.replace(/>/g, '&lt;');
+    return  sText.replace(/</g, '&gt;');
+}
+
+/**
+ * Escapes special characters to HTML-safe sequences, for double quoted
+ * attribute use.
+ *
+ * @returns Escaped string suitable for HTML.
+ * @param   sText               Plain text to escape.
+ */
+function escapeAttr(sText)
+{
+    sText = sText.replace(/&/g, '&amp;');
+    sText = sText.replace(/</g, '&lt;');
+    sText = sText.replace(/>/g, '&gt;');
+    return  sText.replace(/"/g, '&quot;');
 }
 
@@ -692,4 +811,7 @@
  */
 
+/** Enables non-iframe tooltip code. */
+var g_fNewTooltips       = true;
+
 /** Where we keep tooltip elements when not displayed. */
 var g_dTooltips          = {};
@@ -902,6 +1024,6 @@
 function tooltipElementOnMouseEnter()
 {
-    //console.log('tooltipElementOnMouseEnter: arguments.length='+arguments.length+' [0]='+arguments[0]);
-    //console.log('ENT: currentTarget='+arguments[0].currentTarget);
+    console.log('tooltipElementOnMouseEnter: arguments.length='+arguments.length+' [0]='+arguments[0]);
+    console.log('ENT: currentTarget='+arguments[0].currentTarget+' id='+arguments[0].currentTarget.id+' class='+arguments[0].currentTarget.className);
     tooltipResetShowTimer();
     tooltipResetHideTimer();
@@ -919,6 +1041,25 @@
 function tooltipElementOnMouseOut()
 {
-    //console.log('tooltipElementOnMouseOut: arguments.length='+arguments.length+' [0]='+arguments[0]);
-    //console.log('OUT: currentTarget='+arguments[0].currentTarget);
+    var oEvt = arguments[0];
+    console.log('tooltipElementOnMouseOut: arguments.length='+arguments.length+' [0]='+oEvt);
+    console.log('OUT: currentTarget='+oEvt.currentTarget+' id='+oEvt.currentTarget.id+' class='+oEvt.currentTarget.className);
+
+    /* Ignore the event if leaving to a child element. */
+    var oElm = oEvt.toElement || oEvt.relatedTarget;
+    if (oElm != this && oElm)
+    {
+        for (;;)
+        {
+            oElm = oElm.parentNode;
+            if (!oElm || oElm == window)
+                break;
+            if (oElm == this)
+            {
+                console.log('OUT: was to child! - ignore');
+                return false;
+            }
+        }
+    }
+
     tooltipHide();
     return true;
@@ -931,7 +1072,7 @@
  * work around various browser differences too.
  */
-function svnHistoryTooltipOnLoad()
-{
-    //console.log('svnHistoryTooltipOnLoad');
+function svnHistoryTooltipOldOnLoad()
+{
+    //console.log('svnHistoryTooltipOldOnLoad');
 
     /*
@@ -953,5 +1094,5 @@
         if (cx >= cxNeeded)
         {
-            //console.log('svnHistoryTooltipOnLoad: overflowX -> hidden');
+            //console.log('svnHistoryTooltipOldOnLoad: overflowX -> hidden');
             oIFrameElement.style.overflowX = 'hidden';
         }
@@ -975,5 +1116,5 @@
         if (cy >= cyNeeded)
         {
-            //console.log('svnHistoryTooltipOnLoad: overflowY -> hidden');
+            //console.log('svnHistoryTooltipOldOnLoad: overflowY -> hidden');
             oIFrameElement.style.overflowY = 'hidden';
         }
@@ -985,9 +1126,162 @@
         //console.log('cyNeeded='+cyNeeded+' cyMax='+g_oCurrentTooltip.cyMax+' cySpace='+cySpace+' cy='+cy);
         //console.log('oIFrameElement.offsetTop='+oIFrameElement.offsetTop);
-        //console.log('svnHistoryTooltipOnLoad: cx='+cx+'cxMax='+g_oCurrentTooltip.cxMax+' cxNeeded='+cxNeeded+' cy='+cy+' cyMax='+g_oCurrentTooltip.cyMax);
+        //console.log('svnHistoryTooltipOldOnLoad: cx='+cx+'cxMax='+g_oCurrentTooltip.cxMax+' cxNeeded='+cxNeeded+' cy='+cy+' cyMax='+g_oCurrentTooltip.cyMax);
 
         tooltipRepositionOnLoad();
     }
     return true;
+}
+
+/**
+ * iframe.onload hook that repositions and resizes the tooltip.
+ *
+ * This is a little hacky and we're calling it one or three times too many to
+ * work around various browser differences too.
+ */
+function svnHistoryTooltipNewOnLoad()
+{
+    //console.log('svnHistoryTooltipNewOnLoad');
+
+    /*
+     * Resize the tooltip to better fit the content.
+     */
+    tooltipRepositionOnLoad(); /* Sets cxMax and cyMax. */
+    oTooltip = g_oCurrentTooltip;
+    if (oTooltip)
+    {
+        var oElmInner = oTooltip.oInnerElm;
+        var cxSpace  = Math.max(oElmInner.offsetLeft * 2, 0); /* simplified */
+        var cySpace  = Math.max(oElmInner.offsetTop  * 2, 0); /* simplified */
+        var cxNeeded = oElmInner.scrollWidth  + cxSpace;
+        var cyNeeded = oElmInner.scrollHeight + cySpace;
+        var cx = Math.min(cxNeeded, oTooltip.cxMax);
+
+        oTooltip.oElm.width = cx + 'px';
+        oElmInner.width     = (cx - cxSpace) + 'px';
+        if (cx >= cxNeeded)
+        {
+            //console.log('svnHistoryTooltipNewOnLoad: overflowX -> hidden');
+            oElmInner.style.overflowX = 'hidden';
+        }
+        else
+        {
+            oElmInner.style.overflowX = 'scroll';
+        }
+
+        var cy = Math.min(cyNeeded, oTooltip.cyMax);
+        if (cyNeeded > oTooltip.cyMax && oTooltip.cyMaxUp > 0)
+        {
+            var cyMove = Math.min(cyNeeded - oTooltip.cyMax, oTooltip.cyMaxUp);
+            oTooltip.cyMax += cyMove;
+            oTooltip.yPos  -= cyMove;
+            oTooltip.oElm.style.top = oTooltip.yPos + 'px';
+            cy = Math.min(cyNeeded, oTooltip.cyMax);
+        }
+
+        oTooltip.oElm.height = cy + 'px';
+        oElmInner.height     = (cy - cySpace) + 'px';
+        if (cy >= cyNeeded)
+        {
+            //console.log('svnHistoryTooltipNewOnLoad: overflowY -> hidden');
+            oElmInner.style.overflowY = 'hidden';
+        }
+        else
+        {
+            oElmInner.style.overflowY = 'scroll';
+        }
+
+        //console.log('cyNeeded='+cyNeeded+' cyMax='+oTooltip.cyMax+' cySpace='+cySpace+' cy='+cy);
+        //console.log('oElmInner.offsetTop='+oElmInner.offsetTop);
+        //console.log('svnHistoryTooltipNewOnLoad: cx='+cx+'cxMax='+oTooltip.cxMax+' cxNeeded='+cxNeeded+' cy='+cy+' cyMax='+oTooltip.cyMax);
+
+        tooltipRepositionOnLoad();
+    }
+    return true;
+}
+
+
+function svnHistoryTooltipNewOnReadState(oTooltip, oRestReq, oParent)
+{
+    console.log('svnHistoryTooltipNewOnReadState');
+    console.log('svnHistoryTooltipNewOnReadState');
+
+    /*
+     * Check the result and translate it to a javascript object (oResp).
+     */
+    var oResp = null;
+    var sHtml;
+    if (oRestReq.status != 200)
+    {
+        console.log('svnHistoryTooltipNewOnReadState: status=' + oRestReq.status);
+        sHtml = '<p>error: status=' + oRestReq.status + '</p>';
+    }
+    else
+    {
+        try
+        {
+            oResp = JSON.parse(oRestReq.responseText);
+        }
+        catch (oEx)
+        {
+            console.log('JSON.parse threw: ' + oEx.toString());
+            console.log(oRestReq.responseText);
+            sHtml = '<p>error: JSON.parse threw: ' + oEx.toString() + '</p>';
+        }
+    }
+
+    /*
+     * Generate the HTML.
+     *
+     * Note! Make sure the highlighting code in svnHistoryTooltipNewDelayedShow
+     *       continues to work after modifying this code.
+     */
+    if (oResp)
+    {
+        var asDaysOfTheWeek = [ 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday' ];
+        sHtml = '<div class="tmvcstimeline tmvcstimelinetooltip">\n';
+
+        var aoCommits = oResp.aoCommits;
+        var cCommits  = oResp.aoCommits.length;
+        var iCurDay   = null;
+        var i;
+        for (i = 0; i < cCommits; i++)
+        {
+            var oCommit    = aoCommits[i];
+            var tsCreated  = parseIsoTimestamp(oCommit.tsCreated);
+            var iCommitDay = Math.floor((tsCreated.getTime() + tsCreated.getTimezoneOffset()) / (24 * 60 * 60 * 1000));
+            if (iCurDay === null || iCurDay != iCommitDay)
+            {
+                if (iCurDay !== null)
+                    sHtml += ' </dl>\n';
+                iCurDay = iCommitDay;
+                sHtml += ' <h2>' + tsCreated.toISOString().split('T')[0] + ' ' + asDaysOfTheWeek[tsCreated.getDay()] + '</h2>\n';
+                sHtml += ' <dl>\n';
+            }
+            Date
+
+            var sHighligh = '';
+            if (oCommit.iRevision == oTooltip.iRevision)
+                sHighligh += ' class="tmvcstimeline-highlight"';
+
+            sHtml += '  <dt id="r' + oCommit.iRevision + '"' + sHighligh + '>';
+            sHtml += '<a href="' + oResp.sTracChangesetUrlFmt.replace('%(iRevision)s', oCommit.iRevision.toString());
+            sHtml += '" target="_blank">';
+            sHtml += '<span class="tmvcstimeline-time">' + escapeElem(tsCreated.toLocaleTimeString()) + '</span>'
+            sHtml += ' Changeset <span class="tmvcstimeline-rev">[' + oCommit.iRevision + ']</span>';
+            sHtml += ' by <span class="tmvcstimeline-author">' + escapeElem(oCommit.sAuthor) + '</span>';
+            sHtml += '</a></dt>\n';
+            sHtml += '  <dd' + sHighligh + '>' + escapeElem(oCommit.sMessage) + '</dd>\n';
+        }
+
+        if (iCurDay !== null)
+            sHtml += ' </dl>\n';
+        sHtml += '</div>';
+    }
+
+    console.log('svnHistoryTooltipNewOnReadState: sHtml=' + sHtml);
+    oTooltip.oInnerElm.innerHTML = sHtml;
+
+    tooltipReallyShow(oTooltip, oParent);
+    svnHistoryTooltipNewOnLoad();
 }
 
@@ -1038,10 +1332,10 @@
     //console.log('svnHistoryTooltipShow ' + sRepository);
 
-    function svnHistoryTooltipDelayedShow()
+    function svnHistoryTooltipOldDelayedShow()
     {
         var sSrc;
 
         var oTooltip = g_dTooltips[sKey];
-        //console.log('svnHistoryTooltipDelayedShow ' + sRepository + ' ' + oTooltip);
+        //console.log('svnHistoryTooltipOldDelayedShow ' + sRepository + ' ' + oTooltip);
         if (!oTooltip)
         {
@@ -1071,5 +1365,5 @@
             oIFrameElement.style.position = 'relative';
             oIFrameElement.onmouseenter   = tooltipElementOnMouseEnter;
-            oIFrameElement.onmouseout     = tooltipElementOnMouseOut;
+            //oIFrameElement.onmouseout     = tooltipElementOnMouseOut;
             oTooltip.oElm.appendChild(oIFrameElement);
             oTooltip.oIFrame = oIFrameElement;
@@ -1082,5 +1376,5 @@
                                 /*console.log('iframe/onload');*/
                                 tooltipReallyShow(oTooltip, oParent);
-                                svnHistoryTooltipOnLoad();
+                                svnHistoryTooltipOldOnLoad();
                            }, isBrowserInternetExplorer() ? 256 : 128);
             };
@@ -1105,5 +1399,5 @@
                     setTimeout(function() { /* Slight delay to make sure it scrolls before it's shown. */
                                    tooltipReallyShow(oTooltip, oParent);
-                                   svnHistoryTooltipOnLoad();
+                                   svnHistoryTooltipOldOnLoad();
                                }, isBrowserInternetExplorer() ? 256 : 64);
                 }
@@ -1114,8 +1408,99 @@
             {
                 tooltipReallyShow(oTooltip, oParent);
-                svnHistoryTooltipOnLoad();
-            }
-        }
-    }
+                svnHistoryTooltipOldOnLoad();
+            }
+        }
+    }
+
+    function svnHistoryTooltipNewDelayedShow()
+    {
+        var sSrc;
+
+        var oTooltip = g_dTooltips[sKey];
+        console.log('svnHistoryTooltipNewDelayedShow: ' + sRepository + ' ' + oTooltip);
+        if (!oTooltip)
+        {
+            /*
+             * Create a new tooltip element.
+             */
+            console.log('creating ' + sKey);
+
+            var oElm = document.createElement('div');
+            oElm.setAttribute('id', sKey);
+            oElm.className      = 'tmvcstooltipnew';
+            //oElm.setAttribute('style', 'display:none; position: absolute;');
+            oElm.style.display  = 'none';  /* Note! Must stay hidden till loaded, or parent jumps with #rXXXX.*/
+            oElm.style.position = 'absolute';
+            oElm.style.zIndex   = 6001;
+            oElm.onmouseenter   = tooltipElementOnMouseEnter;
+            oElm.onmouseout     = tooltipElementOnMouseOut;
+
+            var oInnerElm = document.createElement('div');
+            oInnerElm.className = 'tooltip-inner';
+            oElm.appendChild(oInnerElm);
+
+            oTooltip = {};
+            oTooltip.oElm      = oElm;
+            oTooltip.oInnerElm = oInnerElm;
+            oTooltip.xPos      = 0;
+            oTooltip.yPos      = 0;
+            oTooltip.cxMax     = 0;
+            oTooltip.cyMax     = 0;
+            oTooltip.cyMaxUp   = 0;
+            oTooltip.xScroll   = 0;
+            oTooltip.yScroll   = 0;
+            oTooltip.iRevision = iRevision;   /**< For  :target/highlighting */
+
+            oRestReq = new XMLHttpRequest();
+            oRestReq.onreadystatechange = function() { svnHistoryTooltipNewOnReadState(oTooltip, this, oParent); }
+            oRestReq.open('GET', sUrlPrefix + 'rest.py?sPath=vcs/changelog/' + sRepository
+                          + '/' + svnHistoryTooltipCalcLastRevision(iRevision) + '/' + g_cTooltipSvnRevisions);
+            oRestReq.setRequestHeader('Content-type', 'application/json');
+
+            document.body.appendChild(oTooltip.oElm);
+            g_dTooltips[sKey] = oTooltip;
+
+            oRestReq.send('');
+        }
+        else
+        {
+            /*
+             * Show the existing one, possibly with different highlighting.
+             * Note! Update this code when changing svnHistoryTooltipNewOnReadState.
+             */
+            if (oTooltip.iRevision != iRevision)
+            {
+                //console.log('Changing revision ' + oTooltip.iRevision + ' -> ' + iRevision);
+                var oElmTimelineDiv = oTooltip.oInnerElm.firstElementChild;
+                var i;
+                for (i = 0; i < oElmTimelineDiv.children.length; i++)
+                {
+                    var oElm = oElmTimelineDiv.children[i];
+                    //console.log('oElm='+oElm+' id='+oElm.id+' nodeName='+oElm.nodeName);
+                    if (oElm.nodeName == 'DL')
+                    {
+                        var iCurRev = iRevision - 64;
+                        var j;
+                        for (j = 0; i < oElm.children.length; i++)
+                        {
+                            var oDlSubElm = oElm.children[i];
+                            //console.log(' oDlSubElm='+oDlSubElm+' id='+oDlSubElm.id+' nodeName='+oDlSubElm.nodeName+' className='+oDlSubElm.className);
+                            if (oDlSubElm.id.length > 2)
+                                iCurRev = parseInt(oDlSubElm.id.substring(1), 10);
+                            if (iCurRev == iRevision)
+                                oDlSubElm.className = 'tmvcstimeline-highlight';
+                            else
+                                oDlSubElm.className = '';
+                        }
+                    }
+                }
+                oTooltip.iRevision = iRevision;
+            }
+
+            tooltipReallyShow(oTooltip, oParent);
+            svnHistoryTooltipNewOnLoad();
+        }
+    }
+
 
     /*
@@ -1123,5 +1508,8 @@
      */
     tooltipResetShowTimer();
-    g_idTooltipShowTimer = setTimeout(svnHistoryTooltipDelayedShow, 512);
+    if (g_fNewTooltips)
+        g_idTooltipShowTimer = setTimeout(svnHistoryTooltipNewDelayedShow, 512);
+    else
+        g_idTooltipShowTimer = setTimeout(svnHistoryTooltipOldDelayedShow, 512);
 }
 
@@ -1137,5 +1525,5 @@
 function svnHistoryTooltipShow(oEvt, sRepository, iRevision)
 {
-    return svnHistoryTooltipShowEx(oEvt, sRepository, iRevision, '')
+    return svnHistoryTooltipShowEx(oEvt, sRepository, iRevision, '');
 }
 
Index: /trunk/src/VBox/ValidationKit/testmanager/webui/template-details.html
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/webui/template-details.html	(revision 84598)
+++ /trunk/src/VBox/ValidationKit/testmanager/webui/template-details.html	(revision 84599)
@@ -8,4 +8,5 @@
         <link href="htdocs/images/tmfavicon.ico"    rel="icon"          type="image/x-icon" />
         <link href="htdocs/css/common.css"          rel="stylesheet"    type="text/css" media="screen" />
+        <link href="htdocs/css/tooltip.css"         rel="stylesheet"    type="text/css" media="screen" />
         <link href="htdocs/css/details.css"         rel="stylesheet"    type="text/css" media="screen" />
         <script type="text/javascript" src="htdocs/js/common.js"></script>
Index: /trunk/src/VBox/ValidationKit/testmanager/webui/template-graphwiz.html
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/webui/template-graphwiz.html	(revision 84598)
+++ /trunk/src/VBox/ValidationKit/testmanager/webui/template-graphwiz.html	(revision 84599)
@@ -8,4 +8,5 @@
         <link href="htdocs/images/tmfavicon.ico"    rel="icon"          type="image/x-icon" />
         <link href="htdocs/css/common.css"          rel="stylesheet"    type="text/css" media="screen" />
+        <link href="htdocs/css/tooltip.css"         rel="stylesheet"    type="text/css" media="screen" />
         <link href="htdocs/css/graphwiz.css"        rel="stylesheet"    type="text/css" media="screen" />
         <script type="text/javascript" src="htdocs/js/common.js"></script>
Index: /trunk/src/VBox/ValidationKit/testmanager/webui/template-tooltip.html
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/webui/template-tooltip.html	(revision 84598)
+++ /trunk/src/VBox/ValidationKit/testmanager/webui/template-tooltip.html	(revision 84599)
@@ -12,5 +12,5 @@
 <body scroll="no">
 <div id="tooltip" class="tooltip-main">
-<div id="tooltip-inner">
+<div id="tooltip-inner" class="tooltip-inner">
 @@PAGE_BODY@@
 </div>
Index: /trunk/src/VBox/ValidationKit/testmanager/webui/template.html
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/webui/template.html	(revision 84598)
+++ /trunk/src/VBox/ValidationKit/testmanager/webui/template.html	(revision 84599)
@@ -9,4 +9,5 @@
         <link href="htdocs/images/tmfavicon.ico"    rel="icon"          type="image/x-icon" />
         <link href="htdocs/css/common.css"          rel="stylesheet"    type="text/css" media="screen" />
+        <link href="htdocs/css/tooltip.css"         rel="stylesheet"    type="text/css" media="screen" />
         <script type="text/javascript" src="htdocs/js/common.js"></script>
         <title>@@PAGE_TITLE@@</title>
