From 046aa1f8fde8386e61f38e76fd12483d4e919eeb Mon Sep 17 00:00:00 2001
From: Gaetan Lepage <gaetan@glepage.com>
Date: Fri, 16 Dec 2022 10:50:26 +0100
Subject: [PATCH] waiting list

---
 backend/cluster.py          | 153 +++++++++++++++------
 public/cluster.js           | 267 ------------------------------------
 public/css/style.css        |   1 +
 public/css/waiting_list.css |  57 ++++++++
 public/index.html           |   2 +-
 public/js/cluster.js        |  56 ++++++++
 public/js/gpu.js            | 187 +++++++++++++++++++++++++
 public/js/main.js           |  38 +++++
 public/js/node.js           |  39 ++++++
 public/{ => js}/utils.js    |  29 +++-
 public/js/waiting_list.js   | 108 +++++++++++++++
 public/main.js              |  30 ----
 12 files changed, 621 insertions(+), 346 deletions(-)
 delete mode 100644 public/cluster.js
 create mode 100644 public/css/waiting_list.css
 create mode 100644 public/js/cluster.js
 create mode 100644 public/js/gpu.js
 create mode 100644 public/js/main.js
 create mode 100644 public/js/node.js
 rename public/{ => js}/utils.js (63%)
 create mode 100644 public/js/waiting_list.js
 delete mode 100644 public/main.js

diff --git a/backend/cluster.py b/backend/cluster.py
index c3338eb..cee075e 100644
--- a/backend/cluster.py
+++ b/backend/cluster.py
@@ -3,7 +3,7 @@ import subprocess
 from datetime import datetime, timedelta
 import logging
 import re
-from typing import Optional
+from typing import Optional, Any
 from matplotlib import cm  # type: ignore
 import numpy as np
 
@@ -17,6 +17,7 @@ PRETTY_GPU_MODELS: dict[str, str] = {
     'rtx8000': 'Quadro RTX 8000'
 }
 
+TIME_FORMAT: str = '%m/%d %H:%M:%S'
 
 USERNAMES: list[str] = [
     'aballou',
@@ -31,7 +32,7 @@ USERNAMES: list[str] = [
     'dmeng',
     'wguo',
     'xbie',
-    'adupless',
+    'UNKNOWN',
     'bbasavas',
 ]
 
@@ -52,6 +53,10 @@ USER_COLORS: dict[str, str] = {
     )
 }
 
+# keys: job_id
+# value: dict containing 1 job information
+JobDict = dict[str, dict[str, Any]]
+
 
 def _run_remote_command_and_fetch_dict(update_cmd: list[str]) -> dict:
 
@@ -80,8 +85,10 @@ def _run_remote_command_and_fetch_dict(update_cmd: list[str]) -> dict:
 
             return json.loads(stdout)
 
-        raise subprocess.CalledProcessError(returncode=return_code,
-                                            cmd=update_cmd)
+        raise subprocess.CalledProcessError(
+            returncode=return_code,
+            cmd=update_cmd
+        )
 
     except subprocess.TimeoutExpired as timeout_exception:
         LOGGER.warning(f"`{update_cmd[2]}` command has timed out.")
@@ -147,32 +154,39 @@ def _fetch_and_parse_oarnodes_dict() -> tuple[dict, list[str]]:
     return nodes_dict, jobs_list
 
 
-def _fetch_and_parse_oarstat_dict(jobs_list: list[str]) -> dict:
+############################################
+# OARSTAT (fetch running and waiting jobs) #
+############################################
 
-    oarstat_raw_dict: dict = _run_remote_command_and_fetch_dict(
-        update_cmd=['oarstat', '-J', '--sql', '"state = \'Running\'"'])
+def _extract_walltime(message: str) -> str:
+    match: Optional[re.Match] = re.compile(r"(?<=W=).+?(?=,)").search(message)
+
+    walltime: str = ''
+    if match:
+        walltime = ':'.join(
+            [
+                sub.zfill(2)
+                for sub in match.group().split(':')
+            ]
+        )
 
-    oarstat_filtered_dict: dict = {}
+    return walltime
 
-    time_format: str = '%m/%d %H:%M:%S'
 
-    def _extract_walltime(message: str) -> str:
-        match: Optional[re.Match] = re.compile(r"(?<=W=).+?(?=,)").search(message)
+def _date_time_from_timestamp(timestamp: str) -> str:
+    date_time: datetime = datetime.fromtimestamp(int(timestamp))
 
-        assert match is not None
-        walltime = ':'.join([sub.zfill(2)
-                             for sub in match.group().split(':')])
-        return walltime
+    return date_time.strftime(TIME_FORMAT)
 
-    def _date_time_from_timestamp(timestamp: str) -> str:
-        date_time: datetime = datetime.fromtimestamp(int(timestamp))
 
