From b1c95b10f4d1a0d49c2b888dcf10872b4a474569 Mon Sep 17 00:00:00 2001
From: Malfurious <m@lfurio.us>
Date: Sat, 11 Nov 2023 21:50:47 -0500
Subject: dmt: Implement initial functionality for jobs page

Adds javascript to implement self-hydration for the main page.  Several
aspects of these features can be customized via new settings in
config.sh.

Signed-off-by: Malfurious <m@lfurio.us>
---
 dmt/config.sh      |  12 +++++
 dmt/dmt            |   4 ++
 dmt/html/jobs.html |   5 +-
 dmt/script.js      | 154 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 171 insertions(+), 4 deletions(-)
 create mode 100644 dmt/script.js

diff --git a/dmt/config.sh b/dmt/config.sh
index aa8a9c4..3df98e9 100644
--- a/dmt/config.sh
+++ b/dmt/config.sh
@@ -3,3 +3,15 @@ CYCHE_SITE_NAME="cychedelic"
 
 # Number of lines of text to show per job on the main page
 CYCHE_LOG_TAIL_LENGTH=25
+
+# Job content refresh time while system is idle
+CYCHE_IDLE_UPDATE=60000 # 1 minute
+
+# Job content refresh time while system is busy
+CYCHE_ACTIVE_UPDATE=1000 # 1 second
+
+# Progress indicator animation speed (time per update)
+CYCHE_PROGRESS_ANIM_SPEED=1000 # 1 second
+
+# Human-friendly filename template (mind the single-quotes!)
+CYCHE_LOG_FILENAME='${job.job}_${abv_hash}_${job.service}.log'
diff --git a/dmt/dmt b/dmt/dmt
index c23e844..2a12bff 100755
--- a/dmt/dmt
+++ b/dmt/dmt
@@ -109,4 +109,8 @@ case ${route[0]} in
     "style.css")
         template "style.css" text/css
         ;;
+
+    "script.js")
+        template "script.js" text/javascript
+        ;;
 esac
diff --git a/dmt/html/jobs.html b/dmt/html/jobs.html
index 72269f6..6ad138a 100644
--- a/dmt/html/jobs.html
+++ b/dmt/html/jobs.html
@@ -20,7 +20,4 @@
     </div>
 </div>
 
-<div id="latest_jobs"></div>
-
-<h1>Older Jobs</h1>
-<div id="older_jobs"></div>
+<div class="hidden" id="list"></div>
diff --git a/dmt/script.js b/dmt/script.js
new file mode 100644
index 0000000..66e2d31
--- /dev/null
+++ b/dmt/script.js
@@ -0,0 +1,154 @@
+const ICON_VIEW  = "&#x2b07;";
+const ICON_MAINT = "&#x21bb;";
+const ICON_EVENT = "&#x03df;";
+const ICON_PASS  = "&#x2714;";
+const ICON_FAIL  = "&#x2716;";
+
+let version = null;
+let latest_job = -1;
+
+async function api(x) {
+    let r = await fetch(x);
+    return r.json();
+}
+
+function icon_progress() {
+    // Use random braille glyphs to create a moving progress effect.
+    let i = Math.floor(Math.random() * (0x28ff - 0x2801)) + 0x2801;
+    return `&#${i};`;
+}
+
+function set_progress(element, x) {
+    if (x !== null || element.className == "") {
+        element.className = (x === null ? "progress" : "");
+        element.innerHTML = (x === null ? icon_progress() : x);
+    }
+}
+
+function set_time(element) {
+    let timestamp = element.dataset.timestamp;
+    let seconds = Math.floor((Date.now() / 1000) - timestamp);
+    let minutes = Math.floor(seconds / 60);
+    let hours = Math.floor(minutes / 60);
+    let days = Math.floor(hours / 24);
+
+    if (seconds < 120) {
+        element.innerText = `${seconds} seconds ago`;
+    } else if (minutes < 120) {
+        element.innerText = `${minutes} minutes ago`;
+    } else if (hours < 48) {
+        element.innerText = `${hours} hours ago`;
+    } else {
+        element.innerText = `${days} days ago`;
+    }
+}
+
+function touch_job(list, job) {
+    // New job, new <div>
+    if (document.getElementById(`${job.job}`) === null) {
+        let abv_hash = job.hash.substring(0, 16);
+        let reason = (job.reason == "event" ? ICON_EVENT : ICON_MAINT);
+
+        let element = document.createElement("div");
+        list.insertBefore(element, list.childNodes[0]);
+
+        element.innerHTML = `
+        <div class="box" id="${job.job}">
+            <div class="box-title">
+                <a href="/api/log/${job.job}/%($CYCHE_LOG_FILENAME%)">
+                    <span class="button">${ICON_VIEW}</span>
+                </a>
+                #${job.job}
+                ${job.service}
+                <small><i>${abv_hash}</i></small>
+
+                <span class="right">
+                    <span class="time" id="${job.job}_time"
+                            data-timestamp="${job.time}"></span>
+                    <span id="${job.job}_result"></span>
+                    ${reason}
+                </span>
+            </div>
+
+            <div class="box-text">
+                <pre id="${job.job}_log"></pre>
+            </div>
+        </div>`;
+
+        let time = document.getElementById(`${job.job}_time`);
+        set_time(time);
+    }
+
+    // Update existing
+    let box = document.getElementById(`${job.job}`);
+    let result = document.getElementById(`${job.job}_result`);
+    let log = document.getElementById(`${job.job}_log`);
+
+    let cn = (job.reason == "remove" ? "remove" : job.result);
+    box.className = `box ${cn}`;
+
+    switch (job.result) {
+        case "active": set_progress(result, null);      break;
+        case "fail":   set_progress(result, ICON_FAIL); break;
+        case "pass":   set_progress(result, ICON_PASS); break;
+    }
+
+    log.innerText = job.log;
+}
+
+async function update_jobs() {
+    let nav_progress = document.getElementById("nav_progress");
+    let no_server = document.getElementById("no_server");
+    let no_jobs = document.getElementById("no_jobs");
+    let list = document.getElementById("list");
+
+    let timeout = %($CYCHE_IDLE_UPDATE%);
+
+    try {
+        let status = await api("/api/status");
+
+        if (version === null) { version = status.version; }
+        if (version != status.version) { location.reload(); }
+        if (status.active) { timeout = %($CYCHE_ACTIVE_UPDATE%); }
+        set_progress(nav_progress, (status.active ? null : ""));
+
+        for (let i = status.oldest; i <= status.newest; i++) {
+            if (i >= latest_job) {
+                let job = await api(`/api/job/${i}`);
+                touch_job(list, job);
+                latest_job = i;
+            }
+        }
+
+        if (list.childElementCount > 0) {
+            list.classList.remove("hidden");
+            no_jobs.classList.add("hidden");
+        }
+
+        no_server.classList.add("hidden");
+    } catch (ex) {
+        no_server.classList.remove("hidden");
+        console.log(ex);
+    }
+
+    setTimeout(update_jobs, timeout);
+}
+
+function update_times() {
+    let elements = document.getElementsByClassName("time");
+    for (let e of elements) {
+        set_time(e);
+    }
+}
+
+function update_progress() {
+    let elements = document.getElementsByClassName("progress");
+    for (let e of elements) {
+        e.innerHTML = icon_progress();
+    }
+}
+
+update_jobs();
+update_progress();
+setInterval(update_times, 1000);
+setInterval(update_progress, %($CYCHE_PROGRESS_ANIM_SPEED%));
-- 
cgit v1.2.3