diff --git a/setup.py b/setup.py
index efd434962295aa2326931d8376aa7ca57523e860..5738c0f048b2603c5a5b5818b9b0225c91bf90e2 100644
--- a/setup.py
+++ b/setup.py
@@ -33,4 +33,9 @@ setuptools.setup(
     python_requires=">=3.8",
     license="MIT",
     install_requires=['numpy', 'tabulate', "pyfiglet", "coverage", "colorama", 'tqdm', 'importnb', 'requests'],
+    include_package_data=True,
+    package_data={'': ['dashboard/static/*', 'dashboard/templates/*'],},  # so far no Manifest.in.
+    entry_points={
+        'console_scripts': ['unitgrade=unitgrade.dashboard.dashboard_cli:main'],
+    }
 )
diff --git a/src/unitgrade.egg-info/PKG-INFO b/src/unitgrade.egg-info/PKG-INFO
index a7fb7b057d3f62cf356cae4f84df980d637ea7a1..3a139452d75ff701fc5367a1f6434da51023a3a4 100644
--- a/src/unitgrade.egg-info/PKG-INFO
+++ b/src/unitgrade.egg-info/PKG-INFO
@@ -1,12 +1,13 @@
 Metadata-Version: 2.1
 Name: unitgrade
-Version: 0.1.26
+Version: 0.1.28.1
 Summary: A student homework/exam evaluation framework build on pythons unittest framework.
 Home-page: https://lab.compute.dtu.dk/tuhe/unitgrade
 Author: Tue Herlau
 Author-email: tuhe@dtu.dk
 License: MIT
 Project-URL: Bug Tracker, https://lab.compute.dtu.dk/tuhe/unitgrade/issues
+Platform: UNKNOWN
 Classifier: Programming Language :: Python :: 3
 Classifier: License :: OSI Approved :: MIT License
 Classifier: Operating System :: OS Independent