-        return date_time.strftime(time_format)
+def _get_max_time(job_dict: dict) -> str:
+    start_time: datetime = datetime.fromtimestamp(job_dict['startTime'])
 
-    def _get_max_time(job_dict: dict) -> str:
-        start_time: datetime = datetime.fromtimestamp(job_dict['startTime'])
+    walltime_str: str = _extract_walltime(job_dict['message'])
 
-        hours, mins, secs = _extract_walltime(job_dict['message']).split(':')
+    max_time_str: str = ''
+    if walltime_str:
+        hours, mins, secs = walltime_str.split(':')
         walltime: timedelta = timedelta(
             hours=int(hours),
             minutes=int(mins),
@@ -180,34 +194,72 @@ def _fetch_and_parse_oarstat_dict(jobs_list: list[str]) -> dict:
         )
 
         max_time: datetime = start_time + walltime
+        max_time_str = max_time.strftime(TIME_FORMAT)
+
+    return max_time_str
+
+
+def _get_requested_gpu(properties: str) -> str:
+
+    requested_gpu: str = ''
+    match: Optional[re.Match] = re.compile(r"gpu[1-8]").search(properties)
+
+    if match:
+        requested_gpu = match.group()
 
-        return max_time.strftime(time_format)
+    return requested_gpu
 
-    for job_id in jobs_list:
-        job_dict: dict = oarstat_raw_dict[job_id]
 
-        owner: str = job_dict['owner']
+def _fetch_and_parse_oarstat_dict(
+    jobs_running_on_perception_cluster: list[str]
+) -> tuple[JobDict, JobDict]:
 
-        oarstat_filtered_dict[job_id] = {
-            'id': int(job_dict['Job_Id']),
-            'name': job_dict['name'],
+    oarstat_raw_dict: dict = _run_remote_command_and_fetch_dict(
+        update_cmd=['oarstat', '-J']
+    )
+
+    running_jobs_dict: JobDict = {}
+    waiting_jobs_dict: JobDict = {}
+
+    for job_id, job_raw_dict in oarstat_raw_dict.items():
+        # job_raw_dict: dict = oarstat_raw_dict[job_id]
+        if 'perception' not in job_raw_dict['properties']:
+            continue
+
+        owner: str = job_raw_dict['owner']
+
+        job_curated_dict: dict[str, Any] = {
+            'id': int(job_raw_dict['Job_Id']),
+            'name': job_raw_dict['name'],
             'owner': owner,
-            'type': job_dict['jobType'].lower(),
-            'besteffort': 'besteffort' in job_dict['types'],
-            'idempotent': 'idempotent' in job_dict['types'],
-            'submission_time': _date_time_from_timestamp(job_dict['submissionTime']),
-            'start_time': _date_time_from_timestamp(job_dict['startTime']),
-            'walltime': _extract_walltime(job_dict['message']),
-            'max_time': _get_max_time(job_dict)
+            'type': job_raw_dict['jobType'].lower(),
+            'besteffort': 'besteffort' in job_raw_dict['types'],
+            'idempotent': 'idempotent' in job_raw_dict['types'],
+            'submission_time': _date_time_from_timestamp(job_raw_dict['submissionTime']),
+            'start_time': _date_time_from_timestamp(job_raw_dict['startTime']),
+            'walltime': _extract_walltime(job_raw_dict['message']),
+            'max_time': _get_max_time(job_raw_dict),
+            'requested_gpu': _get_requested_gpu(job_raw_dict['properties'])
         }
+        state: str = job_raw_dict['state']
 
         if owner in USER_COLORS:
-            oarstat_filtered_dict[job_id]['owner_color'] = USER_COLORS[owner]
+            job_curated_dict['owner_color'] = USER_COLORS[owner]
+        else:
+            job_curated_dict['owner_color'] = USER_COLORS['UNKNOWN']
+
+        if state == 'Running' and job_id in jobs_running_on_perception_cluster:
+            running_jobs_dict[job_id] = job_curated_dict
+        elif state == 'Waiting':
+            waiting_jobs_dict[job_id] = job_curated_dict
 
-    return oarstat_filtered_dict
+    return running_jobs_dict, waiting_jobs_dict
 
 
-def _merge(nodes_dict: dict, jobs_dict: dict) -> dict:
+def _merge_running_jobs(
+    nodes_dict: dict,
+    jobs_dict: JobDict
+) -> dict[int, dict]:
 
     output_dict: dict = {}
 
@@ -226,13 +278,26 @@ def _merge(nodes_dict: dict, jobs_dict: dict) -> dict:
 
 def update() -> dict:
 
-    nodes_dict, jobs_list = _fetch_and_parse_oarnodes_dict()
-
-    jobs_dict: dict = _fetch_and_parse_oarstat_dict(jobs_list=jobs_list)
+    raw_nodes_dict: dict
+    jobs_running_on_perception_cluster: list[str]
+    raw_nodes_dict, jobs_running_on_perception_cluster = _fetch_and_parse_oarnodes_dict()
+
+    running_jobs_dict: JobDict
+    waiting_jobs_dict: JobDict
+    (
+        running_jobs_dict,
+        waiting_jobs_dict
+    ) = _fetch_and_parse_oarstat_dict(
+        jobs_running_on_perception_cluster=jobs_running_on_perception_cluster
+    )
 
-    cluster_dict: dict = _merge(
-        nodes_dict=nodes_dict,
-        jobs_dict=jobs_dict
+    nodes_dict: dict = _merge_running_jobs(
+        nodes_dict=raw_nodes_dict,
+        jobs_dict=running_jobs_dict
     )
+    cluster_dict: dict[str, dict] = {
+        'nodes': nodes_dict,
+        'waiting_jobs': waiting_jobs_dict
+    }
 
     return cluster_dict
diff --git a/public/cluster.js b/public/cluster.js
deleted file mode 100644
index 906eb03..0000000
--- a/public/cluster.js
+++ /dev/null
@@ -1,267 +0,0 @@
-import * as utils from "./utils.js";
-
-class BoolField extends utils.Field {
-    constructor(name, class_name) {
-        super(name, 'x', true, class_name);
-        this.enabled_char = '✅';
-        this.disabled_char = '❌';
-    }
-
-    update(enabled) {
-        if ( enabled ) {
-            this.set_text(this.enabled_char);
-        }
-        else {
-            this.set_text(this.disabled_char);
-        }
-    }
-}
-
-class Gpu {
-    constructor(id, cluster_container) {
-
-        this.cluster_container = cluster_container;
-
-        this.div = document.createElement('div');
-        this.div.className = 'gpu';
-
-        // Name
-        this.name_div = document.createElement('div');
-        this.name_div.className = 'info-gpu container centered';
-        this.name_field = new utils.Field(`GPU ${id}`, 'model');
-        this.name_div.appendChild(this.name_field.node);
-
-        this.div.appendChild(this.name_div);
-
-        // Job
-        this.job_div = document.createElement('div');
-        this.job_div.className = 'job container';
-
-        // If the GPU is free.
-        this.free_sub_div = document.createElement('div');
-        this.free_sub_div.className = 'free container';
-        let free_par = document.createElement('p');
-        free_par.innerHTML = '<strong>FREE</strong>';
-        this.free_sub_div.appendChild(free_par);
-        this.free_div = document.createElement('div');
-        this.free_div.className = 'job container';
-        this.free_div.appendChild(this.free_sub_div);
-
-        this.div.appendChild(this.free_div);
-
-        // Job ID and owner
-        this.job_info_div = document.createElement('div');
-        this.job_info_div.className = 'job-info container';
-
-        // Job ID
-        this.job_id_field = new utils.Field('ID',
-                                            'job_id',
-                                            true,
-                                            'job-id');
-        this.job_info_div.appendChild(this.job_id_field.node);
-
-        // Job owner
-        this.job_owner_field = new utils.Field('owner',
-                                               'job_owner',
-                                               true,
-                                               'job-owner');
-        this.job_info_div.appendChild(this.job_owner_field.node);
-
-        // Job type
-        this.job_type_field = new utils.Field('type',
-                                              'job_type',
-                                              true,
-                                              'job-type');
-        this.job_info_div.appendChild(this.job_type_field.node);
-
-        this.job_div.appendChild(this.job_info_div);
-
-        // Job name
-        this.job_name_div = document.createElement('div');
-        this.job_name_field = new utils.Field('name',
-                                              'job_name',
-                                              true,
-                                              'job-name');
-        this.job_name_div.appendChild(this.job_name_field.node);
-        this.job_div.appendChild(this.job_name_div);
-
-        // Job properties
-        this.properties_div = document.createElement('div');
-        this.properties_div.className = 'booleans container';
-
-        // besteffort
-        this.besteffort_field = new BoolField('besteffort',
-                                              'besteffort');
-        this.properties_div.appendChild(this.besteffort_field.node);
-
-        // idempotent
-        this.idempotent_field = new BoolField('idempotent',
-                                              'idempotent');
-        this.properties_div.appendChild(this.idempotent_field.node);
-
-        this.job_div.appendChild(this.properties_div);
-
-        // Job times (submission, start, walltime...)
-        this.times_div = document.createElement('div');
-        this.times_div.className = 'times container';
-
-        // Start and submit times
-        this.submit_start_time_div = document.createElement('div');
-        this.submit_start_time_div.className = 'two-times container';
-
-        // Submit time
-        this.submit_time_field = new utils.Field('submitted',
-                                                 '00/00 00:00:00',
-                                                 true,
-                                                 'submit-time');
-        this.submit_start_time_div.appendChild(this.submit_time_field.node);
-        // Start time
-        this.start_time_field = new utils.Field('started',
-                                                '00/00 00:00:00',
-                                                true,
-                                                'start-time');
-        this.submit_start_time_div.appendChild(this.start_time_field.node);
-        this.times_div.appendChild(this.submit_start_time_div);
-
-        // Walltime and max time
-        this.wall_max_finished_time_div = document.createElement('div');
-        this.wall_max_finished_time_div.className = 'two-times container';
-
-        // Walltime
-        this.wall_time_field = new utils.Field('wall-time',
-                                               '00:00:00',
-                                               true,
-                                               'wall-time');
-        this.wall_max_finished_time_div.appendChild(this.wall_time_field.node);
-
-        // Max tilme
-        this.max_finish_time_field = new utils.Field('ends before',
-                                                     '00/00 00:00:00',
-                                                     true,
-                                                     'max-finished-time');
-        this.wall_max_finished_time_div.appendChild(this.max_finish_time_field.node);
-        this.times_div.appendChild(this.wall_max_finished_time_div);
-        this.job_div.appendChild(this.times_div);
-
-        this.cluster_container.appendChild(this.div);
-    }
-
-    update_free() {
-        if (this.div.childNodes[1] === this.job_div)
-            this.div.replaceChild(this.free_div, this.job_div);
-        this.div.style.backgroundColor = getComputedStyle(
-            document.documentElement).getPropertyValue('--primary');
-    }
-
-    update_busy(dict) {
-        if (this.div.childNodes[1] === this.free_div)
-            this.div.replaceChild(this.job_div, this.free_div);
-
-        this.job_id_field.set_text(dict['id']);
-
-        this.job_owner_field.set_text(dict['owner']);
-        if ('owner_color' in dict) {
-            this.div.style.backgroundColor = dict['owner_color'];
-        }
-
-        let job_name = dict['name'] || '';
-        this.job_name_field.set_text(job_name.slice(0, 38));
-        this.job_type_field.set_text(dict['type']);
-        this.idempotent_field.update(dict['idempotent']);
-        this.besteffort_field.update(dict['besteffort']);
-        this.submit_time_field.set_text(dict['submission_time']);
-        this.start_time_field.set_text(dict['start_time']);
-        this.wall_time_field.set_text(dict['walltime']);
-        this.max_finish_time_field.set_text(dict['max_time']);
-    }
-
-    update_from_dict(gpu_model, dict) {
-        this.name_field.set_text(gpu_model);
-
-        if (dict == 'free')
-            this.update_free();
-        else {
-            this.update_busy(dict);
-        }
-    }
-}
-
-class Node {
-    constructor(id, num_gpus, root_node) {
-        this.root_container = root_node;
-
-        this.id = id;
-        this.container = document.createElement('div');
-        this.container.id = `gpu${id}`;
-        this.container.className = 'cluster container';
-        this.root_container.appendChild(this.container);
-
-        this.div_name = document.createElement('div');
-        this.div_name.className = 'specs container centered';
-        this.container.appendChild(this.div_name);
-        this.par_name = document.createElement('p');
-        this.par_name.className = 'cluster-name';
-        this.par_name.innerHTML = `gpu${id}-perception`;
-        this.div_name.append(this.par_name);
-
-        this.num_gpus = num_gpus;
-        this.gpus = [];
-        for (let gpu_id = 0; gpu_id < this.num_gpus; gpu_id++ ) {
-            this.gpus.push(new Gpu(gpu_id, this.container));
-        }
-    }
-
-    update(dict) {
-
-        this.gpu_model = dict['gpu_model'];
-
-        for (let gpu_id = 0; gpu_id < this.num_gpus; gpu_id++ ) {
-            this.gpus[gpu_id].update_from_dict(
-                this.gpu_model,
-                dict['gpus'][gpu_id]
-            );
-        }
-    }
-}
-
-export class Cluster {
-    constructor() {
-        this.nodes = {};
-
-        this.main_container = document.createElement('div');
-        this.main_container.className = 'main container';
-
-        // Add cluster nodes 1 to 7 (they each have 2 GPUs)
-        for (let node_id = 1; node_id < 8; node_id++ ) {
-            this.nodes[node_id] = new Node(node_id, 2, this.main_container);
-        }
-
-        // Invisible node fills the gap at the right of 'gpu7', as longer 'gpu8' is put below
-        let invisible_node = document.createElement('div');
-        invisible_node.className = 'cluster container';
-        invisible_node.id = 'invisible-cluster';
-        this.main_container.appendChild(invisible_node);
-
-        // Add cluster node 8 (which has 4 GPUs)
-        this.nodes[8] = new Node(8, 4, this.main_container);
-
-        let last_container = document.createElement('div');
-        last_container.className = 'cluster container';
-        last_container.id = 'last-cluster';
-        this.main_container.appendChild(last_container);
-    }
-
-    update(dict) {
-        for (let node_id in dict) {
-            this.nodes[node_id].update(dict[node_id]);
-        }
-    }
-
-    show() {
-        var root = document.getElementById('home');
-        root.appendChild(this.main_container);
-    }
-
-    hide() {
-    }
-}
diff --git a/public/css/style.css b/public/css/style.css
index 877932f..8585bda 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -1,5 +1,6 @@
 @import url(header.css);
 @import url(clusters.css);
+@import url(waiting_list.css);
 
 @media (min-width: 1200px) {
     body {
diff --git a/public/css/waiting_list.css b/public/css/waiting_list.css
new file mode 100644
index 0000000..6ab276a
--- /dev/null
+++ b/public/css/waiting_list.css
@@ -0,0 +1,57 @@
+#waiting-list {
+    background-color: var(--bleu-moche);
+    margin: 2vw 2vw 0.5vw 2vw;
+    padding: 0.5vw 0.5vw 0.5vw 0.5vw;
+    border-radius: 10px;
+    justify-content: space-between;
+    flex-wrap: wrap;
+}
+
+#waiting-list-title {
+    font-family: 'Montserrat', sans-serif;
+    font-weight: bold;
+    font-size: 1.5vw;
+    margin-left: 0.5vw;
+}
+
+#waiting-list-table {
+    padding: 0.5vw 0.5vw 0.5vw 0.5vw;
+    border-radius: 10px;
+    /* background-color: red; */
+}
+
+.waiting-list-header {
+    float: left;
+    clear: both;
+    font-weight: bold;
+    /* color: var(--orange-moche); */
+    background-color: var(--primary);
+}
+
+.waiting-list-row {
+    padding: 0.2vw 1vw 0.2vw 1vw;
+    float: left;
+    clear: both;
+    border-radius: 10px;
+    margin-top: 0.2vw;
+}
+
+.waiting-row-element {
+    float: left;
+    width: 6vw;
+}
+
+.waiting-job-id {
+}
+
+.waiting-job-owner {
+}
+
+.waiting-job-walltime {
+}
+
+.waiting-job-besteffort {
+}
+
+.waiting-job-gpu {
+}
diff --git a/public/index.html b/public/index.html
index 39459b2..bcf3319 100644
--- a/public/index.html
+++ b/public/index.html
@@ -38,7 +38,7 @@
             <!--     </div> -->
             <!-- </div> -->
         </header>
-        <script type="module" src="./main.js"></script>
+        <script type="module" src="./js/main.js"></script>
     </div>
     <div id="source-code">
         <a href="https://gitlab.inria.fr/robotlearn/cluster-monitor"><p>View source code</p></a>
diff --git a/public/js/cluster.js b/public/js/cluster.js
new file mode 100644
index 0000000..369a7e0
--- /dev/null
+++ b/public/js/cluster.js
@@ -0,0 +1,56 @@
+import * as utils from "./utils.js";
+import { WaitingList } from "./waiting_list.js";
+import { Node } from "./node.js";
+
+
+export class Cluster {
+    constructor() {
+
+        // Nodes ----------------------------------------------------------
+        this.nodes = {};
+
+        this.main_container = document.createElement('div');
+        this.main_container.className = 'main container';
+
+        // Add cluster nodes 1 to 7 (they each have 2 GPUs)
+        for (let node_id = 1; node_id < 8; node_id++ ) {
+            this.nodes[node_id] = new Node(node_id, 2, this.main_container);
+        }
+
+        // Invisible node fills the gap at the right of 'gpu7', as longer 'gpu8' is put below
+        let invisible_node = document.createElement('div');
+        invisible_node.className = 'cluster container';
+        invisible_node.id = 'invisible-cluster';
+        this.main_container.appendChild(invisible_node);
+
+        // Add cluster node 8 (which has 4 GPUs)
+        this.nodes[8] = new Node(8, 4, this.main_container);
+
+        let last_container = document.createElement('div');
+        last_container.className = 'cluster container';
+        last_container.id = 'last-cluster';
+        this.main_container.appendChild(last_container);
+
+        // Waiting list ---------------------------------------------------
+        this.waiting_list = new WaitingList();
+        this.main_container.appendChild(this.waiting_list.main_div);
+    }
+
+    update(dict) {
+        let nodes_dict = dict['nodes'];
+        for (let node_id in nodes_dict) {
+            this.nodes[node_id].update(nodes_dict[node_id]);
+        }
+
+        let waiting_list_dict = dict['waiting_jobs'];
+        this.waiting_list.update(waiting_list_dict);
+    }
+
+    show() {
+        var root = document.getElementById('home');
+        root.appendChild(this.main_container);
+    }
+
+    hide() {
+    }
+}
diff --git a/public/js/gpu.js b/public/js/gpu.js
new file mode 100644
index 0000000..4f6a67d
--- /dev/null
+++ b/public/js/gpu.js
@@ -0,0 +1,187 @@
+import { Field, BoolField } from "./utils.js";
+
+export class Gpu {
+    constructor(id, cluster_container) {
+
+        this.cluster_container = cluster_container;
+
+        this.div = document.createElement('div');
+        this.div.className = 'gpu';
+
+        // Name
+        this.name_div = document.createElement('div');
+        this.name_div.className = 'info-gpu container centered';
+        this.name_field = new Field(`GPU ${id}`, 'model');
+        this.name_div.appendChild(this.name_field.node);
+
+        this.div.appendChild(this.name_div);
+
+        // Job
+        this.job_div = document.createElement('div');
+        this.job_div.className = 'job container';
+
+        this.state = 'FREE';
+
+        // If the GPU is free or suspected.
+        this.state_sub_div = document.createElement('div');
+        this.state_sub_div.className = 'free container';
+        let state_par = document.createElement('p');
+        state_par.innerHTML = '<strong>FREE</strong>';
+        this.state_sub_div.appendChild(state_par);
+        this.state_div = document.createElement('div');
+        this.state_div.className = 'job container';
+        this.state_div.appendChild(this.state_sub_div);
+
+        this.div.appendChild(this.state_div);
+
+        // Job ID and owner
+        this.job_info_div = document.createElement('div');
+        this.job_info_div.className = 'job-info container';
+
+        // Job ID
+        this.job_id_field = new Field(
+            'ID',
+            'job_id',
+            true,
+            'job-id'
+        );
+        this.job_info_div.appendChild(this.job_id_field.node);
+
+        // Job owner
+        this.job_owner_field = new Field(
+            'owner',
+            'job_owner',
+            true,
+            'job-owner'
+        );
+        this.job_info_div.appendChild(this.job_owner_field.node);
+
+        // Job type
+        this.job_type_field = new Field(
+            'type',
+            'job_type',
+            true,
+            'job-type'
+        );
+        this.job_info_div.appendChild(this.job_type_field.node);
+
+        this.job_div.appendChild(this.job_info_div);
+
+        // Job name
+        this.job_name_div = document.createElement('div');
+        this.job_name_field = new Field(
+            'name',
+            'job_name',
+            true,
+            'job-name'
+        );
+        this.job_name_div.appendChild(this.job_name_field.node);
+        this.job_div.appendChild(this.job_name_div);
+
+        // Job properties
+        this.properties_div = document.createElement('div');
+        this.properties_div.className = 'booleans container';
+
+        // besteffort
+        this.besteffort_field = new BoolField('besteffort', 'besteffort');
+        this.properties_div.appendChild(this.besteffort_field.node);
+
+        // idempotent
+        this.idempotent_field = new BoolField('idempotent', 'idempotent');
+        this.properties_div.appendChild(this.idempotent_field.node);
+
+        this.job_div.appendChild(this.properties_div);
+
+        // Job times (submission, start, walltime...)
+        this.times_div = document.createElement('div');
+        this.times_div.className = 'times container';
+
+        // Start and submit times
+        this.submit_start_time_div = document.createElement('div');
+        this.submit_start_time_div.className = 'two-times container';
+
+        // Submit time
+        this.submit_time_field = new Field(
+            'submitted',
+            '00/00 00:00:00',
+            true,
+            'submit-time'
+        );
+        this.submit_start_time_div.appendChild(this.submit_time_field.node);
+        // Start time
+        this.start_time_field = new Field(
+            'started',
+            '00/00 00:00:00',
+            true,
+            'start-time'
+        );
+        this.submit_start_time_div.appendChild(this.start_time_field.node);
+        this.times_div.appendChild(this.submit_start_time_div);
+
+        // Walltime and max time
+        this.wall_max_finished_time_div = document.createElement('div');
+        this.wall_max_finished_time_div.className = 'two-times container';
+
+        // Walltime
+        this.wall_time_field = new Field(
+            'wall-time',
+            '00:00:00',
+            true,
+            'wall-time'
+        );
+        this.wall_max_finished_time_div.appendChild(this.wall_time_field.node);
+
+        // Max time
+        this.max_finish_time_field = new Field(
+            'ends before',
+            '00/00 00:00:00',
+            true,
+            'max-finished-time'
+        );
+        this.wall_max_finished_time_div.appendChild(this.max_finish_time_field.node);
+        this.times_div.appendChild(this.wall_max_finished_time_div);
+        this.job_div.appendChild(this.times_div);
+
+        this.cluster_container.appendChild(this.div);
+    }
+
+    update_free() {
+        if (this.div.childNodes[1] === this.job_div)
+            this.div.replaceChild(this.state_div, this.job_div);
+        this.div.style.backgroundColor = getComputedStyle(
+            document.documentElement
+        ).getPropertyValue('--primary');
+    }
+
+    update_busy(dict) {
+        if (this.div.childNodes[1] === this.state_div)
+            this.div.replaceChild(this.job_div, this.state_div);
+
+        this.job_id_field.set_text(dict['id']);
+
+        this.job_owner_field.set_text(dict['owner']);
+        if ('owner_color' in dict) {
+            this.div.style.backgroundColor = dict['owner_color'];
+        }
+
+        let job_name = dict['name'] || '';
+        this.job_name_field.set_text(job_name.slice(0, 38));
+        this.job_type_field.set_text(dict['type']);
+        this.idempotent_field.update(dict['idempotent']);
+        this.besteffort_field.update(dict['besteffort']);
+        this.submit_time_field.set_text(dict['submission_time']);
+        this.start_time_field.set_text(dict['start_time']);
+        this.wall_time_field.set_text(dict['walltime']);
+        this.max_finish_time_field.set_text(dict['max_time']);
+    }
+
+    update_from_dict(gpu_model, dict) {
+        this.name_field.set_text(gpu_model);
+
+        if (dict == 'free')
+            this.update_free();
+        else {
+            this.update_busy(dict);
+        }
+    }
+}
diff --git a/public/js/main.js b/public/js/main.js
new file mode 100644
index 0000000..a7397cb
--- /dev/null
+++ b/public/js/main.js
@@ -0,0 +1,38 @@
+import { Field } from "./utils.js";
+import { Cluster } from "./cluster.js";
+
+var last_updated = new Field('Last update', '', false);
+last_updated.get_node_from_id('last-updated');
+
+var cluster = new Cluster();
+
+// Show the default page
+cluster.show();
+
+// console.log("Connecting to socket")
+// var socket = io('https://ws.robotlearn.inrialpes.fr');
+var socket = io('0.0.0.0:8888');
+// console.log("Succesfully connected to socket server.")
+
+function update_timestamp() {
+    let currentDate = new Date();
+    let current_time = currentDate.getHours() + ':';
+    current_time += String(
+        currentDate.getMinutes()
+    ).padStart(2, '0') + ":";
+    current_time += String(
+        currentDate.getSeconds()
+    ).padStart(2, '0');
+    last_updated.set_text(current_time);
+}
+
+socket.on(
+    'update',
+    function(new_dict) {
+        // Timestamp
+        update_timestamp();
+
+        // Update data
+        cluster.update(new_dict);
+    }
+);
diff --git a/public/js/node.js b/public/js/node.js
new file mode 100644
index 0000000..be1f7a9
--- /dev/null
+++ b/public/js/node.js
@@ -0,0 +1,39 @@
+import { Gpu } from "./gpu.js";
+
+export class Node {
+    constructor(id, num_gpus, root_node) {
+        this.root_container = root_node;
+
+        this.id = id;
+        this.container = document.createElement('div');
+        this.container.id = `gpu${id}`;
+        this.container.className = 'cluster container';
+        this.root_container.appendChild(this.container);
+
+        this.div_name = document.createElement('div');
+        this.div_name.className = 'specs container centered';
+        this.container.appendChild(this.div_name);
+        this.par_name = document.createElement('p');
+        this.par_name.className = 'cluster-name';
+        this.par_name.innerHTML = `gpu${id}-perception`;
+        this.div_name.append(this.par_name);
+
+        this.num_gpus = num_gpus;
+        this.gpus = [];
+        for (let gpu_id = 0; gpu_id < this.num_gpus; gpu_id++ ) {
+            this.gpus.push(new Gpu(gpu_id, this.container));
+        }
+    }
+
+    update(dict) {
+
+        this.gpu_model = dict['gpu_model'];
+
+        for (let gpu_id = 0; gpu_id < this.num_gpus; gpu_id++ ) {
+            this.gpus[gpu_id].update_from_dict(
+                this.gpu_model,
+                dict['gpus'][gpu_id]
+            );
+        }
+    }
+}
diff --git a/public/utils.js b/public/js/utils.js
similarity index 63%
rename from public/utils.js
rename to public/js/utils.js
index 0eedff6..fb1e8dc 100644
--- a/public/utils.js
+++ b/public/js/utils.js
@@ -8,11 +8,14 @@ export class DivField {
     }
 }
 