@@ -190,3 +191,4 @@ Please contact me and we can discuss your specific concerns.
 	year={2022},
 }
 ```
+
diff --git a/src/unitgrade.egg-info/SOURCES.txt b/src/unitgrade.egg-info/SOURCES.txt
index e6a1310b464bf9d5bad36adf1eb174649341eef5..aae28ff784245da80b912c94b14b0f635526c5cc 100644
--- a/src/unitgrade.egg-info/SOURCES.txt
+++ b/src/unitgrade.egg-info/SOURCES.txt
@@ -3,6 +3,7 @@ README.md
 pyproject.toml
 setup.py
 src/unitgrade/__init__.py
+src/unitgrade/artifacts.py
 src/unitgrade/evaluate.py
 src/unitgrade/framework.py
 src/unitgrade/runners.py
@@ -11,5 +12,19 @@ src/unitgrade/version.py
 src/unitgrade.egg-info/PKG-INFO
 src/unitgrade.egg-info/SOURCES.txt
 src/unitgrade.egg-info/dependency_links.txt
+src/unitgrade.egg-info/entry_points.txt
 src/unitgrade.egg-info/requires.txt
-src/unitgrade.egg-info/top_level.txt
\ No newline at end of file
+src/unitgrade.egg-info/top_level.txt
+src/unitgrade/dashboard/static/favicon.ico
+src/unitgrade/dashboard/static/sidebars.css
+src/unitgrade/dashboard/static/sidebars.js
+src/unitgrade/dashboard/static/unitgrade.css
+src/unitgrade/dashboard/static/unitgrade.js
+src/unitgrade/dashboard/static/wz_js.js
+src/unitgrade/dashboard/static/wz_style.css
+src/unitgrade/dashboard/static/wz_style_modified.css
+src/unitgrade/dashboard/templates/base.html
+src/unitgrade/dashboard/templates/bootstrap.html
+src/unitgrade/dashboard/templates/index3.html
+src/unitgrade/dashboard/templates/terminal.html
+src/unitgrade/dashboard/templates/wz.html
\ No newline at end of file
diff --git a/src/unitgrade.egg-info/requires.txt b/src/unitgrade.egg-info/requires.txt
index 2cf942c4cfd80f2087cb63b0bb69259106eaa8a7..26ed8c6de6d30b0a59f110956f35c47f6b5f9b64 100644
--- a/src/unitgrade.egg-info/requires.txt
+++ b/src/unitgrade.egg-info/requires.txt
@@ -1,7 +1,8 @@
+colorama
+coverage
+importnb
 numpy
-tabulate
 pyfiglet
-coverage
-colorama
+requests
+tabulate
 tqdm
-importnb
diff --git a/src/unitgrade/dashboard/app.py b/src/unitgrade/dashboard/app.py
new file mode 100644
index 0000000000000000000000000000000000000000..85f4cb9c2937b7d2012a53f53c82def8d983bfe1
--- /dev/null
+++ b/src/unitgrade/dashboard/app.py
@@ -0,0 +1,345 @@
+import fnmatch
+from threading import Lock
+import argparse
+import datetime
+import os
+import subprocess
+import logging
+import sys
+import glob
+from pathlib import Path
+from flask import Flask, render_template
+from flask_socketio import SocketIO
+from pupdb.core import PupDB
+from unitgrade_private.hidden_gather_upload import dict2picklestring, picklestring2dict
+from unitgrade.dashboard.app_helpers import get_available_reports
+from unitgrade.dashboard.watcher import Watcher
+from unitgrade.dashboard.file_change_handler import FileChangeHandler
+from unitgrade_private import load_token
+logging.getLogger('werkzeug').setLevel("WARNING")
+
+def mkapp(base_dir="./"):
+    app = Flask(__name__, template_folder="templates", static_folder="static", static_url_path="/static")
+    x = {'watcher': None, 'handler': None}  # maintain program state across functions.
+    app.config["SECRET_KEY"] = "secret!"
+    app.config["fd"] = None
+    app.config["TEMPLATES_AUTO_RELOAD"] = True
+    app.config["child_pid"] = None
+    socketio = SocketIO(app)
+    available_reports = get_available_reports(jobfolder=base_dir)
+    current_report = {}
+    watched_files_lock = Lock()
+    watched_files_dictionary = {}
+
+    def do_something(file_pattern):
+        """
+        Oh crap, `file` has changed on disk. We need to open it, look at it, and then do stuff based on what is in it.
+        That is, we push all chnages in the file to clients.
+
+        We don't know what are on the clients, so perhaps push everything and let the browser resolve it.
+        """
+        with watched_files_lock:
+            file = watched_files_dictionary[file_pattern]['file']
+            type = watched_files_dictionary[file_pattern]['type']
+            lrc = watched_files_dictionary[file_pattern]['last_recorded_change']
+
+        if type == 'question_json': # file.endswith(".json"):
+            if file is None:
+                return # There is nothing to do, the file does not exist.
+            db = PupDB(file)
+            if "state" not in db.keys():  # Test has not really been run yet. There is no reason to submit this change to the UI.
+                return
+            state = db.get('state')
+            key = os.path.basename(file)[:-5]
+            wz = db.get('wz_stacktrace') if 'wz_stacktrace' in db.keys() else None
+            if wz is not None:
+                wz = wz.replace('<div class="traceback">', f'<div class="traceback"><div class="{key}-traceback">')
+                wz += "</div>"
+            coverage_files_changed = db.get('coverage_files_changed') if 'coverage_files_changed' in db.keys() else None
+            if state == "fail":
+                print("State is fail, I am performing update", state, key)
+            socketio.emit('testupdate', {"id":  key, 'state': state, 'stacktrace': wz, 'stdout': db.get('stdout'), 'run_id': db.get('run_id'),
+                                         'coverage_files_changed': coverage_files_changed}, namespace="/status")
+        elif type =='coverage':
+            if lrc is None: # Program startup. We don't care about this.
+                return
+            db = get_report_database()
+            for q in db['questions']:
+                for i in db['questions'][q]['tests']:
+                    # key = '-'.join(i)
+                    test_invalidated = False
+
+                    for f in db['questions'][q]['tests'][i]['coverage_files']:
+
+                        # fnmatch.fnmatch(f, file_pattern)
+                        if fnmatch.fnmatch(file, "**/" + f):
+                            # This file has been matched. The question is now invalid.
+                            test_invalidated = True
+                            break
+
+                    if test_invalidated:
+                        # Why not simply write this bitch into the db?
+                        dbf = current_report['root_dir'] + "/" + current_report['questions'][q]['tests'][i]['artifact_file']
+                        db = PupDB(dbf)
+                        print("A test has been invalidated. Setting coverage files", file)
+                        db.set('coverage_files_changed', [file])
+                        # print("dbf", dbf)
+                        # print("marking a test as invalidated: ", db)
+        elif type =="token":
+            a, b = load_token(file)
+            rs = {}
+            for k in a['details']:
+                for ikey in a['details'][k]['items']:
+                    rs['-'.join(ikey)] = a['details'][k]['items'][ikey]['status']
+            socketio.emit('token_update', {"full_path": file, 'token': os.path.basename(file),
+                                           'results': rs, 'state': 'evaluated'}, namespace="/status")
+        else:
+            raise Exception("Bad type: " + type)
+
+    def get_json_base(jobfolder):
+        return current_report['json']
+
+    def get_report_database():
+        dbjson = get_json_base(base_dir)
+        db = PupDB(dbjson)
+        from unitgrade_private.hidden_gather_upload import picklestring2dict
+        rs = {}
+        for k in db.keys():
+            if k == 'questions':
+                qenc, _ = picklestring2dict(db.get("questions"))
+                rs['questions'] = qenc # This feels like a good place to find the test-file stuff.
+            else:
+                rs[k] = db.get(k)
+
+        lpath_full = Path(os.path.normpath(os.path.dirname(dbjson) + "/../" + os.path.basename(dbjson)[12:-5] + ".py"))
+        rpath = Path(db.get('relative_path'))
+        base = lpath_full.parts[:-len(rpath.parts)]
+
+        rs['local_base_dir_for_test_module'] = str(Path(*base))
+        rs['test_module'] = ".".join(db.get('modules'))
+        return rs
+
+    def select_report_file(json):
+        current_report.clear()
+        for k, v in available_reports[json].items():
+            current_report[k] = v
+
+        for k, v in get_report_database().items():
+            current_report[k] = v
+
+        def mkempty(pattern, type):
+            fls = glob.glob(current_report['root_dir'] + pattern)
+            fls.sort(key=os.path.getmtime)
+            # if type == 'token':
+            #     fls.sort(key= lambda s: int(s[:-6].split("_")[-3]) ) # Sort by points obtained.
+            f = None if len(fls) == 0 else fls[-1] # Bootstrap with the given best matched file.
+            return {'type': type, 'last_recorded_change': None, 'last_handled_change': None, 'file': f}
+        with watched_files_lock:
+            watched_files_dictionary.clear()
+            db = PupDB(json)
+            dct = picklestring2dict(db.get('questions'))[0]
+            for q in dct.values():
+                for i in q['tests'].values():
+                    file = "*/"+i['artifact_file']
+                    watched_files_dictionary[file] = mkempty(file, 'question_json')  # when the file was last changed and when that change was last handled.
+                    for c in i['coverage_files']:
+                        file = "*/"+c
+                        watched_files_dictionary[file] = mkempty(file, "coverage")
+
+            # tdir = "*/"+os.path.dirname(current_report['relative_path_token']) + "/" + os.path.basename(current_report['relative_path'])[:-3] + "*.token"
+            tdir = "*/"+current_report['token_stub'] + "*.token"
+            watched_files_dictionary[tdir] = mkempty(tdir, 'token')
+
+        for l in ['watcher', 'handler']:
+            if x[l] is not None: x[l].close()
+
+        x['watcher'] = Watcher(current_report['root_dir'], watched_files_dictionary, watched_files_lock)
+        x['watcher'].run()
+
+        x['handler'] = FileChangeHandler(watched_files_dictionary, watched_files_lock, do_something)
+        x['handler'].start()
+
+
+    if len(available_reports) == 0:
+        print("Unitgrade was launched in the directory")
+        print(">", base_dir)
+        print("But this directory does not contain any reports. Run unitgrade from a directory containing report files.")
+        sys.exit()
+
+    # x['current_report'] =
+    select_report_file(list(available_reports.keys()).pop())
+
+    @app.route("/app.js")
+    def appjs():
+        return render_template("app.js")
+
+    @socketio.on("ping", namespace="/status")
+    def ping():
+        json = get_json_base(jobfolder=base_dir)[0]
+        socketio.emit("pong", {'base_json': json})
+
+
+    @app.route("/")
+    def index_bare():
+        # select_report_file()
+        return index(list(available_reports.values()).pop()['menu_name'])
+
+    @app.route("/report/<report>")
+    def index(report):
+
+        if report != current_report['menu_name']:
+            for k, r in available_reports.items():
+                if report == r['menu_name']:
+                    select_report_file(k)
+            raise Exception("Bad report selected", report)
+
+        rs = get_report_database()
+        qenc = rs['questions']
+        x = {}
+        for k, v in current_report.items():
+            x[k] = v
+        x['questions'] = {}
+
+        for q in qenc:
+            items = {}
+            for it_key, it_value in qenc[q]['tests'].items():
+                it_key_js = "-".join(it_key)
+                # do a quick formatting of the hints. Split into list by breaking at *.
+                hints = it_value['hints']
+                for k in range(len(hints)):
+                    ahints = []
+                    for h in hints[k][0].split("\n"):
+                        if h.strip().startswith("*"):
+                            ahints.append('')
+                            h = h.strip()[1:]
+                        ahints[-1] += "\n" + h
+                    hints[k] = (ahints, hints[k][1], hints[k][2])
+
+                items[it_key_js] = {'title': it_value['title'], 'hints': hints}
+            x['questions'][q] = {'title': qenc[q]['title'], 'tests': items}
+
+        run_cmd_grade = '.'.join(x['modules']) + "_grade"
+        x['grade_script'] = x['modules'][-1] + "_grade.py"
+        x['run_cmd_grade'] = f"python -m {run_cmd_grade}"
+        x['available_reports'] = available_reports
+        return render_template("index3.html", **x)
+
+    @socketio.on("rerun", namespace="/status")
+    def rerun(data):
+        """write to the child pty. The pty sees this as if you are typing in a real
+        terminal.
+        """
+        db = get_report_database()
+        targs = ".".join( data['test'].split("-") )
+        m = '.'.join(db['modules'])
+        # cmd = f"python -m {m} {targs}"
+        cmd = f"python -m unittest {m}.{targs}"
+        try:
+            out = subprocess.run(cmd, cwd=db['local_base_dir_for_test_module'], shell=True,  check=True, capture_output=True, text=True)
+        except Exception as e: # I think this is related to simple exceptions being treated as errors.
+            print(e)
+            pass
+            # print("oh dear.")
+        for q in db['questions']:
+            for i in db['questions'][q]['tests']:
+                if "-".join(i) == data['test']:
+                    with watched_files_lock:
+                        watched_files_dictionary["*/"+db['questions'][q]['tests'][i]['artifact_file']]['last_recorded_change'] = datetime.datetime.now()
+
+
+    @socketio.on("rerun_all", namespace="/status")
+    def rerun_all(data):
+        """write to the child pty. The pty sees this as if you are typing in a real
+        terminal.
+        """
+        db = get_report_database()
+        # targs = ".".join( data['test'].split("-") )
+        m = '.'.join(db['modules'])
+        # cmd = f"python -m {m}"
+        cmd = f"python -m unittest {m}"
+        try:
+            out = subprocess.run(cmd, cwd=db['local_base_dir_for_test_module'], shell=True,  check=True, capture_output=True, text=True)
+        except Exception as e:
+            pass
+
+    @socketio.on("pty-input", namespace="/pty")
+    def pty_input(data):
+        """write to the child pty. The pty sees this as if you are typing in a real
+        terminal.
+        """
+        if app.config["fd"]:
+            logging.debug("received input from browser: %s" % data["input"])
+            os.write(app.config["fd"], data["input"].encode())
+
+    @app.route("/crash")
+    def navbar():
+        assert False
+
+    @app.route('/wz')
+    def wz():
+        return render_template('wz.html')
+
+    @socketio.on("reconnected", namespace="/status")
+    def client_reconnected(data):
+        """write to the child pty. The pty sees this as if you are typing in a real
+        terminal.
+        """
+        print("Client has reconnected...")
+        with watched_files_lock:
+            for k in watched_files_dictionary:
+                if watched_files_dictionary[k]['type'] in ['token', 'question_json']:
+                    watched_files_dictionary[k]['last_handled_change'] = None
+                elif watched_files_dictionary[k]['type'] == 'coverage':
+                    pass
+                else:
+                    raise Exception()
+
+    closeables = [x['watcher'], x['handler']]
+    return app, socketio, closeables
+
+def main():
+    parser = argparse.ArgumentParser(
+        description=(
+            "A fully functional terminal in your browser. "
+            "https://github.com/cs01/pyxterm.js"
+        ),
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+    )
+    parser.add_argument("-p", "--port", default=5000, help="port to run server on")
+    parser.add_argument("--host",default="127.0.0.1", help="host to run server on (use 0.0.0.0 to allow access from other hosts)",)
+    parser.add_argument("--debug", action="store_true", help="debug the server")
+    parser.add_argument("--version", action="store_true", help="print version and exit")
+    # parser.add_argument("--command", default="bash", help="Command to run in the terminal")
+    # parser.add_argument("--cmd-args",default="", help="arguments to pass to command (i.e. --cmd-args='arg1 arg2 --flag')",)
+    args = parser.parse_args()
+    args_port = 5000
+    args_host = "127.0.0.1"
+
+    from cs108 import deploy
+    deploy.main(with_coverage=True)
+    import subprocess
+    from cs108.report_devel import mk_bad
+    mk_bad()
+    bdir = os.path.dirname(deploy.__file__)
+
+    app, socketio, closeables = mkapp(base_dir=bdir)
+    # app.config["cmd"] = [args.command] + shlex.split(args.cmd_args)
+    green = "\033[92m"
+    end = "\033[0m"
+    log_format = green + "pyxtermjs > " + end + "%(levelname)s (%(funcName)s:%(lineno)s) %(message)s"
+    logging.basicConfig(
+        format=log_format,
+        stream=sys.stdout,
+        level=logging.DEBUG if args.debug else logging.INFO,
+    )
+    logging.info(f"serving on http://{args.host}:{args.port}")
+    debug = args.debug
+    debug = True
+    # os.environ["WERKZEUG_DEBUG_PIN"] = "off"
+    socketio.run(app, debug=debug, port=args_port, host=args.host, allow_unsafe_werkzeug=debug)
+    for c in closeables:
+        c.close()
+    sys.exit()
+
+if __name__ == "__main__":
+    main()
\ No newline at end of file
diff --git a/src/unitgrade/dashboard/app_helpers.py b/src/unitgrade/dashboard/app_helpers.py
new file mode 100644
index 0000000000000000000000000000000000000000..d0ec3ef947f5fee35bd8d543f8a3c92321eb55dc
--- /dev/null
+++ b/src/unitgrade/dashboard/app_helpers.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+from queue import Queue
+from threading import Lock
+import argparse
+import datetime
+import subprocess
+from flask import Flask, render_template
+from flask_socketio import SocketIO
+import pty
+import os
+import subprocess
+import select
+import termios
+import struct
+import fcntl
+import shlex
+import logging
+import sys
+import glob
+from pupdb.core import PupDB
+from unitgrade_private.hidden_gather_upload import picklestring2dict
+from unitgrade_private.hidden_gather_upload import dict2picklestring, picklestring2dict
+from pathlib import Path
+
+def get_available_reports(jobfolder):
+    bdir = os.path.abspath(jobfolder)
+    available_reports = {}
+    if os.path.isdir(bdir):
+        fls = glob.glob(bdir + "/**/main_config_*.json", recursive=True)
+    elif os.path.isfile(bdir):
+        fls = glob.glob(os.path.dirname(bdir) + "/**/main_config_*.json", recursive=True)
+    else:
+        raise Exception("No report files found in the given directory. Start the dashboard in a folder which contains a report test file.")
+
+    for f in fls:
+        db = PupDB(f)
+
+        report_py = db.get('relative_path')
+        lpath_full = Path(os.path.normpath(os.path.dirname(f) + f"/../{os.path.basename(report_py)}"))
+        # rpath =
+        base = lpath_full.parts[:-len(Path(report_py).parts)]
+
+        # rs['local_base_dir_for_test_module'] = str(Path(*base))
+        root_dir = str(Path(*base))
+        token = report_py[:-3] + "_grade.py"
+        available_reports[f] = {'json': f,
+                                'relative_path': report_py,
+                                'root_dir': root_dir,
+                                'title': db.get('title'),
+                                'relative_path_token': None if not os.path.isfile(root_dir + "/" + token) else token,
+                                'menu_name': os.path.basename(report_py),
+                                }
+    return available_reports
\ No newline at end of file
diff --git a/src/unitgrade/dashboard/dashboard_cli.py b/src/unitgrade/dashboard/dashboard_cli.py
new file mode 100644
index 0000000000000000000000000000000000000000..fb07564b5d18ebbccf3232c71bc272021a097d0a
--- /dev/null
+++ b/src/unitgrade/dashboard/dashboard_cli.py
@@ -0,0 +1,62 @@
+from unitgrade.version import __version__
+import argparse
+import os
+import logging
+import sys
+from unitgrade.dashboard.app import mkapp
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description=(
+            "Unitgrade dashboard"
+            "https://lab.compute.dtu.dk/tuhe/unitgrade"
+        ),
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+    )
+    parser.add_argument('dir', nargs='?', default=os.getcwd(), help="Directory to listen in (default to current directory)")
+    parser.add_argument("-p", "--port", default=5000, help="port to run server on")
+    parser.add_argument("--host",default="127.0.0.1", help="host to run server on (use 0.0.0.0 to allow access from other hosts)",)
+    parser.add_argument("--debug", action="store_true", help="debug the server")
+    parser.add_argument("--version", action="store_true", help="print version and exit")
+
+    args = parser.parse_args()
+    if args.version:
+        print(__version__)
+        exit(0)
+    # from cs108 import deploy
+    # deploy.main(with_coverage=True)
+    # import subprocess
+    # subprocess.run("python ", cwd="")
+    # from cs108.report_devel import mk_bad
+    # mk_bad()
+    # bdir = os.path.dirname(deploy.__file__)
+
+    app, socketio, closeables = mkapp(base_dir=args.dir)
+
+    # app.config["cmd"] = [args.command] + shlex.split(args.cmd_args)
+    green = "\033[92m"
+    end = "\033[0m"
+    log_format = green + "pyxtermjs > " + end + "%(levelname)s (%(funcName)s:%(lineno)s) %(message)s"
+    logging.basicConfig(
+        format=log_format,
+        stream=sys.stdout,
+        level=logging.DEBUG if args.debug else logging.INFO,
+    )
+    url = f"http://{args.host}:{args.port}"
+    logging.info(f"Starting unitgrade dashboard version {__version__}")
+    logging.info(f"Serving dashboard on: {url}")
+
+    # debug = args.debug
+    debug = False
+    os.environ["WERKZEUG_DEBUG_PIN"] = "off"
+    import webbrowser
+    # url = 'https://codefather.tech/blog/'
+    webbrowser.open(url)
+    socketio.run(app, debug=debug, port=args.port, host=args.host) # , allow_unsafe_werkzeug=True )
+    for c in closeables:
+        c.close()
+    sys.exit()
+
+if __name__ == "__main__":
+    main()
\ No newline at end of file
diff --git a/src/unitgrade/dashboard/file_change_handler.py b/src/unitgrade/dashboard/file_change_handler.py
new file mode 100644
index 0000000000000000000000000000000000000000..cbc3cb6d99586b0e329c40cf2e50db8d485c17f0
--- /dev/null
+++ b/src/unitgrade/dashboard/file_change_handler.py
@@ -0,0 +1,67 @@
+from threading import Thread
+from queue import Queue, Empty
+import threading
+import datetime
+import time
+
+
+class FileChangeHandler(Thread):
+    def __init__(self, watched_files_dictionary, watched_files_lock, do_something):
+        super().__init__()
+        self.watched_files_dictionary = watched_files_dictionary
+        self.watched_files_lock = watched_files_lock
+        self.do_something = do_something
+        self.stoprequest = threading.Event()
+
+
+    def run(self):
+        # As long as we weren't asked to stop, try to take new tasks from the
+        # queue. The tasks are taken with a blocking 'get', so no CPU
+        # cycles are wasted while waiting.
+        # Also, 'get' is given a timeout, so stoprequest is always checked,
+        # even if there's nothing in the queue.
+        while not self.stoprequest.is_set():
+            ct = datetime.datetime.now()
+            # try:
+
+            file_to_handle = None
+            with self.watched_files_lock:
+                for k, v in self.watched_files_dictionary.items():
+                    if v['last_handled_change'] is None:
+                        file_to_handle = k
+                        break
+                    else:
+                        # This file has been handled recently. Check last change to the file.
+                        if v['last_recorded_change'] is not None:
+                            from datetime import timedelta
+                            if (v['last_recorded_change'] - v['last_handled_change'] ) > timedelta(seconds=0):
+                                file_to_handle = k
+                                break
+
+            if file_to_handle is not None:
+                # Handle the changes made to this exact file.
+                self.do_something(file_to_handle)
+
+                with self.watched_files_lock:
+                    self.watched_files_dictionary[file_to_handle]['last_handled_change'] = datetime.datetime.now()
+
+            time.sleep(min(0.1, (datetime.datetime.now()-ct).seconds ) )
+
+
+    def join(self, timeout=None):
+        # print("Got a stop")
+        self.stoprequest.set()
+        super().join(timeout)
+
+    def close(self):
+        print("Closing change handler..")
+        self.join()
+        print("Closed.")
+
+    #
+    # q = Queue()
+    # try:
+    #     e = q.get_nowait()
+    # except Empty as e:
+    #     print("empty queue. ")
+    # # menial tasks if required.
diff --git a/src/unitgrade/dashboard/static/favicon.ico b/src/unitgrade/dashboard/static/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..67f19c1cabac6f3a8e297d22eb118acf00d3813b
Binary files /dev/null and b/src/unitgrade/dashboard/static/favicon.ico differ
diff --git a/src/unitgrade/dashboard/static/sidebars.css b/src/unitgrade/dashboard/static/sidebars.css
new file mode 100644
index 0000000000000000000000000000000000000000..23f7a870b419ca15ede8fcdf98eb7d5536422af3
--- /dev/null
+++ b/src/unitgrade/dashboard/static/sidebars.css
@@ -0,0 +1,108 @@
+body {
+  min-height: 100vh;
+  min-height: -webkit-fill-available;
+  height: 100%;
+}
+html {
+  height: -webkit-fill-available;
+}
+.box {
+
+}
+
+main {
+  display: flex;
+  display: flex;
+  flex-wrap: nowrap;
+/**  height: -webkit-fill-available; **/
+
+
+min-height: 100vh;
+/*
+
+  */
+  /* max-height: 100vh;
+    overflow-y: hidden;
+  */
+  overflow-x: off;
+}
+
+.b-example-divider {
+  /* flex-shrink: 0;
+  height: 100%;
+
+  */
+  width:         calc(100% - 280px);
+  background-color: rgba(0, 0, 0, .1);
+  border: solid rgba(0, 0, 0, .15);
+  border-width: 1px 0;
+  box-shadow: inset 0 .5em 1.5em rgba(0, 0, 0, .1), inset 0 .125em .5em rgba(0, 0, 0, .15);
+   padding-left: 10px;
+  padding-top: 10px;
+  padding-right: 10px;
+  padding-bottom: 10px;
+
+  /* padding: 0px; */
+}
+
+
+.bi {
+  vertical-align: -.125em;
+  pointer-events: none;
+  fill: currentColor;
+}
+
+.dropdown-toggle { outline: 0; }
+
+.nav-flush .nav-link {
+  border-radius: 0;
+}
+
+.btn-toggle {
+  display: inline-flex;
+  align-items: center;
+  padding: .25rem .5rem;
+  font-weight: 600;
+  color: rgba(0, 0, 0, .65);
+  background-color: transparent;
+  border: 0;
+}
+.btn-toggle:hover,
+.btn-toggle:focus {
+  color: rgba(0, 0, 0, .85);
+  background-color: #d2f4ea;
+}
+
+.btn-toggle::before {
+  width: 1.25em;
+  line-height: 0;
+  content: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='rgba%280,0,0,.5%29' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M5 14l6-6-6-6'/%3e%3c/svg%3e");
+  transition: transform .35s ease;
+  transform-origin: .5em 50%;
+}
+
+.btn-toggle[aria-expanded="true"] {
+  color: rgba(0, 0, 0, .85);
+}
+.btn-toggle[aria-expanded="true"]::before {
+  transform: rotate(90deg);
+}
+
+.btn-toggle-nav a {
+  display: inline-flex;
+  padding: .1875rem .5rem;
+  margin-top: .125rem;
+  margin-left: 1.25rem;
+  text-decoration: none;
+}
+.btn-toggle-nav a:hover,
+.btn-toggle-nav a:focus {
+  background-color: #d2f4ea;
+}
+
+.scrollarea {
+  overflow-y: auto;
+}
+
+.fw-semibold { font-weight: 600; }
+.lh-tight { line-height: 1.25; }
diff --git a/src/unitgrade/dashboard/static/sidebars.js b/src/unitgrade/dashboard/static/sidebars.js
new file mode 100644
index 0000000000000000000000000000000000000000..68384c1633e88970593660b87763b1c937cb12a6
--- /dev/null
+++ b/src/unitgrade/dashboard/static/sidebars.js
@@ -0,0 +1,8 @@
+/* global bootstrap: false */
+(function () {
+  'use strict'
+  var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
+  tooltipTriggerList.forEach(function (tooltipTriggerEl) {
+    new bootstrap.Tooltip(tooltipTriggerEl)
+  })
+})()
diff --git a/src/unitgrade/dashboard/static/unitgrade.css b/src/unitgrade/dashboard/static/unitgrade.css
new file mode 100644
index 0000000000000000000000000000000000000000..2a0f15cac733de0a507a2426a46c38fa09bbf0d2
--- /dev/null
+++ b/src/unitgrade/dashboard/static/unitgrade.css
@@ -0,0 +1,14 @@
+
+.list-unstyled .btn{
+   text-align: left;''
+   background-color: rgba(0,0,0,0.05);
+}
+.list-unstyled .btn:hover{
+    background-color: rgba(0,0,0,0.25);
+}
+
+.traceback{
+    font-size: 12px;
+    line-height: .9em;
+}
+
diff --git a/src/unitgrade/dashboard/static/unitgrade.js b/src/unitgrade/dashboard/static/unitgrade.js
new file mode 100644
index 0000000000000000000000000000000000000000..a862da79d3e6c26770d8483f32bf9b9b670ae60c
--- /dev/null
+++ b/src/unitgrade/dashboard/static/unitgrade.js
@@ -0,0 +1,256 @@
+const socket = io.connect("/status"); // Status of the tests.
+socket.on("connect", () => {
+    $("#status-connected").show(); // className = "badge rounded-pill bg-success"
+    $("#status-connecting").hide(); // className = "badge rounded-pill bg-success"
+});
+socket.on("disconnect", () => {
+    $("#status-connected").hide(); // className = "badge rounded-pill bg-success"
+    $("#status-connecting").show(); // className = "badge rounded-pill bg-success"
+});
+
+function re_run_all_tests(){
+    socket.emit("rerun_all", {});
+    for(test in terminals){
+        ui_set_state(test, 'running', {'stacktrace': 'Test is running'});
+        terminals[test][0].reset();
+        terminals[test][0].writeln('Rerunning test...');
+    }
+}
+function re_run_test(test){
+    socket.emit("rerun", {'test': test});
+    ui_set_state(test, 'running', {'stacktrace': 'Test is running'});
+    terminals[test][0].reset();
+    terminals[test][0].writeln('Rerunning test...');
+}
+function tests_and_state(){
+    /** This function update the token/test results. **/
+}
+
+function ui_set_token_state(){
+    /** React to a change in the .token file state **/
+}
+td_classes = {'fail': 'table-danger',
+                 'pass': 'table-success',
+                 'running': 'table-warning',
+}
+$("#token-blurb").hide();
+socket.on("token_update", function(data){
+    console.log('> Updating token from remote...');
+    // console.log(data);
+
+    $("#token-blurb").show();
+     for(const e of $(".current_token_file")){
+        e.innerHTML = data.token;
+     }
+     for(k in data.results){
+        console.log(k);
+        console.log(data.results[k]);
+        state = data.results[k];
+         $("#tbl-"+k+"-token").removeClass();
+        $("#tbl-"+k+"-token").addClass(td_classes[state]);
+        $("#tbl-"+k+"-token")[0].querySelector("span").innerHTML = state;
+    }
+});
+
+
+var questions = {}
+
+function ui_set_state(test_id, state, data){
+    /** Set the state of the test in the UI. Does not fire any events to the server. **/
+   state_emojis = {'fail': "bi bi-emoji-frown",
+                   'pass': "bi bi-emoji-smile",
+                   'running': 'spinner-border text-primary spinner-border-sm',
+    }
+    state_classes = {'fail': 'text-danger',
+                     'pass': 'text-success',
+                     'running': 'text-warning',
+    }
+    $("#tbl-"+test_id+"-title").removeClass();
+    $("#tbl-"+test_id+"-title").addClass(td_classes[state]);
+    $("#tbl-"+test_id+"-unit").removeClass();
+    $("#tbl-"+test_id+"-unit").addClass(td_classes[state]);
+    $("#tbl-"+test_id+"-unit")[0].querySelector("span").innerHTML = state;
+
+    for(const e of $("." + test_id + "-status")){
+        var icon = e.querySelector("#" + test_id + "-icon")
+        if (icon != null){
+                icon.setAttribute("class", state_emojis[state]);
+        }
+        var icon = e.querySelector("#" + test_id + "-status")
+        if (icon != null){
+            nc = state_classes[state]
+
+            if(data.coverage_files_changed != null){
+                nc = nc + " text-decoration-line-through";
+            }
+            icon.setAttribute("class", nc);
+        }
+    }
+    if (state == 'pass'){
+            $('#'+test_id+'-stacktrace').html('The test passed successfully!')
+    }
+    if(state == 'fail'){
+        js = " <script> $('.traceback').on('load', function() { console.log('STUFF');  do_call_doc_ready();   } ); </script>";
+        js = "";
+        if ( !(test_id in questions) ){
+            questions[test_id] = {'stacktrace': ''}
+        }
+        /** We are doing this to avoid adding the same event listener twice. If we do that, we will open/close the trace (if added an even number of times) **/
+        if (questions[test_id]['stacktrace'] == data.stacktrace){
+            // Do nothing.
+        }
+        else{
+            questions[test_id]['stacktrace'] = data.stacktrace;
+            $('#'+test_id+'-stacktrace').html(data.stacktrace+js).ready(
+                function(){
+                     $('.'+test_id +"-traceback").ready( function() {
+                        console.log('in 200ms, I will call: do_call_doc_ready("' + test_id+'")');
+                        setTimeout(function(){
+                            do_call_doc_ready(test_id)
+                        }, 200);
+                    });
+               });
+           }
+    }
+}
+// const status = document.getElementById("status");
+/**
+      socket.of("/admin").on("state", function (data) {
+        console.log("new output received from server:", data.output);
+        term.write(data.output);
+      });
+
+      socket.on("update", function (data) {
+        console.log("new output received from server:", data.output);
+        term.write(data.output);
+      });
+
+    socket.on('test_update', function (data){
+        console.log('test got some new stuff');
+    });
+
+      function fitToscreen() {
+        //fit.fit();
+        const dims = { cols: term.cols, rows: term.rows };
+        console.log("sending new dimensions to server's pty", dims);
+        socket.emit("resize", dims);
+      }
+      **/
+
+        socket.on("testupdate", function(data){
+            console.log('> ', data.state, ': updating test with with id', data.id);
+            ui_set_state(data.id, data.state, data);
+            const targetNode = document.getElementById(''+data.id+'-stacktrace');
+            const callback = (mutationList, observer) => {
+              for (const mutation of mutationList) {
+              }
+            };
+            //console.log(data.stdout);
+            if(data.run_id != terminals[data.id][2]['run_id']){
+                terminals[data.id][0].reset();
+//                terminals[data.id][0].writeln('> Tes');
+
+                terminals[data.id][2]['run_id'] = data.run_id;
+                terminals[data.id][2]['last_chunk_id'] = -1;
+            }
+            if(data.stdout != null){
+                for (const o of data.stdout){
+                    if (o[0] > terminals[data.id][2]['last_chunk_id']){
+                        terminals[data.id][0].write(o[1]);
+                        terminals[data.id][2]['last_chunk_id'] = o[0]
+                    }
+                }
+            }
+       });
+
+      function debounce(func, wait_ms) {
+        let timeout;
+        return function (...args) {
+          const context = this;
+          clearTimeout(timeout);
+          timeout = setTimeout(() => func.apply(context, args), wait_ms);
+        };
+      }
+    $("#status-connected").hide();
+      function reconnect(){
+          console.log("> Reconnected to server...");
+            socket.emit("reconnected", {'hello': 'world'});
+            // <span class="badge rounded-pill bg-success">Success</span>
+            // $('#status').innerHTML = '<span style="background-color: lightgreen;">connected tp tje server.</span>';
+            $("#status-connected").show(); // className = "badge rounded-pill bg-success"
+            $("#status-connecting").hide(); // className = "badge rounded-pill bg-success"
+//            $("#status").text('Connected')
+  //          console.log("changed html");
+         /**
+          socket.on("connect", () => {
+            fitToscreen();
+            status.innerHTML =
+              '<span style="background-color: lightgreen;">connected</span>';
+          });
+          **/
+      }
+      const wait_ms = 50;
+   //   window.onresize = debounce(fitToscreen, wait_ms);
+//reconnect();
+window.onload = debounce(reconnect, wait_ms);
+
+/** This block of code is responsible for managing the terminals */
+//console.log(terminals);
+
+for (var key in terminals) {
+      const term = new Terminal({
+       rows: 22,
+        cursorBlink: true,
+        macOptionIsMeta: true,
+         scrollback: 5000,
+        disableStdin: true,
+        convertEol: true,
+      });
+      const fit = new FitAddon.FitAddon();
+      term.loadAddon(fit);
+
+      term.open(document.getElementById(key));
+      fit.fit();
+        term.writeln("Welcome back! Press the blue 'rerun' button above to run the test anew.")
+        terminals[key] = [term, fit, {'last_run_id': -1, 'last_chunk': 0}]; // Last item are the state details.
+}
+
+ function fitToscreen() {
+        mpt = $("#main_page_tabs")[0]
+        for(k in terminals){
+            e = mpt.querySelector("#"+k + "-pane");
+            if ( e.classList.contains("active") ){
+                console.log("Fitting the terminal given by ", k)
+                terminals[k][1].fit();
+            }
+            }
+      }
+window.onresize = debounce(fitToscreen, wait_ms);
+
+$('button[data-toggle="tab"]').on('shown.bs.tab', function (e) {
+    for(key in terminals){
+       terminals[key][0].write(''); // This appears to refresh the terminal.
+    }
+});
+
+
+/** THIS CODE STORES THE CURRENTLY SELECTED TAB AND SELECTS IT AGAIN ON PAGE REFRESH **/
+/* https://stackoverflow.com/questions/18999501/how-can-i-keep-selected-bootstrap-tab-on-page-refresh
+
+$('#myTab button').click(function(e) {
+  e.preventDefault();
+  console.log("clicked")
+  $(this).tab('show');
+});
+
+// store the currently selected tab in the hash value
+$('button[data-toggle="tab"]').on("shown.bs.tab", function(e) {
+  var id = $(e.target).attr("data-bs-target").substr(1);
+  console.log(id);
+  window.location.hash = id;
+});
+
+// on load of the page: switch to the currently selected tab
+var hash = window.location.hash;
+$('#myTab button[data-bs-target="' + hash + '"]').tab('show');
+*/
\ No newline at end of file
diff --git a/src/unitgrade/dashboard/static/wz_js.js b/src/unitgrade/dashboard/static/wz_js.js
new file mode 100644
index 0000000000000000000000000000000000000000..1a49a9a41146074ac53228839ec4bacf987983cd
--- /dev/null
+++ b/src/unitgrade/dashboard/static/wz_js.js
@@ -0,0 +1,365 @@
+function do_call_doc_ready(id){
+
+    docReady(() => {
+      if (!EVALEX_TRUSTED) {
+        initPinBox();
+      }
+      // if we are in console mode, show the console.
+      if (CONSOLE_MODE && EVALEX) {
+        createInteractiveConsole();
+      }
+
+      const frames = document.querySelectorAll("div."+id +"-traceback div.frame");
+      if (EVALEX) {
+        addConsoleIconToFrames(frames);
+      }
+      addEventListenersToElements(document.querySelectorAll("div.detail"), "click", () =>
+        document.querySelector("div."+id+"-traceback").scrollIntoView(false)
+      );
+      addToggleFrameTraceback(frames);
+      addToggleTraceTypesOnClick(document.querySelectorAll("h2.traceback"));
+      addInfoPrompt(document.querySelectorAll("span.nojavascript"));
+        console.log("Document is ready; setting traceback.")
+    });
+}
+
+function addToggleFrameTraceback(frames) {
+  frames.forEach((frame) => { // the problem may be the event listener is added multiple times...
+    console.log("Adding event listener to frame ", frame);
+    frame.addEventListener("click", () => {
+      console.log("Now the element has been clicked. " + frame + " " + frame.getElementsByTagName("pre")[0].parentElement);
+      frame.getElementsByTagName("pre")[0].parentElement.classList.toggle("expanded");
+    });
+  })
+}
+
+
+function wrapPlainTraceback() {
+  const plainTraceback = document.querySelector("div.plain textarea");
+  const wrapper = document.createElement("pre");
+  const textNode = document.createTextNode(plainTraceback.textContent);
+  wrapper.appendChild(textNode);
+  plainTraceback.replaceWith(wrapper);
+}
+
+function initPinBox() {
+  document.querySelector(".pin-prompt form").addEventListener(
+    "submit",
+    function (event) {
+      event.preventDefault();
+      const pin = encodeURIComponent(this.pin.value);
+      const encodedSecret = encodeURIComponent(SECRET);
+      const btn = this.btn;
+      btn.disabled = true;
+
+      fetch(
+        `${document.location.pathname}?__debugger__=yes&cmd=pinauth&pin=${pin}&s=${encodedSecret}`
+      )
+        .then((res) => res.json())
+        .then(({auth, exhausted}) => {
+          if (auth) {
+            EVALEX_TRUSTED = true;
+            fadeOut(document.getElementsByClassName("pin-prompt")[0]);
+          } else {
+            alert(
+              `Error: ${
+                exhausted
+                  ? "too many attempts.  Restart server to retry."
+                  : "incorrect pin"
+              }`
+            );
+          }
+        })
+        .catch((err) => {
+          alert("Error: Could not verify PIN.  Network error?");
+          console.error(err);
+        })
+        .finally(() => (btn.disabled = false));
+    },
+    false
+  );
+}
+
+function promptForPin() {
+  if (!EVALEX_TRUSTED) {
+    const encodedSecret = encodeURIComponent(SECRET);
+    fetch(
+      `${document.location.pathname}?__debugger__=yes&cmd=printpin&s=${encodedSecret}`
+    );
+    const pinPrompt = document.getElementsByClassName("pin-prompt")[0];
+    fadeIn(pinPrompt);
+    document.querySelector('.pin-prompt input[name="pin"]').focus();
+  }
+}
+
+/**
+ * Helper function for shell initialization
+ */
+function openShell(consoleNode, target, frameID) {
+  promptForPin();
+  if (consoleNode) {
+    slideToggle(consoleNode);
+    return consoleNode;
+  }
+  let historyPos = 0;
+  const history = [""];
+  const consoleElement = createConsole();
+  const output = createConsoleOutput();
+  const form = createConsoleInputForm();
+  const command = createConsoleInput();
+
+  target.parentNode.appendChild(consoleElement);
+  consoleElement.append(output);
+  consoleElement.append(form);
+  form.append(command);
+  command.focus();
+  slideToggle(consoleElement);
+
+  form.addEventListener("submit", (e) => {
+    handleConsoleSubmit(e, command, frameID).then((consoleOutput) => {
+      output.append(consoleOutput);
+      command.focus();
+      consoleElement.scrollTo(0, consoleElement.scrollHeight);
+      const old = history.pop();
+      history.push(command.value);
+      if (typeof old !== "undefined") {
+        history.push(old);
+      }
+      historyPos = history.length - 1;
+      command.value = "";
+    });
+  });
+
+  command.addEventListener("keydown", (e) => {
+    if (e.key === "l" && e.ctrlKey) {
+      output.innerText = "--- screen cleared ---";
+    } else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
+      // Handle up arrow and down arrow.
+      if (e.key === "ArrowUp" && historyPos > 0) {
+        e.preventDefault();
+        historyPos--;
+      } else if (e.key === "ArrowDown" && historyPos < history.length - 1) {
+        historyPos++;
+      }
+      command.value = history[historyPos];
+    }
+    return false;
+  });
+
+  return consoleElement;
+}
+
+function addEventListenersToElements(elements, event, listener) {
+  elements.forEach((el) => el.addEventListener(event, listener));
+}
+
+/**
+ * Add extra info
+ */
+function addInfoPrompt(elements) {
+  for (let i = 0; i < elements.length; i++) {
+    elements[i].innerHTML =
+      "<p>To switch between the interactive traceback and the plaintext " +
+      'one, you can click on the "Traceback" headline. From the text ' +
+      "traceback you can also create a paste of it. " +
+      (!EVALEX
+        ? ""
+        : "For code execution mouse-over the frame you want to debug and " +
+          "click on the console icon on the right side." +
+          "<p>You can execute arbitrary Python code in the stack frames and " +
+          "there are some extra helpers available for introspection:" +
+          "<ul><li><code>dump()</code> shows all variables in the frame" +
+          "<li><code>dump(obj)</code> dumps all that's known about the object</ul>");
+    elements[i].classList.remove("nojavascript");
+  }
+}
+
+function addConsoleIconToFrames(frames) {
+  for (let i = 0; i < frames.length; i++) {
+    let consoleNode = null;
+    const target = frames[i];
+    const frameID = frames[i].id.substring(6);
+
+    for (let j = 0; j < target.getElementsByTagName("pre").length; j++) {
+      const img = createIconForConsole();
+      img.addEventListener("click", (e) => {
+        e.stopPropagation();
+        consoleNode = openShell(consoleNode, target, frameID);
+        return false;
+      });
+      target.getElementsByTagName("pre")[j].append(img);
+    }
+  }
+}
+
+function slideToggle(target) {
+  target.classList.toggle("active");
+}
+
+/**
+ * toggle traceback types on click.
+ */
+function addToggleTraceTypesOnClick(elements) {
+   // logger.log("something..")
+  for (let i = 0; i < elements.length; i++) {
+    elements[i].addEventListener("click", () => {
+      document.querySelector("div.traceback").classList.toggle("hidden");
+      document.querySelector("div.plain").classList.toggle("hidden");
+    });
+    elements[i].style.cursor = "pointer";
+    document.querySelector("div.plain").classList.toggle("hidden");
+  }
+}
+
+function createConsole() {
+  const consoleNode = document.createElement("pre");
+  consoleNode.classList.add("console");
+  consoleNode.classList.add("active");
+  return consoleNode;
+}
+
+function createConsoleOutput() {
+  const output = document.createElement("div");
+  output.classList.add("output");
+  output.innerHTML = "[console ready]";
+  return output;
+}
+
+function createConsoleInputForm() {
+  const form = document.createElement("form");
+  form.innerHTML = "&gt;&gt;&gt; ";
+  return form;
+}
+
+function createConsoleInput() {
+  const command = document.createElement("input");
+  command.type = "text";
+  command.setAttribute("autocomplete", "off");
+  command.setAttribute("spellcheck", false);
+  command.setAttribute("autocapitalize", "off");
+  command.setAttribute("autocorrect", "off");
+  return command;
+}
+
+function createIconForConsole() {
+  const img = document.createElement("img");
+  img.setAttribute("src", "?__debugger__=yes&cmd=resource&f=console.png");
+  img.setAttribute("title", "Open an interactive python shell in this frame");
+  return img;
+}
+
+function createExpansionButtonForConsole() {
+  const expansionButton = document.createElement("a");
+  expansionButton.setAttribute("href", "#");
+  expansionButton.setAttribute("class", "toggle");
+  expansionButton.innerHTML = "&nbsp;&nbsp;";
+  return expansionButton;
+}
+
+function createInteractiveConsole() {
+  const target = document.querySelector("div.console div.inner");
+  while (target.firstChild) {
+    target.removeChild(target.firstChild);
+  }
+  openShell(null, target, 0);
+}
+
+function handleConsoleSubmit(e, command, frameID) {
+  // Prevent page from refreshing.
+  e.preventDefault();
+
+  return new Promise((resolve) => {
+    // Get input command.
+    const cmd = command.value;
+
+    // Setup GET request.
+    const urlPath = "";
+    const params = {
+      __debugger__: "yes",
+      cmd: cmd,
+      frm: frameID,
+      s: SECRET,
+    };
+    const paramString = Object.keys(params)
+      .map((key) => {
+        return "&" + encodeURIComponent(key) + "=" + encodeURIComponent(params[key]);
+      })
+      .join("");
+
+    fetch(urlPath + "?" + paramString)
+      .then((res) => {
+        return res.text();
+      })
+      .then((data) => {
+        const tmp = document.createElement("div");
+        tmp.innerHTML = data;
+        resolve(tmp);
+
+        // Handle expandable span for long list outputs.
+        // Example to test: list(range(13))
+        let wrapperAdded = false;
+        const wrapperSpan = document.createElement("span");
+        const expansionButton = createExpansionButtonForConsole();
+
+        tmp.querySelectorAll("span.extended").forEach((spanToWrap) => {
+          const parentDiv = spanToWrap.parentNode;
+          if (!wrapperAdded) {
+            parentDiv.insertBefore(wrapperSpan, spanToWrap);
+            wrapperAdded = true;
+          }
+          parentDiv.removeChild(spanToWrap);
+          wrapperSpan.append(spanToWrap);
+          spanToWrap.hidden = true;
+
+          expansionButton.addEventListener("click", () => {
+            spanToWrap.hidden = !spanToWrap.hidden;
+            expansionButton.classList.toggle("open");
+            return false;
+          });
+        });
+
+        // Add expansion button at end of wrapper.
+        if (wrapperAdded) {
+          wrapperSpan.append(expansionButton);
+        }
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+    return false;
+  });
+}
+
+function fadeOut(element) {
+  element.style.opacity = 1;
+
+  (function fade() {
+    element.style.opacity -= 0.1;
+    if (element.style.opacity < 0) {
+      element.style.display = "none";
+    } else {
+      requestAnimationFrame(fade);
+    }
+  })();
+}
+
+function fadeIn(element, display) {
+  element.style.opacity = 0;
+  element.style.display = display || "block";
+
+  (function fade() {
+    let val = parseFloat(element.style.opacity) + 0.1;
+    if (val <= 1) {
+      element.style.opacity = val;
+      requestAnimationFrame(fade);
+    }
+  })();
+}
+
+function docReady(fn) {
+  if (document.readyState === "complete" || document.readyState === "interactive") {
+    setTimeout(fn, 1);
+  } else {
+    document.addEventListener("DOMContentLoaded", fn);
+  }
+}
diff --git a/src/unitgrade/dashboard/static/wz_style.css b/src/unitgrade/dashboard/static/wz_style.css
new file mode 100644
index 0000000000000000000000000000000000000000..e9397ca0a1b6c26f30cb28fc81510a48fc46ede9
--- /dev/null
+++ b/src/unitgrade/dashboard/static/wz_style.css
@@ -0,0 +1,150 @@
+body, input  { font-family: sans-serif; color: #000; text-align: center;
+               margin: 1em; padding: 0; font-size: 15px; }
+h1, h2, h3   { font-weight: normal; }
+
+input        { background-color: #fff; margin: 0; text-align: left;
+               outline: none !important; }
+input[type="submit"] { padding: 3px 6px; }
+a            { color: #11557C; }
+a:hover      { color: #177199; }
+pre, code,
+textarea     { font-family: monospace; font-size: 14px; }
+
+div.debugger { text-align: left; padding: 12px; margin: auto;
+               background-color: white; }
+h1           { font-size: 36px; margin: 0 0 0.3em 0; }
+div.detail { cursor: pointer; }
+div.detail p { margin: 0 0 8px 13px; font-size: 14px; white-space: pre-wrap;
+               font-family: monospace; }
+div.explanation { margin: 20px 13px; font-size: 15px; color: #555; }
+div.footer   { font-size: 13px; text-align: right; margin: 30px 0;
+               color: #86989B; }
+
+h2           { font-size: 16px; margin: 1.3em 0 0.0 0; padding: 9px;
+               background-color: #11557C; color: white; }
+h2 em, h3 em { font-style: normal; color: #A5D6D9; font-weight: normal; }
+
+div.traceback, div.plain { border: 1px solid #ddd; margin: 0 0 1em 0; padding: 10px; }
+div.plain p      { margin: 0; }
+div.plain textarea,
+div.plain pre { margin: 10px 0 0 0; padding: 4px;
+                background-color: #E8EFF0; border: 1px solid #D3E7E9; }
+div.plain textarea { width: 99%; height: 300px; }
+div.traceback h3 { font-size: 1em; margin: 0 0 0.8em 0; }
+div.traceback ul { list-style: none; margin: 0; padding: 0 0 0 1em; }
+div.traceback h4 { font-size: 13px; font-weight: normal; margin: 0.7em 0 0.1em 0; }
+div.traceback pre { margin: 0; padding: 5px 0 3px 15px;
+                    background-color: #E8EFF0; border: 1px solid #D3E7E9; }
+div.traceback .library .current { background: white; color: #555; }
+div.traceback .expanded .current { background: #E8EFF0; color: black; }
+div.traceback pre:hover { background-color: #DDECEE; color: black; cursor: pointer; }
+div.traceback div.source.expanded pre + pre { border-top: none; }
+
+div.traceback span.ws { display: none; }
+div.traceback pre.before, div.traceback pre.after { display: none; background: white; }
+div.traceback div.source.expanded pre.before,
+div.traceback div.source.expanded pre.after {
+    display: block;
+}
+
+div.traceback div.source.expanded span.ws {
+    display: inline;
+}
+
+div.traceback blockquote { margin: 1em 0 0 0; padding: 0; white-space: pre-line; }
+div.traceback img { float: right; padding: 2px; margin: -3px 2px 0 0; display: none; }
+div.traceback img:hover { background-color: #ddd; cursor: pointer;
+                          border-color: #BFDDE0; }
+div.traceback pre:hover img { display: block; }
+div.traceback cite.filename { font-style: normal; color: #3B666B; }
+
+pre.console { border: 1px solid #ccc; background: white!important;
+              color: black; padding: 5px!important;
+              margin: 3px 0 0 0!important; cursor: default!important;
+              max-height: 400px; overflow: auto; }
+pre.console form { color: #555; }
+pre.console input { background-color: transparent; color: #555;
+                    width: 90%; font-family: monospace; font-size: 14px;
+                     border: none!important; }
+
+span.string { color: #30799B; }
+span.number { color: #9C1A1C; }
+span.help   { color: #3A7734; }
+span.object { color: #485F6E; }
+span.extended { opacity: 0.5; }
+span.extended:hover { opacity: 1; }
+a.toggle { text-decoration: none; background-repeat: no-repeat;
+           background-position: center center;
+           background-image: url(?__debugger__=yes&cmd=resource&f=more.png); }
+a.toggle:hover { background-color: #444; }
+a.open { background-image: url(?__debugger__=yes&cmd=resource&f=less.png); }
+
+pre.console div.traceback,
+pre.console div.box { margin: 5px 10px; white-space: normal;
+                      border: 1px solid #11557C; padding: 10px;
+                      font-family: sans-serif;  }
+pre.console div.box h3,
+pre.console div.traceback h3 { margin: -10px -10px 10px -10px; padding: 5px;
+                               background: #11557C; color: white; }
+
+pre.console div.traceback pre:hover { cursor: default; background: #E8EFF0; }
+pre.console div.traceback pre.syntaxerror { background: inherit; border: none;
+                                            margin: 20px -10px -10px -10px;
+                                            padding: 10px; border-top: 1px solid #BFDDE0;
+                                            background: #E8EFF0; }
+pre.console div.noframe-traceback pre.syntaxerror { margin-top: -10px; border: none; }
+
+pre.console div.box pre.repr { padding: 0; margin: 0; background-color: white; border: none; }
+pre.console div.box table { margin-top: 6px; }
+pre.console div.box pre { border: none; }
+pre.console div.box pre.help { background-color: white; }
+pre.console div.box pre.help:hover { cursor: default; }
+pre.console table tr { vertical-align: top; }
+div.console { border: 1px solid #ccc; padding: 4px; background-color: #fafafa; }
+
+div.traceback pre, div.console pre {
+    white-space: pre-wrap;       /* css-3 should we be so lucky... */
+    white-space: -moz-pre-wrap;  /* Mozilla, since 1999 */
+    white-space: -pre-wrap;      /* Opera 4-6 ?? */
+    white-space: -o-pre-wrap;    /* Opera 7 ?? */
+    word-wrap: break-word;       /* Internet Explorer 5.5+ */
+    _white-space: pre;           /* IE only hack to re-specify in
+                                 addition to word-wrap  */
+}
+
+
+div.pin-prompt {
+    position: absolute;
+    display: none;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    background: rgba(255, 255, 255, 0.8);
+}
+
+div.pin-prompt .inner {
+    background: #eee;
+    padding: 10px 50px;
+    width: 350px;
+    margin: 10% auto 0 auto;
+    border: 1px solid #ccc;
+    border-radius: 2px;
+}
+
+div.exc-divider {
+    margin: 0.7em 0 0 -1em;
+    padding: 0.5em;
+    background: #11557C;
+    color: #ddd;
+    border: 1px solid #ddd;
+}
+
+.console.active {
+    max-height: 0!important;
+    display: none;
+}
+
+.hidden {
+    display: none;
+}
diff --git a/src/unitgrade/dashboard/static/wz_style_modified.css b/src/unitgrade/dashboard/static/wz_style_modified.css
new file mode 100644
index 0000000000000000000000000000000000000000..3518ad84384c4d154ef1afe386e49642110c9c2f
--- /dev/null
+++ b/src/unitgrade/dashboard/static/wz_style_modified.css
@@ -0,0 +1,152 @@
+/**
+body, input  { font-family: sans-serif; color: #000; text-align: center;
+               margin: 1em; padding: 0; font-size: 15px; }
+    **/
+h1, h2, h3   { font-weight: normal; }
+
+input        { background-color: #fff; margin: 0; text-align: left;
+               outline: none !important; }
+input[type="submit"] { padding: 3px 6px; }
+a            { color: #11557C; }
+a:hover      { color: #177199; }
+pre, code,
+textarea     { font-family: monospace; font-size: 14px; }
+
+div.debugger { text-align: left; padding: 12px; margin: auto;
+               background-color: white; }
+h1           { font-size: 36px; margin: 0 0 0.3em 0; }
+div.detail { cursor: pointer; }
+div.detail p { margin: 0 0 8px 13px; font-size: 14px; white-space: pre-wrap;
+               font-family: monospace; }
+div.explanation { margin: 20px 13px; font-size: 15px; color: #555; }
+div.footer   { font-size: 13px; text-align: right; margin: 30px 0;
+               color: #86989B; }
+
+h2           { font-size: 16px; margin: 1.3em 0 0.0 0; padding: 9px;
+               background-color: #11557C; color: white; }
+h2 em, h3 em { font-style: normal; color: #A5D6D9; font-weight: normal; }
+
+div.traceback, div.plain { border: 1px solid #ddd; margin: 0 0 1em 0; padding: 10px; }
+div.plain p      { margin: 0; }
+div.plain textarea,
+div.plain pre { margin: 10px 0 0 0; padding: 4px;
+                background-color: #E8EFF0; border: 1px solid #D3E7E9; }
+div.plain textarea { width: 99%; height: 300px; }
+div.traceback h3 { font-size: 1em; margin: 0 0 0.8em 0; }
+div.traceback ul { list-style: none; margin: 0; padding: 0 0 0 1em; }
+div.traceback h4 { font-size: 13px; font-weight: normal; margin: 0.7em 0 0.1em 0; }
+div.traceback pre { margin: 0; padding: 5px 0 3px 15px;
+                    background-color: #E8EFF0; border: 1px solid #D3E7E9; }
+div.traceback .library .current { background: white; color: #555; }
+div.traceback .expanded .current { background: #E8EFF0; color: black; }
+div.traceback pre:hover { background-color: #DDECEE; color: black; cursor: pointer; }
+div.traceback div.source.expanded pre + pre { border-top: none; }
+
+div.traceback span.ws { display: none; }
+div.traceback pre.before, div.traceback pre.after { display: none; background: white; }
+div.traceback div.source.expanded pre.before,
+div.traceback div.source.expanded pre.after {
+    display: block;
+}
+
+div.traceback div.source.expanded span.ws {
+    display: inline;
+}
+
+div.traceback blockquote { margin: 1em 0 0 0; padding: 0; white-space: pre-line; }
+div.traceback img { float: right; padding: 2px; margin: -3px 2px 0 0; display: none; }
+div.traceback img:hover { background-color: #ddd; cursor: pointer;
+                          border-color: #BFDDE0; }
+div.traceback pre:hover img { display: block; }
+div.traceback cite.filename { font-style: normal; color: #3B666B; }
+
+pre.console { border: 1px solid #ccc; background: white!important;
+              color: black; padding: 5px!important;
+              margin: 3px 0 0 0!important; cursor: default!important;
+              max-height: 400px; overflow: auto; }
+pre.console form { color: #555; }
+pre.console input { background-color: transparent; color: #555;
+                    width: 90%; font-family: monospace; font-size: 14px;
+                     border: none!important; }
+
+span.string { color: #30799B; }
+span.number { color: #9C1A1C; }
+span.help   { color: #3A7734; }
+span.object { color: #485F6E; }
+span.extended { opacity: 0.5; }
+span.extended:hover { opacity: 1; }
+a.toggle { text-decoration: none; background-repeat: no-repeat;
+           background-position: center center;
+           background-image: url(?__debugger__=yes&cmd=resource&f=more.png); }
+a.toggle:hover { background-color: #444; }
+a.open { background-image: url(?__debugger__=yes&cmd=resource&f=less.png); }
+
+pre.console div.traceback,
+pre.console div.box { margin: 5px 10px; white-space: normal;
+                      border: 1px solid #11557C; padding: 10px;
+                      font-family: sans-serif;  }
+pre.console div.box h3,
+pre.console div.traceback h3 { margin: -10px -10px 10px -10px; padding: 5px;
+                               background: #11557C; color: white; }
+
+pre.console div.traceback pre:hover { cursor: default; background: #E8EFF0; }
+pre.console div.traceback pre.syntaxerror { background: inherit; border: none;
+                                            margin: 20px -10px -10px -10px;
+                                            padding: 10px; border-top: 1px solid #BFDDE0;
+                                            background: #E8EFF0; }
+pre.console div.noframe-traceback pre.syntaxerror { margin-top: -10px; border: none; }
+
+pre.console div.box pre.repr { padding: 0; margin: 0; background-color: white; border: none; }
+pre.console div.box table { margin-top: 6px; }
+pre.console div.box pre { border: none; }
+pre.console div.box pre.help { background-color: white; }
+pre.console div.box pre.help:hover { cursor: default; }
+pre.console table tr { vertical-align: top; }
+div.console { border: 1px solid #ccc; padding: 4px; background-color: #fafafa; }
+
+div.traceback pre, div.console pre {
+    white-space: pre-wrap;       /* css-3 should we be so lucky... */
+    white-space: -moz-pre-wrap;  /* Mozilla, since 1999 */
+    white-space: -pre-wrap;      /* Opera 4-6 ?? */
+    white-space: -o-pre-wrap;    /* Opera 7 ?? */
+    word-wrap: break-word;       /* Internet Explorer 5.5+ */
+    _white-space: pre;           /* IE only hack to re-specify in
+                                 addition to word-wrap  */
+}
+
+
+div.pin-prompt {
+    position: absolute;
+    display: none;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    background: rgba(255, 255, 255, 0.8);
+}
+
+div.pin-prompt .inner {
+    background: #eee;
+    padding: 10px 50px;
+    width: 350px;
+    margin: 10% auto 0 auto;
+    border: 1px solid #ccc;
+    border-radius: 2px;
+}
+
+div.exc-divider {
+    margin: 0.7em 0 0 -1em;
+    padding: 0.5em;
+    background: #11557C;
+    color: #ddd;
+    border: 1px solid #ddd;
+}
+
+.console.active {
+    max-height: 0!important;
+    display: none;
+}
+
+.hidden {
+    display: none;
+}
diff --git a/src/unitgrade/dashboard/templates/base.html b/src/unitgrade/dashboard/templates/base.html
new file mode 100644
index 0000000000000000000000000000000000000000..ed8774ea9dac486aac08c2b4cea81f1c4abb9190
--- /dev/null
+++ b/src/unitgrade/dashboard/templates/base.html
@@ -0,0 +1,106 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+        <link rel="icon" type="image/x-icon" href="/static/favicon.ico">
+    <meta name="description" content="">
+    <meta name="author" content="Tue Herlau">
+    <meta name="generator" content="Unitgrade">
+    <title>Unitgrade dashboard</title>
+
+    <link rel="canonical" href="https://getbootstrap.com/docs/5.0/examples/sidebars/">
+    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-gH2yIJqKdNHPEq0n4Mqa/HGKIhSkIHeL5AyhkYV8i59U5AR6csBvApHHNl/vI1Bx" crossorigin="anonymous">
+    <script src="https://unpkg.com/xterm@4.11.0/lib/xterm.js"></script>
+    <script src="https://unpkg.com/xterm-addon-fit@0.5.0/lib/xterm-addon-fit.js"></script>
+    <script src="https://unpkg.com/xterm-addon-web-links@0.4.0/lib/xterm-addon-web-links.js"></script>
+    <script src="https://unpkg.com/xterm-addon-search@0.8.0/lib/xterm-addon-search.js"></script>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.min.js"></script>
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-A3rJD856KowSb7dwlZdYEkO39Gagi7vIsF0jrRAoQmDKKtQBHUuLZ9AsSv4jD4Xa" crossorigin="anonymous"></script>
+<script src="https://ajax.aspnetcdn.com/ajax/jQuery/jquery-3.3.1.min.js"></script>
+     <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css">
+<!-- terminal related -->
+        <link rel="stylesheet" href="https://unpkg.com/xterm@4.11.0/css/xterm.css"/>
+        <script src="https://unpkg.com/xterm@4.11.0/lib/xterm.js"></script>
+        <script src="https://unpkg.com/xterm-addon-fit@0.5.0/lib/xterm-addon-fit.js"></script>
+        <script src="https://unpkg.com/xterm-addon-web-links@0.4.0/lib/xterm-addon-web-links.js"></script>
+        <script src="https://unpkg.com/xterm-addon-search@0.8.0/lib/xterm-addon-search.js"></script>
+      <!-- end terminal related -->
+    <style>
+      .bd-placeholder-img {
+        font-size: 1.125rem;
+        text-anchor: middle;
+        -webkit-user-select: none;
+        -moz-user-select: none;
+        user-select: none;
+      }
+
+      @media (min-width: 768px) {
+        .bd-placeholder-img-lg {
+          font-size: 3.5rem;
+        }
+      }
+    </style>
+    <!-- Custom styles for this template -->
+    <link href="/static/sidebars.css" rel="stylesheet">
+        <link href="/static/wz_style_modified.css" rel="stylesheet">
+    <script>
+      var CONSOLE_MODE = false,
+          EVALEX = false, // console mode is possible.
+          EVALEX_TRUSTED = true,
+          SECRET = "Xbtn32ZR6AqRabFk2a3l";
+    </script>
+    <link href="/static/unitgrade.css" rel="stylesheet">
+  </head>
+  <body>
+
+
+<main>
+<div class="flex-shrink-0 p-3 bg-white" style="width: 280px; background-color: #f0f0f0">
+<span role="tablist" aria-orientation="vertical">
+<div class="d-flex flex-column" style="min-height: calc(100vh - 30px);">
+<header>
+    <a href="/" class="d-flex align-items-center pb-1 mb-1 link-dark text-decoration-none">
+        <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor"  class="bi me-2" width="30" height="24" viewBox="0 0 16 16">
+            <path d="M8.211 2.047a.5.5 0 0 0-.422 0l-7.5 3.5a.5.5 0 0 0 .025.917l7.5 3a.5.5 0 0 0 .372 0L14 7.14V13a1 1 0 0 0-1 1v2h3v-2a1 1 0 0 0-1-1V6.739l.686-.275a.5.5 0 0 0 .025-.917l-7.5-3.5Z"/>
+            <path d="M4.176 9.032a.5.5 0 0 0-.656.327l-.5 1.7a.5.5 0 0 0 .294.605l4.5 1.8a.5.5 0 0 0 .372 0l4.5-1.8a.5.5 0 0 0 .294-.605l-.5-1.7a.5.5 0 0 0-.656-.327L8 10.466 4.176 9.032Z"/>
+        </svg>
+        <span class="fs-5 fw-semibold">        Unitgrade</span>&nbsp;
+        <span class="badge rounded-pill bg-success" id="status-connected">Connected</span>
+        <span class="badge rounded-pill bg-warning text-dark" id="status-connecting">Connecting</span>
+    </a>
+    <!-- Example single danger button -->
+    <div class="dropdown">
+        <button class="btn btn-secondary dropdown-toggle btn-sm" style="width: 100%" type="button" id="dropdownMenuButton1" data-bs-toggle="dropdown" aria-expanded="false">
+          {{menu_name}}
+        </button>
+        <ul class="dropdown-menu " aria-labelledby="dropdownMenuButton1">
+          {% for a in available_reports.values() %}
+               <li><a class="dropdown-item" href="/report/{{a.menu_name}}">{{a.menu_name}}</a></li>
+          {% endfor %}
+        </ul>
+    </div>
+</header>
+
+<content>
+<section>
+      {% block navigation %} {% endblock %}
+</section>
+</content>
+<footer class="mt-auto">
+      {% block navigation_footer %}{% endblock %}
+</footer>
+</div>
+    </span>
+</div>
+  <div class="b-example-divider">
+    {% block content %}{% endblock %}
+ </div> <!-- example divider ends -->
+</main>
+<!--
+    <script src="../assets/dist/js/bootstrap.bundle.min.js"></script> -->
+      <script src="/static/sidebars.js"></script>
+      <script src="/static/unitgrade.js"></script>
+    <script src="/static/wz_js.js"></script>
+  </body>
+</html>
diff --git a/src/unitgrade/dashboard/templates/bootstrap.html b/src/unitgrade/dashboard/templates/bootstrap.html
new file mode 100644
index 0000000000000000000000000000000000000000..566a1332a78a39259480e07b387a048b127fa638
--- /dev/null
+++ b/src/unitgrade/dashboard/templates/bootstrap.html
@@ -0,0 +1,70 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Bootstrap demo</title>
+    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-gH2yIJqKdNHPEq0n4Mqa/HGKIhSkIHeL5AyhkYV8i59U5AR6csBvApHHNl/vI1Bx" crossorigin="anonymous">
+  </head>
+  <body>
+    <h1>Hello, world!</h1>
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-A3rJD856KowSb7dwlZdYEkO39Gagi7vIsF0jrRAoQmDKKtQBHUuLZ9AsSv4jD4Xa" crossorigin="anonymous"></script>
+
+
+  <div class="accordion" id="accordionPanelsStayOpenExample">
+  <div class="accordion-item">
+    <h2 class="accordion-header" id="panelsStayOpen-headingOne">
+      <button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#panelsStayOpen-collapseOne" aria-expanded="true" aria-controls="panelsStayOpen-collapseOne">
+        Accordion Item #1 <span class="glyphicon glyphicon-star" aria-hidden="true"> das sdafsdf</span> Star
+      </button>
+    </h2>
+    <div id="panelsStayOpen-collapseOne" class="accordion-collapse collapse show" aria-labelledby="panelsStayOpen-headingOne">
+      <div class="accordion-body">
+        <strong>This is the first item's accordion body.</strong> It is shown by default, until the collapse plugin adds the appropriate classes that we use to style each element. These classes control the overall appearance, as well as the showing and hiding via CSS transitions. You can modify any of this with custom CSS or overriding our default variables. It's also worth noting that just about any HTML can go within the <code>.accordion-body</code>, though the transition does limit overflow.
+  <div class="accordion" id="accordionPanelsStayOpenExample2">
+  <div class="accordion-item">
+    <h2 class="accordion-header" id="panelsStayOpen-headingOne2">
+      <button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#panelsStayOpen-collapseOne2" aria-expanded="true" aria-controls="panelsStayOpen-collapseOne">
+        Accordion Item #1
+      </button>
+    </h2>
+    <div id="panelsStayOpen-collapseOne2" class="accordion-collapse collapse show" aria-labelledby="panelsStayOpen-headingOne">
+      <div class="accordion-body">
+        <strong>This is the first item's accordion body.</strong> It is shown by default, until the collapse plugin adds the appropriate classes that we use to style each element. These classes control the overall appearance, as well as the showing and hiding via CSS transitions. You can modify any of this with custom CSS or overriding our default variables. It's also worth noting that just about any HTML can go within the <code>.accordion-body</code>, though the transition does limit overflow.
+      </div>
+    </div>
+  </div>
+    </div>
+
+      </div>
+    </div>
+  </div>
+  <div class="accordion-item">
+    <h2 class="accordion-header" id="panelsStayOpen-headingTwo">
+      <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#panelsStayOpen-collapseTwo" aria-expanded="false" aria-controls="panelsStayOpen-collapseTwo">
+        Accordion Item #2
+      </button>
+    </h2>
+    <div id="panelsStayOpen-collapseTwo" class="accordion-collapse collapse" aria-labelledby="panelsStayOpen-headingTwo">
+      <div class="accordion-body">
+        <strong>This is the second item's accordion body.</strong> It is hidden by default, until the collapse plugin adds the appropriate classes that we use to style each element. These classes control the overall appearance, as well as the showing and hiding via CSS transitions. You can modify any of this with custom CSS or overriding our default variables. It's also worth noting that just about any HTML can go within the <code>.accordion-body</code>, though the transition does limit overflow.
+      </div>
+    </div>
+  </div>
+  <div class="accordion-item">
+    <h2 class="accordion-header" id="panelsStayOpen-headingThree">
+      <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#panelsStayOpen-collapseThree" aria-expanded="false" aria-controls="panelsStayOpen-collapseThree">
+        Accordion Item #3
+      </button>
+    </h2>
+    <div id="panelsStayOpen-collapseThree" class="accordion-collapse collapse" aria-labelledby="panelsStayOpen-headingThree">
+      <div class="accordion-body">
+        <strong>This is the third item's accordion body.</strong> It is hidden by default, until the collapse plugin adds the appropriate classes that we use to style each element. These classes control the overall appearance, as well as the showing and hiding via CSS transitions. You can modify any of this with custom CSS or overriding our default variables. It's also worth noting that just about any HTML can go within the <code>.accordion-body</code>, though the transition does limit overflow.
+      </div>
+    </div>
+  </div>
+</div>
+
+
+  </body>
+</html>
\ No newline at end of file
diff --git a/src/unitgrade/dashboard/templates/index3.html b/src/unitgrade/dashboard/templates/index3.html
new file mode 100644
index 0000000000000000000000000000000000000000..758bab29fef55cf0729eb762eaedad49dda9e186
--- /dev/null
+++ b/src/unitgrade/dashboard/templates/index3.html
@@ -0,0 +1,207 @@
+{% extends 'base.html' %}
+{% macro build_question_body(hi) %}
+{{hi}}
+{% endmacro %}
+{% block head %}
+{% endblock %}
+
+{% block content %}
+<div class="tab-content container-fluid" id="main_page_tabs">
+<script>
+var terminals = {};
+ </script>
+{% set count=0 %}
+{% for qkey, qbody in questions.items() %}
+      {% set outer_loop = loop %}
+   {% for ikey, ibody in qbody.tests.items() %}
+     <script>terminals["{{ikey}}"] = null; </script>
+<div class="tab-pane fade {{ 'show active' if outer_loop.index == 1 and loop.index == 1 else ''}}" id="{{ikey}}-pane" role="tabpanel" aria-labelledby="{{ikey}}-pane-tab">
+<!-- begin tab card -->
+<h1>{{qbody.title}}</h1>
+<h4>
+    <span class="{{ikey}}-status">
+    <span id="{{ikey}}-status"><i id="{{ikey}}-icon" class="bi bi-emoji-neutral"></i> <span class="text-left">{{ibody.title}}</span></span>
+    </span>
+    <a onclick="re_run_test('{{ikey}}');" type="button" class="btn btn-primary">Rerun</a>
+</h4>
+
+<div class="card shadow mb-3 bg-white rounded">
+  <div class="card-header">
+        Terminal Output
+  </div>
+  <div class="card-body">
+      <div style="padding-left: 5px; padding-top: 5px; background-color: black;">
+     <div style="width: 100%; height: 20%;" id="{{ikey}}"></div>
+      </div>
+          <p class="card-text">
+
+    </p>
+  </div>
+</div>
+
+<div class="row">
+  <div class="col-sm-8">
+    <div class="card shadow mb-5 bg-white rounded">
+        <div class="card-header">Test outcome</div>
+        <div class="card-body">
+            <div id="{{ikey}}-stacktrace">{{ibody.wz if ibody.wz else 'Your results will be shown here'}}</div>
+        </div>
+    </div>
+  </div>
+  <div class="col-sm-4">
+    <div class="card shadow mb-5 bg-white rounded">
+      <div class="card-header"> Hints </div>
+              <div class="card-body">
+        <dl>
+        {% for h in ibody.hints %}
+            <dt>{% if not h[1] %} Overall hints: {% else %} From the file <emph>{{ h[1] }}</emph> {% endif %}</dt>
+            <dd>
+                <ul>
+                {% for hitem in h[0] %}
+                <li>{{hitem}}</li>
+                {% endfor %}
+                    </ul>
+            </dd>
+        {% endfor %}
+</dl>
+
+      </div>
+    </div>
+  </div>
+</div>
+</div>
+      {% endfor %}
+      {% endfor %}
+<div class="tab-pane fade" id="token-pane" role="tabpanel" aria-labelledby="token-pane-tab">
+
+<div class="row">
+    <div class="col-sm-2"> </div>
+  <div class="col-sm-8">
+    <div class="card shadow mb-5 bg-white rounded">
+        <div class="card-header">Your submission</div>
+        <div class="card-body">
+            <span id="#token-blurb">The following results are based on: <code><span class="current_token_file">no file here. </span></code> </span>
+
+             <table  class="table table-hover" style="td-height: 10px;">
+                 <!--
+                    <thead>
+                     <tr>
+                     <td style="border: none;"></td>
+                        <td style="border: none;">Unittests result</td>
+                        <td style="border: none;"><code>.token</code>-file result</td>
+                     </tr>
+                    </thead>
+                -->
+            {% for qkey, qbody in questions.items() %}
+                 <!--
+            <h6> {{qbody.title}}</h6>
+               -->
+                    <tr>
+                        {% if loop.index == 1 %}
+                            <td><strong>{{qbody.title}}</strong></td>
+                            <td>Unittests result</td>
+                            <td><code>.token</code>-file result</td>
+                        {% else %}
+                            <td colspan="3"><strong>{{qbody.title}}</strong></td>
+                        {% endif %}
+                    </tr>
+                   {% for ikey, ibody in qbody.tests.items() %}
+                    <tr style="line-height: 10px;  height: 10px;">
+                        <td id="tbl-{{ikey}}-title">{{ibody.title}}</td>
+                        <td id="tbl-{{ikey}}-unit"><span class="test-state">Test has not been run</span></td>
+                        <td id="tbl-{{ikey}}-token"><span class="test-state">Test has not been run</span></td>
+                    </tr>
+                    <!--
+                    <div class="tab-pane fade" id="{{ikey}}-pane" role="tabpanel" aria-labelledby="{{ikey}}-pane-tab">
+                    -->
+                    <!-- begin tab card -->
+                    <!--
+                    <h1>{{qbody.title}}</h1>-->
+
+                {% endfor %}
+                    </table>
+
+            {% endfor %}
+            </div></div>
+    <div class="card shadow mb-5 bg-white rounded">
+        <div class="card-header">Hand-in instructions:</div>
+        <div class="card-body">
+<p>
+To hand in your results, you should run the file <code>{{grade_script}}</code>. You can either do this from your IDE, or by going to the directory:
+<pre>
+    <code>{{root_dir}}</code>
+</pre>
+and from there run the command:<pre><code>
+    {{run_cmd_grade}}</code>
+</pre>
+This will generate a <code>.token</code> file which contains your answers and you should upload to DTU learn.
+</p>
+        </div>
+    </div>
+  </div>
+</div>
+  </div>
+{% endblock %}
+
+<!---------------------------------- NAVIGATION SECTION -------------------->
+
+{% block navigation %}
+
+   <ul class="list-unstyled ps-0">
+         {% for qkey, qbody in questions.items() %}
+         {% set outer_loop = loop %}
+      <li class="mb-1">
+        <button class="btn btn-toggle align-items-center rounded collapsed" data-bs-toggle="collapse" data-bs-target="#{{qkey}}-collapse" aria-expanded="true" >
+          {{qbody.title}} </button>
+        <div class="collapse show" id="{{qkey}}-collapse">
+          <ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small" id="myTab">
+              {% for ikey, ibody in qbody.tests.items() %}
+            <li>
+
+        <div class="container" style="">
+            <div class="row" style="background-color: white;">
+                <div class="col col-lg-11 text-truncate" style="background-color: white;">
+                    <button class="btn rounded collapsed nav-link {{  'active' if outer_loop.index == 1 and loop.index == 1 else ''}} text-left" style="width: 100%;" id="{{ikey}}-pane-tab" data-bs-toggle="pill" data-bs-target="#{{ikey}}-pane" type="button" role="tab" aria-controls="{{ikey}}-pane" aria-selected="false" data-toggle="tab">
+                        <span class="{{ikey}}-status">
+                            <span id="{{ikey}}-status"><i style="height: 16px;width: 16px; id="{{ikey}}-icon" class="bi bi-emoji-neutral"></i> <span class="text-left">{{ibody.title}}</span></span>
+                        </span>
+                    </button>
+                </div>
+                <div class="col col-lg-auto" style="padding: 0px; backgrund-color: white;">
+                    <a onclick="re_run_test('{{ikey}}');" type="button" class="btn btn-primary btn-sm" style="padding: 0px; margin: 0px;"><i class="bi bi-arrow-clockwise"></i></a>
+                </div>
+            </div>
+        </div>
+
+
+                    <!--
+                 <button class="nav-link" id="v-pills-settings-tab" data-bs-toggle="pill" data-bs-target="#v-pills-settings" type="button" role="tab" aria-controls="v-pills-settings" aria-selected="false">Settings</button>
+                 -->
+
+            </li>
+              {% endfor %} <!--
+            <li><a href="#" class="link-dark rounded">Updates</a></li>
+            <li><a href="#" class="link-dark rounded">Reports</a></li> -->
+          </ul>
+        </div>
+      </li>
+       {% endfor %}
+    </ul>
+    <hr/>
+{% endblock %}
+{% block navigation_footer %}
+<div class="btn-group-vertical" style="width: 100%">
+<a onclick="re_run_all_tests();" class="btn btn-primary btn-sm" style="width: 100%;"  type="button">Rerun all tests</a>
+<button class="btn  btn-success btn-sm" style="width: 100%;" id="token-pane-tab" data-bs-toggle="pill" data-bs-target="#token-pane" type="button" role="tab" aria-controls="token-pane" aria-selected="false" data-toggle="tab">Submit results</button>
+</div>
+<!--
+    <p>
+      <a onclick="re_run_all_tests();" class="btn btn-primary btn-sm" style="width: 100%;"  type="button">
+        Rerun all tests
+    </a></p>
+    <p>
+    <button class="btn  btn-success btn-sm" style="width: 100%;" id="token-pane-tab" data-bs-toggle="pill" data-bs-target="#token-pane" type="button" role="tab" aria-controls="token-pane" aria-selected="false" data-toggle="tab">
+        Submit results
+    </button>
+    </p>-->
+{% endblock %}
\ No newline at end of file
diff --git a/src/unitgrade/dashboard/templates/old/base.html b/src/unitgrade/dashboard/templates/old/base.html
new file mode 100644
index 0000000000000000000000000000000000000000..208709d6a61bcbadacfc33b309ddbb82d0c5e174
--- /dev/null
+++ b/src/unitgrade/dashboard/templates/old/base.html
@@ -0,0 +1,35 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Unitgrade Dashboard</title>
+
+    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-gH2yIJqKdNHPEq0n4Mqa/HGKIhSkIHeL5AyhkYV8i59U5AR6csBvApHHNl/vI1Bx" crossorigin="anonymous">
+    <script src="https://unpkg.com/xterm@4.11.0/lib/xterm.js"></script>
+    <script src="https://unpkg.com/xterm-addon-fit@0.5.0/lib/xterm-addon-fit.js"></script>
+    <script src="https://unpkg.com/xterm-addon-web-links@0.4.0/lib/xterm-addon-web-links.js"></script>
+    <script src="https://unpkg.com/xterm-addon-search@0.8.0/lib/xterm-addon-search.js"></script>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.min.js"></script>
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-A3rJD856KowSb7dwlZdYEkO39Gagi7vIsF0jrRAoQmDKKtQBHUuLZ9AsSv4jD4Xa" crossorigin="anonymous"></script>
+    <script src="https://ajax.aspnetcdn.com/ajax/jQuery/jquery-3.3.1.min.js"></script>
+     <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css">
+  </head>
+
+
+  <body>
+     {% block head %} {% endblock %}
+
+
+
+{% block content %}
+
+
+{% endblock %}
+
+
+
+
+  </body>
+</html>
+
diff --git a/src/unitgrade/dashboard/templates/old/index.html b/src/unitgrade/dashboard/templates/old/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..d3df6c74180688d0b61fedaad232164a5c973ace
--- /dev/null
+++ b/src/unitgrade/dashboard/templates/old/index.html
@@ -0,0 +1,141 @@
+{% extends 'base.html' %}
+
+
+{% macro build_question_body(hi) %}
+{{hi}}
+{% endmacro %}
+
+{% block head %}
+<script language="javascript">
+const socket = io.connect("/status"); // Status of the tests.
+
+// const status = document.getElementById("status");
+/**
+      socket.of("/admin").on("state", function (data) {
+        console.log("new output received from server:", data.output);
+        term.write(data.output);
+      });
+
+      socket.on("update", function (data) {
+        console.log("new output received from server:", data.output);
+        term.write(data.output);
+      });
+
+    socket.on('test_update', function (data){
+        console.log('test got some new stuff');
+    });
+
+      socket.on("connect", () => {
+        fitToscreen();
+        status.innerHTML =
+          '<span style="background-color: lightgreen;">connected</span>';
+      });
+
+      socket.on("disconnect", () => {
+        status.innerHTML =
+          '<span style="background-color: #ff8383;">disconnected</span>';
+      });
+
+      function fitToscreen() {
+        //fit.fit();
+        const dims = { cols: term.cols, rows: term.rows };
+        console.log("sending new dimensions to server's pty", dims);
+        socket.emit("resize", dims);
+      }
+      **/
+      function debounce(func, wait_ms) {
+        let timeout;
+        return function (...args) {
+          const context = this;
+          clearTimeout(timeout);
+          timeout = setTimeout(() => func.apply(context, args), wait_ms);
+        };
+      }
+
+      function reconnect(){
+          console.log("hi world");
+            socket.emit("reconnected", {'hello': 'world'});
+            $('#status').innerHTML = '<span style="background-color: lightgreen;">connected tp tje server.</span>';
+            $("#status").css("background-color", "lightgreen");
+            $("#status").text('Connected')
+            console.log("changed html");
+         /**
+          socket.on("connect", () => {
+            fitToscreen();
+            status.innerHTML =
+              '<span style="background-color: lightgreen;">connected</span>';
+          });
+          **/
+      }
+      const wait_ms = 50;
+   //   window.onresize = debounce(fitToscreen, wait_ms);
+//reconnect();
+window.onload = debounce(reconnect, wait_ms);
+
+</script>
+{% endblock %}
+
+
+
+{% block content %}
+<nav class="navbar navbar-dark bg-dark">
+    <!--
+<a class="navbar-brand" href="#">Navbar</a>-->
+<span class="navbar-brand mb-0 h3">Unitgrade <i class="bi bi-emoji-heart-eyes-fill"></i></span>
+ <form class="form-inline">
+    <div class="input-group">
+      <div class="input-group-prepend">
+        <span class="input-group-text" id="basic-addon1">@</span>
+      </div>
+      <input type="text" class="form-control" placeholder="Username" aria-label="Username" aria-describedby="basic-addon1" value="{{jobdir}}">
+    </div>
+  </form>
+<span class="navbar-brand mb-0 h2" id="status">Status</span>
+
+<h3>   stuff here.</h3>
+      {% block title %}<h3>Test results</h3>{% endblock %}
+  <p>{{jobdir}}</p>
+
+  <!-- Navbar content -->
+</nav>
+
+
+<div id="status"></div>
+
+<div class="accordion" id="questions">
+  {% for qkey, qbody in questions.items() %}
+  <div class="accordion-item">
+    <h2 class="accordion-header" id="{{qkey}}-header">
+      <button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#{{qkey}}-collapse" aria-expanded="true" aria-controls="{{qkey}}-collapse">
+        {{qbody.title}}: Accordion Item #1 <span class="glyphicon glyphicon-star" aria-hidden="true"> das sdafsdf</span> Star
+      </button>
+    </h2>
+    </div>
+   <div id="{{qkey}}-collapse" class="accordion-collapse collapse show" aria-labelledby="{{qkey}}-header">
+      <div class="accordion-body">
+        <!-- Begin question body -->
+            <strong>This is the first item's accordion body.</strong> It is shown by default, until the collapse plugin adds the appropriate classes that we use to style each element. These classes control the overall appearance, as well as the showing and hiding via CSS transitions. You can modify any of this with custom CSS or overriding our default variables. It's also worth noting that just about any HTML can go within the <code>.accordion-body</code>, though the transition does limit overflow.
+          <div class="accordion" id="{{qkey}}-tests">
+                {% for ikey, ibody in qbody.tests.items() %}
+              <div class="accordion-item">
+                <h2 class="accordion-header" id="{{ikey}}-header">
+                  <button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#{{ikey}}-collapse" aria-expanded="true" aria-controls="{{ikey}}-collapse">
+                    {{ ibody.title }}: Accordion Item #1 <span class="glyphicon glyphicon-star" aria-hidden="true"> das sdafsdf</span> Star
+                  </button>
+                </h2>
+                </div>
+               <div id="{{ikey}}-collapse" class="accordion-collapse collapse show" aria-labelledby="{{ikey}}-header">
+                  <div class="accordion-body">
+                    <!-- Begin item body-->
+                        <strong>This is the first item's accordion body.</strong> It is shown by default, until the collapse plugin adds the appropriate classes that we use to style each element. These classes control the overall appearance, as well as the showing and hiding via CSS transitions. You can modify any of this with custom CSS or overriding our default variables. It's also worth noting that just about any HTML can go within the <code>.accordion-body</code>, though the transition does limit overflow.
+                    <!-- End item body -->
+                  </div>
+               </div>
+              {% endfor %}
+          </div>
+        <!-- End question body -->
+      </div>
+   </div>
+{% endfor %}
+</div>
+{% endblock %}
diff --git a/src/unitgrade/dashboard/templates/old/index2.html b/src/unitgrade/dashboard/templates/old/index2.html
new file mode 100644
index 0000000000000000000000000000000000000000..94946cfde5e73c35616277107948e5ce46af4df5
--- /dev/null
+++ b/src/unitgrade/dashboard/templates/old/index2.html
@@ -0,0 +1,121 @@
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <title>pyxterm.js</title>
+    <style>
+      html {
+        font-family: arial;
+      }
+    </style>
+    <link
+      rel="stylesheet"
+      href="https://unpkg.com/xterm@4.11.0/css/xterm.css"
+    />
+  </head>
+  <body>
+    <span style="font-size: 1.4em">pyxterm.js</span>&nbsp;&nbsp;&nbsp;
+    <span style="font-size: small"
+      >status:
+      <span style="font-size: small" id="status">connecting...</span></span
+    >
+
+    <div style="width: 100%; height: calc(100% - 50px)" id="terminal"></div>
+    <div style="width: 50%; height: calc(20%)" id="term2">another terminal</div>
+    <p style="text-align: right; font-size: small">
+      built by <a href="https://chadsmith.dev">Chad Smith</a>
+      <a href="https://github.com/cs01">GitHub</a>
+    </p>
+    <!-- xterm -->
+    <script src="https://unpkg.com/xterm@4.11.0/lib/xterm.js"></script>
+    <script src="https://unpkg.com/xterm-addon-fit@0.5.0/lib/xterm-addon-fit.js"></script>
+    <script src="https://unpkg.com/xterm-addon-web-links@0.4.0/lib/xterm-addon-web-links.js"></script>
+    <script src="https://unpkg.com/xterm-addon-search@0.8.0/lib/xterm-addon-sear
+ch.js"></script>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.min.js"></script>
+
+    <script>
+      const term = new Terminal({
+        cursorBlink: true,
+        macOptionIsMeta: true,
+        scrollback: 300,
+      });
+      // https://github.com/xtermjs/xterm.js/issues/2941
+      const fit = new FitAddon.FitAddon();
+      term.loadAddon(fit);
+      term.loadAddon(new WebLinksAddon.WebLinksAddon());
+      term.loadAddon(new SearchAddon.SearchAddon());
+
+      term.open(document.getElementById("terminal"));
+      fit.fit();
+      term.resize(15, 50);
+      console.log(`size: ${term.cols} columns, ${term.rows} rows`);
+      fit.fit();
+      term.writeln("Welcome to pyxterm.js!");
+      term.writeln("https://github.com/cs01/pyxterm.js");
+      term.onData((data) => {
+        console.log("key pressed in browser:", data);
+        socket.emit("pty-input", { input: data });
+      });
+
+      const socket = io.connect("/pty");
+      const status = document.getElementById("status");
+
+      socket.on("pty-output", function (data) {
+        console.log("new output received from server:", data.output);
+        term.write(data.output);
+      });
+
+      socket.on("connect", () => {
+        fitToscreen();
+        status.innerHTML =
+          '<span style="background-color: lightgreen;">connected</span>';
+      });
+
+      socket.on("disconnect", () => {
+        status.innerHTML =
+          '<span style="background-color: #ff8383;">disconnected</span>';
+      });
+
+      function fitToscreen() {
+        fit.fit();
+        const dims = { cols: term.cols, rows: term.rows };
+        console.log("sending new dimensions to server's pty", dims);
+        socket.emit("resize", dims);
+      }
+
+      function debounce(func, wait_ms) {
+        let timeout;
+        return function (...args) {
+          const context = this;
+          clearTimeout(timeout);
+          timeout = setTimeout(() => func.apply(context, args), wait_ms);
+        };
+      }
+
+      const wait_ms = 50;
+      window.onresize = debounce(fitToscreen, wait_ms);
+    </script>
+    <script>
+      const term2 = new Terminal({
+        cursorBlink: true,
+        macOptionIsMeta: true,
+        scrollback: true,
+      });
+      // https://github.com/xtermjs/xterm.js/issues/2941
+      const fit2 = new FitAddon.FitAddon();
+      term2.loadAddon(fit2);
+      term2.loadAddon(new WebLinksAddon.WebLinksAddon());
+      term2.loadAddon(new SearchAddon.SearchAddon());
+
+      term2.open(document.getElementById("term2"));
+      fit2.fit();
+      term2.resize(15, 50);
+      console.log(`size: ${term.cols} columns, ${term.rows} rows`);
+      fit2.fit();
+      term2.writeln("Welcome to pyxterm.js!");
+      term2.writeln("https://github.com/cs01/pyxterm.js");
+
+    </script>
+
+  </body>
+</html>
\ No newline at end of file
diff --git a/src/unitgrade/dashboard/templates/old/scrap.html b/src/unitgrade/dashboard/templates/old/scrap.html
new file mode 100644
index 0000000000000000000000000000000000000000..0f28275acc15311cc84bcabc9f87f97af29a7ddf
--- /dev/null
+++ b/src/unitgrade/dashboard/templates/old/scrap.html
@@ -0,0 +1,13 @@
+   <li class="mb-1">
+        <button class="btn btn-toggle align-items-center rounded collapsed" data-bs-toggle="collapse" data-bs-target="#dashboard-collapse" aria-expanded="false">
+          Dashboard
+        </button>
+        <div class="collapse" id="dashboard-collapse">
+          <ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small">
+            <li><a href="#" class="link-dark rounded">Overview</a></li>
+            <li><a href="#" class="link-dark rounded">Weekly</a></li>
+            <li><a href="#" class="link-dark rounded">Monthly</a></li>
+            <li><a href="#" class="link-dark rounded">Annually</a></li>
+          </ul>
+        </div>
+      </li>
\ No newline at end of file
diff --git a/src/unitgrade/dashboard/templates/terminal.html b/src/unitgrade/dashboard/templates/terminal.html
new file mode 100644
index 0000000000000000000000000000000000000000..1a0cf82efafbe058d62ac0f8b74a39b4870e53af
--- /dev/null
+++ b/src/unitgrade/dashboard/templates/terminal.html
@@ -0,0 +1,103 @@
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <title>pyxterm.js</title>
+    <style>
+      html {
+        font-family: arial;
+      }
+    </style>
+    <link
+      rel="stylesheet"
+      href="https://unpkg.com/xterm@4.11.0/css/xterm.css"
+    />
+  </head>
+  <body>
+    <span style="font-size: 1.4em">pyxterm.js</span>&nbsp;&nbsp;&nbsp;
+    <span style="font-size: small"
+      >status:
+      <span style="font-size: small" id="status">connecting...</span></span>
+
+    <div style="width: 100%; height: calc(100% - 50px)" id="terminal"></div>
+
+    <p style="text-align: right; font-size: small">
+      built by <a href="https://chadsmith.dev">Chad Smith</a>
+      <a href="https://github.com/cs01">GitHub</a>
+    </p>
+    <!-- xterm -->
+    <script src="https://unpkg.com/xterm@4.11.0/lib/xterm.js"></script>
+    <script src="https://unpkg.com/xterm-addon-fit@0.5.0/lib/xterm-addon-fit.js"></script>
+    <script src="https://unpkg.com/xterm-addon-web-links@0.4.0/lib/xterm-addon-web-links.js"></script>
+    <script src="https://unpkg.com/xterm-addon-search@0.8.0/lib/xterm-addon-search.js"></script>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.min.js"></script>
+
+    <script>
+      const term = new Terminal({
+        cursorBlink: true,
+        macOptionIsMeta: true,
+        scrollback: true,
+      });
+      // https://github.com/xtermjs/xterm.js/issues/2941
+      const fit = new FitAddon.FitAddon();
+      term.loadAddon(fit);
+      term.loadAddon(new WebLinksAddon.WebLinksAddon());
+      term.loadAddon(new SearchAddon.SearchAddon());
+
+      term.open(document.getElementById("terminal"));
+      fit.fit();
+      term.resize(15, 50);
+      console.log(`size: ${term.cols} columns, ${term.rows} rows`);
+      fit.fit();
+      term.writeln("Welcome to pyxterm.js!");
+      term.writeln("https://github.com/cs01/pyxterm.js");
+      term.onData((data) => {
+        console.log("key pressed in browser:", data);
+        socket.emit("pty-input", { input: data });
+      });
+
+      const socket = io.connect("/pty");
+       const status = document.getElementById("status");
+
+
+      socket.of("/admin").on("state", function (data) {
+        console.log("new output received from server:", data.output);
+        term.write(data.output);
+      });
+
+      socket.on("pty-output", function (data) {
+        console.log("new output received from server:", data.output);
+        term.write(data.output);
+      });
+
+      socket.on("connect", () => {
+        fitToscreen();
+        status.innerHTML =
+          '<span style="background-color: lightgreen;">connected</span>';
+      });
+
+      socket.on("disconnect", () => {
+        status.innerHTML =
+          '<span style="background-color: #ff8383;">disconnected</span>';
+      });
+
+      function fitToscreen() {
+        fit.fit();
+        const dims = { cols: term.cols, rows: term.rows };
+        console.log("sending new dimensions to server's pty", dims);
+        socket.emit("resize", dims);
+      }
+
+      function debounce(func, wait_ms) {
+        let timeout;
+        return function (...args) {
+          const context = this;
+          clearTimeout(timeout);
+          timeout = setTimeout(() => func.apply(context, args), wait_ms);
+        };
+      }
+
+      const wait_ms = 50;
+      window.onresize = debounce(fitToscreen, wait_ms);
+    </script>
+  </body>
+</html>
\ No newline at end of file
diff --git a/src/unitgrade/dashboard/templates/wz.html b/src/unitgrade/dashboard/templates/wz.html
new file mode 100644
index 0000000000000000000000000000000000000000..5307f9378edef9955b75767dc8fccf0ef8276e38
--- /dev/null
+++ b/src/unitgrade/dashboard/templates/wz.html
@@ -0,0 +1,295 @@
+<!doctype html>
+<html lang=en>
+  <head>
+    <title>AssertionError
+ // Werkzeug Debugger</title>
+
+
+    <link rel="canonical" href="https://getbootstrap.com/docs/5.0/examples/sidebars/">
+    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-gH2yIJqKdNHPEq0n4Mqa/HGKIhSkIHeL5AyhkYV8i59U5AR6csBvApHHNl/vI1Bx" crossorigin="anonymous">
+    <script src="https://unpkg.com/xterm@4.11.0/lib/xterm.js"></script>
+    <script src="https://unpkg.com/xterm-addon-fit@0.5.0/lib/xterm-addon-fit.js"></script>
+    <script src="https://unpkg.com/xterm-addon-web-links@0.4.0/lib/xterm-addon-web-links.js"></script>
+    <script src="https://unpkg.com/xterm-addon-search@0.8.0/lib/xterm-addon-search.js"></script>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.min.js"></script>
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-A3rJD856KowSb7dwlZdYEkO39Gagi7vIsF0jrRAoQmDKKtQBHUuLZ9AsSv4jD4Xa" crossorigin="anonymous"></script>
+<script src="https://ajax.aspnetcdn.com/ajax/jQuery/jquery-3.3.1.min.js"></script>
+     <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css">
+
+    <style>
+      .bd-placeholder-img {
+        font-size: 1.125rem;
+        text-anchor: middle;
+        -webkit-user-select: none;
+        -moz-user-select: none;
+        user-select: none;
+      }
+
+      @media (min-width: 768px) {
+        .bd-placeholder-img-lg {
+          font-size: 3.5rem;
+        }
+      }
+    </style>
+    <!-- Custom styles for this template -->
+    <link href="static/sidebars.css" rel="stylesheet">
+        <link href="static/wz_style.css" rel="stylesheet">
+    <link href="static/unitgrade.css" rel="stylesheet">
+
+
+
+    <link rel="stylesheet" href="/static/wz_style_modified.css">
+    <link rel="shortcut icon"
+        href="?__debugger__=yes&amp;cmd=resource&amp;f=console.png">
+    <script src="/static/wz_js.js"></script>
+    <script>
+      var CONSOLE_MODE = false,
+          EVALEX = true,
+          EVALEX_TRUSTED = true,
+          SECRET = "Xbtn32ZR6AqRabFk2a3l";
+    </script>
+  </head>
+  <body style="background-color: #fff">
+    <div class="debugger">
+<h1>AssertionError</h1>
+<div class="detail">
+  <p class="errormsg">AssertionError
+</p>
+</div>
+<h2 class="traceback">Traceback <em>(most recent call last)</em></h2>
+<div class="traceback">
+  <h3></h3>
+  <ul><li><div class="frame" id="frame-140166614937696">
+  <h4>File <cite class="filename">"/usr/local/lib/python3.10/dist-packages/flask/app.py"</cite>,
+      line <em class="line">2548</em>,
+      in <code class="function">__call__</code></h4>
+  <div class="source library"><pre class="line before"><span class="ws">    </span>def __call__(self, environ: dict, start_response: t.Callable) -&gt; t.Any:</pre>
+<pre class="line before"><span class="ws">        </span>&#34;&#34;&#34;The WSGI server calls the Flask application object as the</pre>
+<pre class="line before"><span class="ws">        </span>WSGI application. This calls :meth:`wsgi_app`, which can be</pre>
+<pre class="line before"><span class="ws">        </span>wrapped to apply middleware.</pre>
+<pre class="line before"><span class="ws">        </span>&#34;&#34;&#34;</pre>
+<pre class="line current"><span class="ws">        </span>return self.wsgi_app(environ, start_response)</pre></div>
+</div>
+
+<li><div class="frame" id="frame-140166595255472">
+  <h4>File <cite class="filename">"/home/tuhe/.local/lib/python3.10/site-packages/flask_socketio/__init__.py"</cite>,
+      line <em class="line">43</em>,
+      in <code class="function">__call__</code></h4>
+  <div class="source "><pre class="line before"><span class="ws">                                                  </span>socketio_path=socketio_path)</pre>
+<pre class="line before"><span class="ws"></span> </pre>
+<pre class="line before"><span class="ws">    </span>def __call__(self, environ, start_response):</pre>
+<pre class="line before"><span class="ws">        </span>environ = environ.copy()</pre>
+<pre class="line before"><span class="ws">        </span>environ[&#39;flask.app&#39;] = self.flask_app</pre>
+<pre class="line current"><span class="ws">        </span>return super(_SocketIOMiddleware, self).__call__(environ,</pre>
+<pre class="line after"><span class="ws">                                                         </span>start_response)</pre>
+<pre class="line after"><span class="ws"></span> </pre>
+<pre class="line after"><span class="ws"></span> </pre>
+<pre class="line after"><span class="ws"></span>class _ManagedSession(dict, SessionMixin):</pre>
+<pre class="line after"><span class="ws">    </span>&#34;&#34;&#34;This class is used for user sessions that are managed by</pre></div>
+</div>
+
+<li><div class="frame" id="frame-140166595342432">
+  <h4>File <cite class="filename">"/home/tuhe/.local/lib/python3.10/site-packages/engineio/middleware.py"</cite>,
+      line <em class="line">74</em>,
+      in <code class="function">__call__</code></h4>
+  <div class="source "><pre class="line before"><span class="ws">                    </span>&#39;200 OK&#39;,</pre>
+<pre class="line before"><span class="ws">                    </span>[(&#39;Content-Type&#39;, static_file[&#39;content_type&#39;])])</pre>
+<pre class="line before"><span class="ws">                </span>with open(static_file[&#39;filename&#39;], &#39;rb&#39;) as f:</pre>
+<pre class="line before"><span class="ws">                    </span>return [f.read()]</pre>
+<pre class="line before"><span class="ws">            </span>elif self.wsgi_app is not None:</pre>
+<pre class="line current"><span class="ws">                </span>return self.wsgi_app(environ, start_response)</pre>
+<pre class="line after"><span class="ws">        </span>return self.not_found(start_response)</pre>
+<pre class="line after"><span class="ws"></span> </pre>
+<pre class="line after"><span class="ws">    </span>def not_found(self, start_response):</pre>
+<pre class="line after"><span class="ws">        </span>start_response(&#34;404 Not Found&#34;, [(&#39;Content-Type&#39;, &#39;text/plain&#39;)])</pre>
+<pre class="line after"><span class="ws">        </span>return [b&#39;Not Found&#39;]</pre></div>
+</div>
+
+<li><div class="frame" id="frame-140166595344224">
+  <h4>File <cite class="filename">"/usr/local/lib/python3.10/dist-packages/flask/app.py"</cite>,
+      line <em class="line">2528</em>,
+      in <code class="function">wsgi_app</code></h4>
+  <div class="source library"><pre class="line before"><span class="ws">            </span>try:</pre>
+<pre class="line before"><span class="ws">                </span>ctx.push()</pre>
+<pre class="line before"><span class="ws">                </span>response = self.full_dispatch_request()</pre>
+<pre class="line before"><span class="ws">            </span>except Exception as e:</pre>
+<pre class="line before"><span class="ws">                </span>error = e</pre>
+<pre class="line current"><span class="ws">                </span>response = self.handle_exception(e)</pre>
+<pre class="line after"><span class="ws">            </span>except:  # noqa: B001</pre>
+<pre class="line after"><span class="ws">                </span>error = sys.exc_info()[1]</pre>
+<pre class="line after"><span class="ws">                </span>raise</pre>
+<pre class="line after"><span class="ws">            </span>return response(environ, start_response)</pre>
+<pre class="line after"><span class="ws">        </span>finally:</pre></div>
+</div>
+
+<li><div class="frame" id="frame-140166595344336">
+  <h4>File <cite class="filename">"/usr/local/lib/python3.10/dist-packages/flask/app.py"</cite>,
+      line <em class="line">2525</em>,
+      in <code class="function">wsgi_app</code></h4>
+  <div class="source library"><pre class="line before"><span class="ws">        </span>ctx = self.request_context(environ)</pre>
+<pre class="line before"><span class="ws">        </span>error: t.Optional[BaseException] = None</pre>
+<pre class="line before"><span class="ws">        </span>try:</pre>
+<pre class="line before"><span class="ws">            </span>try:</pre>
+<pre class="line before"><span class="ws">                </span>ctx.push()</pre>
+<pre class="line current"><span class="ws">                </span>response = self.full_dispatch_request()</pre>
+<pre class="line after"><span class="ws">            </span>except Exception as e:</pre>
+<pre class="line after"><span class="ws">                </span>error = e</pre>
+<pre class="line after"><span class="ws">                </span>response = self.handle_exception(e)</pre>
+<pre class="line after"><span class="ws">            </span>except:  # noqa: B001</pre>
+<pre class="line after"><span class="ws">                </span>error = sys.exc_info()[1]</pre></div>
+</div>
+
+<li><div class="frame" id="frame-140166595344448">
+  <h4>File <cite class="filename">"/usr/local/lib/python3.10/dist-packages/flask/app.py"</cite>,
+      line <em class="line">1822</em>,
+      in <code class="function">full_dispatch_request</code></h4>
+  <div class="source library"><pre class="line before"><span class="ws">            </span>request_started.send(self)</pre>
+<pre class="line before"><span class="ws">            </span>rv = self.preprocess_request()</pre>
+<pre class="line before"><span class="ws">            </span>if rv is None:</pre>
+<pre class="line before"><span class="ws">                </span>rv = self.dispatch_request()</pre>
+<pre class="line before"><span class="ws">        </span>except Exception as e:</pre>
+<pre class="line current"><span class="ws">            </span>rv = self.handle_user_exception(e)</pre>
+<pre class="line after"><span class="ws">        </span>return self.finalize_request(rv)</pre>
+<pre class="line after"><span class="ws"></span> </pre>
+<pre class="line after"><span class="ws">    </span>def finalize_request(</pre>
+<pre class="line after"><span class="ws">        </span>self,</pre>
+<pre class="line after"><span class="ws">        </span>rv: t.Union[ft.ResponseReturnValue, HTTPException],</pre></div>
+</div>
+
+<li><div class="frame" id="frame-140166595344560">
+  <h4>File <cite class="filename">"/usr/local/lib/python3.10/dist-packages/flask/app.py"</cite>,
+      line <em class="line">1820</em>,
+      in <code class="function">full_dispatch_request</code></h4>
+  <div class="source library"><pre class="line before"><span class="ws"></span> </pre>
+<pre class="line before"><span class="ws">        </span>try:</pre>
+<pre class="line before"><span class="ws">            </span>request_started.send(self)</pre>
+<pre class="line before"><span class="ws">            </span>rv = self.preprocess_request()</pre>
+<pre class="line before"><span class="ws">            </span>if rv is None:</pre>
+<pre class="line current"><span class="ws">                </span>rv = self.dispatch_request()</pre>
+<pre class="line after"><span class="ws">        </span>except Exception as e:</pre>
+<pre class="line after"><span class="ws">            </span>rv = self.handle_user_exception(e)</pre>
+<pre class="line after"><span class="ws">        </span>return self.finalize_request(rv)</pre>
+<pre class="line after"><span class="ws"></span> </pre>
+<pre class="line after"><span class="ws">    </span>def finalize_request(</pre></div>
+</div>
+
+<li><div class="frame" id="frame-140166595344672">
+  <h4>File <cite class="filename">"/usr/local/lib/python3.10/dist-packages/flask/app.py"</cite>,
+      line <em class="line">1796</em>,
+      in <code class="function">dispatch_request</code></h4>
+  <div class="source library"><pre class="line before"><span class="ws">            </span>and req.method == &#34;OPTIONS&#34;</pre>
+<pre class="line before"><span class="ws">        </span>):</pre>
+<pre class="line before"><span class="ws">            </span>return self.make_default_options_response()</pre>
+<pre class="line before"><span class="ws">        </span># otherwise dispatch to the handler for that endpoint</pre>
+<pre class="line before"><span class="ws">        </span>view_args: t.Dict[str, t.Any] = req.view_args  # type: ignore[assignment]</pre>
+<pre class="line current"><span class="ws">        </span>return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)</pre>
+<pre class="line after"><span class="ws"></span> </pre>
+<pre class="line after"><span class="ws">    </span>def full_dispatch_request(self) -&gt; Response:</pre>
+<pre class="line after"><span class="ws">        </span>&#34;&#34;&#34;Dispatches the request and on top of that performs request</pre>
+<pre class="line after"><span class="ws">        </span>pre and postprocessing as well as HTTP exception catching and</pre>
+<pre class="line after"><span class="ws">        </span>error handling.</pre></div>
+</div>
+
+<li><div class="frame" id="frame-140166595344784">
+  <h4>File <cite class="filename">"/home/tuhe/Documents/unitgrade_private/vue_flask_stuff/server/app.py"</cite>,
+      line <em class="line">201</em>,
+      in <code class="function">navbar</code></h4>
+  <div class="source "><pre class="line before"><span class="ws">            </span>logging.debug(&#34;received input from browser: %s&#34; % data[&#34;input&#34;])</pre>
+<pre class="line before"><span class="ws">            </span>os.write(app.config[&#34;fd&#34;], data[&#34;input&#34;].encode())</pre>
+<pre class="line before"><span class="ws"></span> </pre>
+<pre class="line before"><span class="ws">    </span>@app.route(&#34;/crash&#34;)</pre>
+<pre class="line before"><span class="ws">    </span>def navbar():</pre>
+<pre class="line current"><span class="ws">        </span>assert False</pre>
+<pre class="line after"><span class="ws">        </span># return render_template(&#34;index3.html&#34;)</pre>
+<pre class="line after"><span class="ws"></span> </pre>
+<pre class="line after"><span class="ws"></span> </pre>
+<pre class="line after"><span class="ws"></span> </pre>
+<pre class="line after"><span class="ws">    </span>@socketio.on(&#34;reconnected&#34;, namespace=&#34;/status&#34;)</pre></div>
+</div>
+</ul>
+  <blockquote>AssertionError
+</blockquote>
+</div>
+
+<div class="plain">
+    <p>
+      This is the Copy/Paste friendly version of the traceback.
+    </p>
+    <textarea cols="50" rows="10" name="code" readonly>Traceback (most recent call last):
+  File &#34;/usr/local/lib/python3.10/dist-packages/flask/app.py&#34;, line 2548, in __call__
+    return self.wsgi_app(environ, start_response)
+  File &#34;/home/tuhe/.local/lib/python3.10/site-packages/flask_socketio/__init__.py&#34;, line 43, in __call__
+    return super(_SocketIOMiddleware, self).__call__(environ,
+  File &#34;/home/tuhe/.local/lib/python3.10/site-packages/engineio/middleware.py&#34;, line 74, in __call__
+    return self.wsgi_app(environ, start_response)
+  File &#34;/usr/local/lib/python3.10/dist-packages/flask/app.py&#34;, line 2528, in wsgi_app
+    response = self.handle_exception(e)
+  File &#34;/usr/local/lib/python3.10/dist-packages/flask/app.py&#34;, line 2525, in wsgi_app
+    response = self.full_dispatch_request()
+  File &#34;/usr/local/lib/python3.10/dist-packages/flask/app.py&#34;, line 1822, in full_dispatch_request
+    rv = self.handle_user_exception(e)
+  File &#34;/usr/local/lib/python3.10/dist-packages/flask/app.py&#34;, line 1820, in full_dispatch_request
+    rv = self.dispatch_request()
+  File &#34;/usr/local/lib/python3.10/dist-packages/flask/app.py&#34;, line 1796, in dispatch_request
+    return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)
+  File &#34;/home/tuhe/Documents/unitgrade_private/vue_flask_stuff/server/app.py&#34;, line 201, in navbar
+    assert False
+AssertionError
+</textarea>
+</div>
+<div class="explanation">
+  The debugger caught an exception in your WSGI application.  You can now
+  look at the traceback which led to the error.  <span class="nojavascript">
+  If you enable JavaScript you can also use additional features such as code
+  execution (if the evalex feature is enabled), automatic pasting of the
+  exceptions and much more.</span>
+</div>
+      <div class="footer">
+        Brought to you by <strong class="arthur">DON'T PANIC</strong>, your
+        friendly Werkzeug powered traceback interpreter.
+      </div>
+    </div>
+
+    <div class="pin-prompt">
+      <div class="inner">
+        <h3>Console Locked</h3>
+        <p>
+          The console is locked and needs to be unlocked by entering the PIN.
+          You can find the PIN printed out on the standard output of your
+          shell that runs the server.
+        <form>
+          <p>PIN:
+            <input type=text name=pin size=14>
+            <input type=submit name=btn value="Confirm Pin">
+        </form>
+      </div>
+    </div>
+
+   <script src="../assets/dist/js/bootstrap.bundle.min.js"></script>
+      <script src="static/sidebars.js"></script>
+      <script src="static/unitgrade.js"></script>
+  </body>
+</html>
+
+<!--
+
+Traceback (most recent call last):
+  File "/usr/local/lib/python3.10/dist-packages/flask/app.py", line 2548, in __call__
+    return self.wsgi_app(environ, start_response)
+  File "/home/tuhe/.local/lib/python3.10/site-packages/flask_socketio/__init__.py", line 43, in __call__
+    return super(_SocketIOMiddleware, self).__call__(environ,
+  File "/home/tuhe/.local/lib/python3.10/site-packages/engineio/middleware.py", line 74, in __call__
+    return self.wsgi_app(environ, start_response)
+  File "/usr/local/lib/python3.10/dist-packages/flask/app.py", line 2528, in wsgi_app
+    response = self.handle_exception(e)
+  File "/usr/local/lib/python3.10/dist-packages/flask/app.py", line 2525, in wsgi_app
+    response = self.full_dispatch_request()
+  File "/usr/local/lib/python3.10/dist-packages/flask/app.py", line 1822, in full_dispatch_request
+    rv = self.handle_user_exception(e)
+  File "/usr/local/lib/python3.10/dist-packages/flask/app.py", line 1820, in full_dispatch_request
+    rv = self.dispatch_request()
+  File "/usr/local/lib/python3.10/dist-packages/flask/app.py", line 1796, in dispatch_request
+    return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)
+  File "/home/tuhe/Documents/unitgrade_private/vue_flask_stuff/server/app.py", line 201, in navbar
+    assert False
+AssertionError
diff --git a/src/unitgrade/dashboard/watcher.py b/src/unitgrade/dashboard/watcher.py
new file mode 100644
index 0000000000000000000000000000000000000000..f8b9c3c0e4e1adc6ea94ce4badd760031015c2a9
--- /dev/null
+++ b/src/unitgrade/dashboard/watcher.py
@@ -0,0 +1,60 @@
+import os
+from watchdog.events import FileSystemEventHandler
+import time
+from watchdog.observers import Observer
+import datetime
+import fnmatch
+import os
+
+class Watcher:
+    # DIRECTORY_TO_WATCH = "/path/to/my/directory"
+
+    def __init__(self, base_directory, watched_files_dictionary, watched_files_lock):
+        self.base_directory = base_directory
+        self.watched_files_dictionary = watched_files_dictionary
+        self.watched_files_lock = watched_files_lock
+        self.observer = Observer()
+
+    def run(self):
+        event_handler = Handler(self.watched_files_dictionary, self.watched_files_lock)
+        # directory = os.path.commonpath([os.path.dirname(f) for f in self.watched_files_dictionary.keys()])
+        self.observer.schedule(event_handler, self.base_directory, recursive=True)
+        self.observer.start()
+
+    def close(self):
+        print("Closing watcher..")
+        self.observer.stop()
+        self.observer.join()
+        print("closed")
+
+    def __del__(self):
+        print("Stopping watcher...")
+        self.close()
+
+
+class Handler(FileSystemEventHandler):
+    def __init__(self, watched_files_dictionary, watched_files_lock):
+        self.watched_files_dictionary = watched_files_dictionary
+        self.watched_files_lock = watched_files_lock
+        super().__init__()
+
+    def on_any_event(self, event):
+        if event.is_directory:
+            return None
+        elif event.event_type == 'created' or event.event_type == 'modified':
+            with self.watched_files_lock:
+                fnd_ = None
+                for k in self.watched_files_dictionary:
+                    if fnmatch.fnmatch(event.src_path, k):
+                        fnd_ = k
+                        break
+                if fnd_ is not None:
+                    # print("Watcher, recording change to", fnd_)
+                    if event.src_path.endswith("json"):
+                        from pupdb.core import PupDB
+                        db = PupDB(event.src_path)
+                        if db.get("state") == "fail":
+                            print("File watcher state:", db.get("state"), "in", event.src_path)
+
+                    self.watched_files_dictionary[fnd_]['last_recorded_change'] = datetime.datetime.now()
+                    self.watched_files_dictionary[fnd_]['file'] = event.src_path
diff --git a/src/unitgrade/evaluate.py b/src/unitgrade/evaluate.py
index 9c4392b101915a3e060f7993676782dd9b2933eb..1ad21fef2fd529a7bef8c192e000eedeb03eebe3 100644
--- a/src/unitgrade/evaluate.py
+++ b/src/unitgrade/evaluate.py
@@ -41,7 +41,9 @@ parser.add_argument('--unmute',  action="store_true",  help='Show result of prin
 parser.add_argument('--passall',  action="store_true",  help='Automatically pass all tests. Useful when debugging.')
 parser.add_argument('--noprogress',  action="store_true",  help='Disable progress bars.')
 
-def evaluate_report_student(report, question=None, qitem=None, unmute=None, passall=None, ignore_missing_file=False, show_tol_err=False, show_privisional=True, noprogress=None):
+def evaluate_report_student(report, question=None, qitem=None, unmute=None, passall=None, ignore_missing_file=False,
+                            show_tol_err=False, show_privisional=True, noprogress=None,
+                            generate_artifacts=True):
     args = parser.parse_args()
     if noprogress is None:
         noprogress = args.noprogress
@@ -63,7 +65,8 @@ def evaluate_report_student(report, question=None, qitem=None, unmute=None, pass
 
     results, table_data = evaluate_report(report, question=question, show_progress_bar=not unmute and not noprogress, qitem=qitem,
                                           verbose=False, passall=passall, show_expected=args.showexpected, show_computed=args.showcomputed,unmute=unmute,
-                                          show_tol_err=show_tol_err)
+                                          show_tol_err=show_tol_err,
+                                          generate_artifacts=generate_artifacts)
 
 
     if question is None and show_privisional:
@@ -105,6 +108,7 @@ class SequentialTestLoader(unittest.TestLoader):
 def evaluate_report(report, question=None, qitem=None, passall=False, verbose=False,  show_expected=False, show_computed=False,unmute=False, show_help_flag=True, silent=False,
                     show_progress_bar=True,
                     show_tol_err=False,
+                    generate_artifacts=True, # Generate the artifact .json files. These are exclusively used by the dashboard.
                     big_header=True):
 
     from unitgrade.version import __version__
@@ -129,6 +133,7 @@ def evaluate_report(report, question=None, qitem=None, passall=False, verbose=Fa
     loader = SequentialTestLoader()
 
     for n, (q, w) in enumerate(report.questions):
+        q._generate_artifacts = generate_artifacts  # Set whether artifact .json files will be generated.
         if question is not None and n+1 != question:
             continue
         suite = loader.loadTestsFromTestCase(q)
diff --git a/src/unitgrade/framework.py b/src/unitgrade/framework.py
index a577dcf39f9741ae37f1ac1c93e3cf5b5c9ac927..9dbfd59f8fa3a5eef322b7b7cde9bb954cc40263 100644
--- a/src/unitgrade/framework.py
+++ b/src/unitgrade/framework.py
@@ -22,6 +22,15 @@ def setup_dir_by_class(C, base_dir):
     name = C.__class__.__name__
     return base_dir, name
 
+# def dash(func):
+#     if isinstance(func, classmethod):
+#         raise Exception("the @dash-decorator was used in the wrong order. The right order is: @dash\n@classmethod\ndef setUpClass(cls):")
+#
+#     def wrapper(*args, **kwargs):
+#         print("Something is happening before the function is called.")
+#         func(*args, **kwargs)
+#         print("Something is happening after the function is called.")
+#     return wrapper
 
 class Report:
     title = "report title"
@@ -101,9 +110,10 @@ class Report:
                 q._report = self
         for q, _ in self.questions:
             q._setup_answers_mode = True
+            # q._generate_artifacts = False # Disable artifact generation when the report is being set up.
 
         from unitgrade import evaluate_report_student
-        evaluate_report_student(self, unmute=verbose, noprogress=not verbose)
+        evaluate_report_student(self, unmute=verbose, noprogress=not verbose, generate_artifacts=False) # Disable artifact generation.
 
         # self.main()  # Run all tests in class just to get that out of the way...
         report_cache = {}
@@ -111,7 +121,7 @@ class Report:
             # print(self.questions)
             if hasattr(q, '_save_cache'):
                 q()._save_cache()
-                print("q is", q())
+                # print("q is", q())
                 report_cache[q.__qualname__] = q._cache2
             else:
                 report_cache[q.__qualname__] = {'no cache see _setup_answers in framework.py': True}
@@ -262,13 +272,16 @@ def get_hints(ss):
 
 
 class UTestCase(unittest.TestCase):
+    # a = 234
     _outcome = None  # A dictionary which stores the user-computed outcomes of all the tests. This differs from the cache.
     _cache = None  # Read-only cache. Ensures method always produce same result.
     _cache2 = None  # User-written cache.
     _with_coverage = False
     _covcache = None # Coverage cache. Written to if _with_coverage is true.
     _report = None  # The report used. This is very, very hacky and should always be None. Don't rely on it!
+    _run_in_report_mode = True
 
+    _generate_artifacts = True # Whether the file will generate the artifact .json files. This is used in the _grade-script mode.
     # If true, the tests will not fail when cache is used. This is necesary since otherwise the cache will not be updated
     # during setup, and the deploy script must be run many times.
     _setup_answers_mode = False
@@ -280,6 +293,11 @@ class UTestCase(unittest.TestCase):
             file = sys.stdout
         return Capturing2(stdout=file)
 
+    def __call__(self, *args, **kwargs):
+        a = '234'
+        pass
+
+
     @classmethod
     def question_title(cls):
         """ Return the question title """
@@ -290,15 +308,18 @@ class UTestCase(unittest.TestCase):
         return cls.__qualname__
 
     def run(self, result):
+        if not self._generate_artifacts:
+            return super().run(result)
         from unitgrade.artifacts import StdCapturing
         from unittest.case import TestCase
         from pupdb.core import PupDB
-        db = PupDB(self._artifact_file())
 
+        db = PupDB(self._artifact_file())
         db.set('run_id', np.random.randint(1000*1000))
         db.set("state", "running")
         db.set('coverage_files_changed', None)
 
+        # print("Re-running test")
         _stdout = sys.stdout
         _stderr = sys.stderr
 
@@ -329,83 +350,48 @@ class UTestCase(unittest.TestCase):
 
             if len(actual_errors) > 0:
                 ex, exi, tb = actual_errors[0]
-
-                # exi =  result_._excinfo[0]._excinfo
-                # tb = result_._excinfo[0]._excinfo[-1]
-                # DebugTraceback(tb)
-                # ex = exi[1]
                 exi.__traceback__ = tb
-                # tbe = _process_traceback(ex)
                 dbt = DebugTraceback(exi)
-                # dbt.render_traceback_text()
                 sys.stderr.write(dbt.render_traceback_text())
                 html = dbt.render_traceback_html(include_title="hello world")
-                # print(HEADER)
-
-                # from unittest.case import As
                 db.set('wz_stacktrace', html)
                 db.set('state', 'fail')
-
-
-                # print("> Set state of test to:", "fail", exi, tb)
-
             else:
-                print("> Set state of test to:", "pass")
                 db.set('state', 'pass')
-
-
         except Exception as e:
             print("-----------------.///////////////////////////////////////////////////////////////")
             # print(e)
             import traceback
             traceback.print_exc()
             raise e
-
         finally:
-
             sys.stdout = _stdout
             sys.stderr = _stderr
-
             std_capture.close()
-
-        # stderr_capture.close()
-        # if len(actual_errors)
-
-        # print(result._test._error_fed_during_run)
-        # print(self._error_fed_during_run)
-        # print( result.errors[0][0]._error_fed_during_run )
-        #
-        # result_.errors[0][0]._error_fed_during_run
-
-        # result_._excinfo[0].errisinstance(Exception)
-        # import AssertionError
-        from werkzeug.debug.tbtools import HEADER
-        # from pupdb.core import PupDB
-        # db = PupDB(self._artifact_file())
-
-        # actual_errors
-
-
         return result_
 
-
     @classmethod
     def before_setup_called(cls):
         print("hi")
         # print("I am called before the fucking class is fucking made. setUpClass has been broken!")
         pass
 
-    setUpClass_not_overwritten = False
+    _setUpClass_not_overwritten = False
     @classmethod
     def setUpClass(cls) -> None:
-        cls.setUpClass_not_overwritten = True
-        pass
+        cls._setUpClass_not_overwritten = True
 
     @classmethod
     def __new__(cls, *args, **kwargs):
         old_setup = cls.setUpClass
         def new_setup():
+            raise Exception("Bad")
             cls.before_setup_called()
+            if cls.setUpClass == UTestCase.setUpClass:
+                print("Setup class not overwritten")
+            else:
+                print("Setup class is overwritten")
+
             try:
                 old_setup()
             except Exception as e:
@@ -413,8 +399,23 @@ class UTestCase(unittest.TestCase):
             finally:
                 pass
 
-        cls.setUpClass = new_setup
-        return super().__new__(cls)
+        # cls.setUpClass = new_setup
+        ci = super().__new__(cls)
+        ci.setUpClass = new_setup
+        return ci
+
+    # def inheritors(klass):
+    # import new
+    # z.q = new.instancemethod(method, z, None)
+
+    def __getattr__(self, item):
+        print("hi there ", item)
+        return super().__getattr__(item)
+
+    def __getattribute__(self, item):
+        print("oh hello sexy. ", item)
+        return super().__getattribute__(item)
+
 
 
     def _callSetUp(self):
@@ -532,9 +533,6 @@ class UTestCase(unittest.TestCase):
         # Find the report class this class is defined within.
         if skip_remote_check:
             return
-        # import inspect
-
-        # file = inspect.getfile(self.__class__)
         import importlib, inspect
         found_reports = []
         # print("But do I have report", self._report)
@@ -628,7 +626,6 @@ class UTestCase(unittest.TestCase):
         diff = np.abs(a1 - a2)
         return diff
 
-
     def assertLinf(self, first, second=None, tol=1e-5, msg=None):
         """ Test in the L_infinity norm.
         :param first:
@@ -681,6 +678,11 @@ class UTestCase(unittest.TestCase):
     def _cache_file(self):
         return os.path.dirname(inspect.getabsfile(type(self))) + "/unitgrade_data/" + self.__class__.__name__ + ".pkl"
 
+    @classmethod
+    def _artifact_file_for_setUpClass(cls):
+        cf = os.path.dirname(inspect.getabsfile(cls)) + "/unitgrade_data/" + cls.__name__
+        return os.path.join(os.path.dirname(self._cache_file()), "-setUpClass.json")
+
     def _artifact_file(self):
         """ File for the artifacts DB (thread safe). This file is optinal. Note that it is a pupdb database file.
         Note the file is shared between all sub-questions. """
@@ -813,6 +815,22 @@ class UTestCase(unittest.TestCase):
     def startTestRun(self):
         super().startTestRun()
 
+# subclasses = set()
+# work = [UTestCase]
+# while work:
+#     parent = work.pop()
+#     for child in parent.__subclasses__():
+#         if child not in subclasses:
+#             subclasses.add(child)
+#             work.append(child)
+# return subclasses
+# import builtins
+# ga = builtins.getattr
+# def my_funky_getatt(a,b,c=None):
+#     print("ga", a, b, c)
+#     return ga(a,b,c)
+# builtins.getattr = my_funky_getatt
+
 class Required:
     pass
 
@@ -838,4 +856,17 @@ class NotebookTestCase(UTestCase):
 
     @property
     def nb(self):
-        return self.__class__._nb
\ No newline at end of file
+        return self.__class__._nb
+
+# import __builtin__
+# all subclasses which are known at this point.
+# def get_all_subclasses(cls):
+#     all_subclasses = []
+#
+#     for subclass in cls.__subclasses__():
+#         all_subclasses.append(subclass)
+#         all_subclasses.extend(get_all_subclasses(subclass))
+#
+#     return all_subclasses
+#
+# a = 234
\ No newline at end of file
diff --git a/src/unitgrade/version.py b/src/unitgrade/version.py
index 8019e24f3ce19f5cf7e963bda5162ebae18b5d95..928604a80b4362947276dddab714583fa633ad41 100644
--- a/src/unitgrade/version.py
+++ b/src/unitgrade/version.py
@@ -1 +1 @@
-__version__ = "0.1.27"
\ No newline at end of file
+__version__ = "0.1.28.1"
\ No newline at end of file