+
 export class Field {
-    constructor(name = 'default',
-                text = 'default',
-                create_node = true,
-                css_class = undefined) {
+    constructor(
+        name = 'default',
+        text = 'default',
+        create_node = true,
+        css_class = undefined
+    ) {
 
         this.name = name;
         this.text = text;
@@ -39,3 +42,21 @@ export class Field {
         this.node = document.getElementById(id);
     }
 }
+
+
+export class BoolField extends Field {
+    constructor(name, class_name) {
+        super(name, 'x', true, class_name);
+        this.enabled_char = '✅';
+        this.disabled_char = '❌';
+    }
+
+    update(enabled) {
+        if ( enabled ) {
+            this.set_text(this.enabled_char);
+        }
+        else {
+            this.set_text(this.disabled_char);
+        }
+    }
+}
diff --git a/public/js/waiting_list.js b/public/js/waiting_list.js
new file mode 100644
index 0000000..9ee4a20
--- /dev/null
+++ b/public/js/waiting_list.js
@@ -0,0 +1,108 @@
+export class WaitingList {
+    constructor() {
+        this.main_div = document.createElement('div');
+        this.main_div.id = 'waiting-list';
+
+        // Title
+        let title = document.createElement('h');
+        title.id = 'waiting-list-title';
+        title.innerHTML = 'Waiting list';
+        this.main_div.appendChild(title);
+
+        let header = document.createElement('div');
+        header.className = 'waiting-list-header waiting-list-row';
+        let header_job_id = document.createElement('div');
+        header_job_id.className = 'waiting-row-element waiting-job-id';
+        header_job_id.innerHTML = 'ID';
+        header.appendChild(header_job_id);
+
+        let header_job_owner = document.createElement('div');
+        header_job_owner.className = 'waiting-row-element waiting-job-owner';
+        header_job_owner.innerHTML = 'owner';
+        header.appendChild(header_job_owner);
+
+        let header_walltime = document.createElement('div');
+        header_walltime.className = 'waiting-row-element waiting-job-walltime';
+        header_walltime.innerHTML = 'walltime';
+        header.appendChild(header_walltime);
+
+        let header_besteffort = document.createElement('div');
+        header_besteffort.className = 'waiting-row-element waiting-job-besteffort';
+        header_besteffort.innerHTML = 'besteffort';
+        header.appendChild(header_besteffort);
+
+        let header_gpu = document.createElement('div');
+        header_gpu.className = 'waiting-row-element waiting-job-gpu';
+        header_gpu.innerHTML = 'requested gpu';
+        header.appendChild(header_gpu);
+
+        let table = document.createElement('div');
+        table.id = 'waiting-list-table';
+        table.appendChild(header);
+        this.table_rows = document.createElement('div');
+        this.table_rows.id = 'waiting-list-table-rows';
+        table.appendChild(this.table_rows);
+
+        this.main_div.appendChild(table);
+    }
+
+    update(waiting_jobs_dict) {
+        let jobs = [];
+
+        for (var job_id in waiting_jobs_dict) {
+            // Check if job already exists in the
+            jobs.push(waiting_jobs_dict[job_id]);
+        }
+
+        // Sort by job ID
+        jobs.sort(
+            function(a, b) {
+                return a['id'] - b['id'];
+            }
+        );
+
+        this.table_rows.innerHTML = '';
+        for (let job_dict of jobs) {
+
+            // Create and fill row
+            let row = document.createElement('div');
+            row.className = 'waiting-list-row';
+
+            let job_id = document.createElement('div');
+            job_id.className = 'waiting-row-element waiting-job-id';
+            job_id.innerHTML = job_dict['id'];
+            row.appendChild(job_id);
+
+            let owner = document.createElement('div');
+            owner.className = 'waiting-row-element waiting-job-owner';
+            owner.innerHTML = job_dict['owner'];
+            row.appendChild(owner);
+
+            if ('owner_color' in job_dict) {
+                row.style.backgroundColor = job_dict['owner_color'];
+            }
+
+            let walltime = document.createElement('div');
+            walltime.className = 'waiting-row-element waiting-job-walltime';
+            walltime.innerHTML = job_dict['walltime'];
+            row.appendChild(walltime);
+
+            let best_effort = document.createElement('div');
+            best_effort.className = 'waiting-row-element waiting-job-besteffort';
+            if (job_dict['besteffort']) {
+                best_effort.innerHTML = '✅';
+            } else {
+                best_effort.innerHTML = '❌';
+            }
+            row.appendChild(best_effort);
+
+            let gpu = document.createElement('div');
+            gpu.className = 'waiting-row-element waiting-job-gpu';
+            gpu.innerHTML = job_dict['requested_gpu'];
+            row.appendChild(gpu);
+
+            this.table_rows.appendChild(row);
+        }
+
+    }
+}
diff --git a/public/main.js b/public/main.js
deleted file mode 100644
index 6139128..0000000
--- a/public/main.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import * as utils from "./utils.js";
-import { Cluster } from "./cluster.js";
-
-var last_updated = new utils.Field('Last update', '', false);
-last_updated.get_node_from_id('last-updated');
-
-var cluster = new Cluster();
-
-// Show the default page
-cluster.show();
-
-// console.log("Connecting to socket")
-var socket = io('https://ws.robotlearn.inrialpes.fr');
-// console.log("Succesfully connected to socket server.")
-
-function update_timestamp() {
-    let currentDate = new Date();
-    let current_time = currentDate.getHours() + ':';
-    current_time += String(currentDate.getMinutes()).padStart(2, '0') + ":";
-    current_time += String(currentDate.getSeconds()).padStart(2, '0');
-    last_updated.set_text(current_time);
-}
-
-socket.on('update', function(new_dict) {
-    // Timestamp
-    update_timestamp();
-
-    // Update data
-    cluster.update(new_dict);
-});
-- 
GitLab