summaryrefslogtreecommitdiff
path: root/ofborg/ofborg-viewer/src
diff options
context:
space:
mode:
authorMehmet Samet Duman <yongdohyun@projecttick.org>2026-04-04 23:51:10 +0300
committerMehmet Samet Duman <yongdohyun@projecttick.org>2026-04-04 23:51:10 +0300
commit1dec73cfa93cb2f93eab3d02c105201674128137 (patch)
treebd959443511976ffbe1e80641405f163af385761 /ofborg/ofborg-viewer/src
parent33a52fa710287f634fc2f5b5208eb9ea8423c4c6 (diff)
downloadProject-Tick-1dec73cfa93cb2f93eab3d02c105201674128137.tar.gz
Project-Tick-1dec73cfa93cb2f93eab3d02c105201674128137.zip
NOISSUE add ofborg-viewer (tickborg-viewer) in tickborg
Signed-off-by: Mehmet Samet Duman <yongdohyun@projecttick.org>
Diffstat (limited to 'ofborg/ofborg-viewer/src')
-rw-r--r--ofborg/ofborg-viewer/src/app.html22
-rw-r--r--ofborg/ofborg-viewer/src/app.js201
-rw-r--r--ofborg/ofborg-viewer/src/backlog.js8
-rw-r--r--ofborg/ofborg-viewer/src/config.js9
-rw-r--r--ofborg/ofborg-viewer/src/gui/index.js158
-rw-r--r--ofborg/ofborg-viewer/src/gui/log.js162
-rw-r--r--ofborg/ofborg-viewer/src/index.js40
-rw-r--r--ofborg/ofborg-viewer/src/lib/bsod.js16
-rw-r--r--ofborg/ofborg-viewer/src/lib/html.js11
-rw-r--r--ofborg/ofborg-viewer/src/lib/ready.js12
-rw-r--r--ofborg/ofborg-viewer/src/listener.js90
-rw-r--r--ofborg/ofborg-viewer/src/mixins/eventable.js48
-rw-r--r--ofborg/ofborg-viewer/src/state.js59
-rw-r--r--ofborg/ofborg-viewer/src/styles/index.less233
14 files changed, 1069 insertions, 0 deletions
diff --git a/ofborg/ofborg-viewer/src/app.html b/ofborg/ofborg-viewer/src/app.html
new file mode 100644
index 0000000000..9b4eaf73eb
--- /dev/null
+++ b/ofborg/ofborg-viewer/src/app.html
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta name="theme-color" content="#AFFFFF">
+ <meta name="viewport" content="width=devide-width, initial-scale=1" />
+ <meta charset="utf-8">
+ <title>TickBorg Log Viewer</title>
+ <style>
+<%= require("./styles/index.less") %>
+ </style>
+ </head>
+ <body>
+ <div id="tickborg-logviewer">
+ <div class="app">
+ <div class="loading">
+ <strong>Loading...</strong>
+ <em>you may need to enable some JavaScript for this to work.</em>
+ </div>
+ </div>
+ </div>
+ </body>
+</html>
diff --git a/ofborg/ofborg-viewer/src/app.js b/ofborg/ofborg-viewer/src/app.js
new file mode 100644
index 0000000000..cdfc3b4e0d
--- /dev/null
+++ b/ofborg/ofborg-viewer/src/app.js
@@ -0,0 +1,201 @@
+import ready from "./lib/ready";
+import Listener from "./listener";
+import Gui from "./gui";
+import Backlog from "./backlog";
+import State from "./state";
+import {WELL_KNOWN} from "./config";
+
+const MAN = `
+tickborg-viewer(web) tickborg web interface tickborg-viewer(web)
+
+NAME
+ tickborg-viewer — Build logs web interface
+
+DESCRIPTION
+ tickborg-viewer is a web interface that aggregates the currently in-progress
+ build logs made by tickborg.
+
+ New logs for the given build will be added to the discovered logs at the
+ top of the interface. Clicking them or activating them through the keyboard
+ shortcuts of your browser will show them.
+
+ The log will autoscroll when the logger interface is scrolled at the
+ bottom. Scrolling up will stop the autoscroll until scrolled back down.
+
+`;
+
+/**
+ * The logger app.
+ */
+class App {
+ constructor() {
+ // Only "boot" the app when the DOM is ready.
+ ready(() => this.boot());
+
+ // To use as event listener targets.
+ this.handle_select = this.handle_select.bind(this);
+ }
+
+ /**
+ * Hooks and starts the app.
+ *
+ * This means:
+ * * Starts the GUI.
+ * * Reads parameters.
+ * * Starts the Listener.
+ */
+ boot() {
+ window.document.title = "Log viewer starting...";
+ this.gui = new Gui();
+
+ this.gui.addEventListener("select", this.handle_select);
+
+ this.log("$ tickborg-viewer --version", null, {tag: "tickborg"});
+ this.log(`tickborg-viewer, version ${VERSION}${GIT_REVISION && ` (${GIT_REVISION})`}`, null, {tag: "tickborg"});
+ this.log("$ man tickborg-viewer", null, {tag: "tickborg"});
+ this.log(MAN, null, {tag: "man"});
+
+ this.log("→ logger starting", null, {tag: "tickborg"});
+ window.document.title = "Log viewer started...";
+
+ this.state = new State();
+ this.state.on_state_change = (s) => this.handle_state_change(s);
+ this.handle_state_change(this.state.params);
+ }
+
+ handle_select(selected) {
+ this.state.set_state({attempt_id: selected["name"]});
+ }
+
+ handle_state_change(new_state) {
+ const {attempt_id, key} = new_state;
+ const {logs} = this.gui;
+
+ // Loading parameters
+ if (!key) {
+ this.log("!! No key parameter... stopping now.", "tickborg");
+ return;
+ }
+
+ // This will allow some app parts to log more information.
+ if (new_state["debug"]) {
+ window.DEBUG = true;
+ }
+
+ window.document.title = `Log viewer [${key}]`;
+
+ // This is set only once in the lifetime of the app and is expected
+ // never to change.
+ // FIXME : Allow key to change live.
+ if (!this.key) {
+ this.key = key;
+
+ // Pings the logger API for existing logs.
+ // Those logs can be either live or complete.
+ this.load_logs(() => {
+ // Selects the log if loaded async.
+ if (logs[attempt_id]) {
+ logs[attempt_id].select();
+ }
+ });
+ }
+
+ // Attempts to select the log.
+ if (logs[attempt_id]) {
+ logs[attempt_id].select();
+ }
+
+ if (!this.listener) {
+ // Starts the listener.
+ this.listener = new Listener({
+ key: new_state["key"],
+ logger: (msg, tag) => this.log(msg, null, {tag}),
+ fn: (...msg) => this.from_listener(...msg),
+ });
+ }
+ }
+
+ load_logs(callback) {
+ this.log(`→ fetching existing attempts for ${this.key}`, null, {tag: "tickborg"});
+ return fetch(`${WELL_KNOWN}/${this.key}`, {mode: "cors"})
+ .then((response) => response.json())
+ .then(({attempts}) => Object.keys(attempts).forEach((attempt_id) => {
+ this.log(`→ fetching log for ${attempt_id}`, null, {tag: "tickborg"});
+ const attempt = attempts[attempt_id];
+ const log = this.gui.addLog(attempt_id, attempt["metadata"]);
+ const {log_url} = attempt;
+ // Loads backlog only when needed.
+ const handler = () => {
+ log.backlog_loading();
+ fetch(log_url, {mode: "cors"})
+ .then((response) => response.text())
+ .then((txt) => {
+ const lines = txt.split("\n");
+ log.backlog(lines, log_url);
+ this.log(`→ added log for ${attempt_id}`, null, {tag: "tickborg"});
+ })
+ ;
+ // Removes self from events.
+ log.removeEventListener("select", handler);
+ };
+ log.addEventListener("select", handler);
+ }))
+ .then(() => callback())
+ ;
+ }
+
+ from_listener(message, routing_key) {
+ const {output, attempt_id, line_number} = message;
+
+ // Probably a build-start message.
+ if (!output && output !== "") {
+ this.gui.addLog(attempt_id, message);
+ return;
+ }
+
+ // Opening a new log?
+ // It should already have been created, but just in case.
+ if (Object.keys(this.gui.logs).indexOf(attempt_id) === -1) {
+ const log = this.gui.addLog(attempt_id);
+
+ // Assumes if there was no log open for attempt, it needs to fetch backlog.
+ if (line_number > 1) {
+ // FIXME : Loop backlog fetching until all lines are found up to line_number.
+ log.backlog_loading();
+ const log_url = Backlog.get_url(routing_key, attempt_id);
+
+ return fetch(log_url, {mode: "cors"})
+ .then((response) => response.text())
+ .then((txt) => {
+ const lines = txt.split("\n").slice(0, line_number - 1);
+ log.backlog(lines, log_url);
+ })
+ .catch((err) => {
+ log.backlog_error(err);
+ })
+ ;
+ }
+ }
+
+ return this.log(output, attempt_id, {
+ tag: "stdout",
+ title: `#${line_number}`,
+ });
+ }
+
+ /**
+ * Logs to the console.
+ *
+ * This can receive a class for some more styling.
+ */
+ log(msg, log, {tag, title} = {}) {
+ this.gui.log({
+ msg,
+ log,
+ tag,
+ title,
+ });
+ }
+}
+
+export default App;
diff --git a/ofborg/ofborg-viewer/src/backlog.js b/ofborg/ofborg-viewer/src/backlog.js
new file mode 100644
index 0000000000..bb99917196
--- /dev/null
+++ b/ofborg/ofborg-viewer/src/backlog.js
@@ -0,0 +1,8 @@
+import {WELL_KNOWN} from "./config";
+class Backlog {
+ static get_url(routing, id) {
+ return `${WELL_KNOWN}/${routing}/${id}`;
+ }
+}
+
+export default Backlog;
diff --git a/ofborg/ofborg-viewer/src/config.js b/ofborg/ofborg-viewer/src/config.js
new file mode 100644
index 0000000000..827b06c231
--- /dev/null
+++ b/ofborg/ofborg-viewer/src/config.js
@@ -0,0 +1,9 @@
+const infra = "projecttick.net";
+
+const WELL_KNOWN = `https://logs.tickborg.${infra}/logs`;
+const SOCK = `wss://logs.tickborg.${infra}/ws/`;
+const SOCK_VHOST = `/`;
+const AUTH = "tickborg-logviewer";
+const MAX_LINES = 25000;
+
+export {SOCK, SOCK_VHOST, AUTH, MAX_LINES, WELL_KNOWN};
diff --git a/ofborg/ofborg-viewer/src/gui/index.js b/ofborg/ofborg-viewer/src/gui/index.js
new file mode 100644
index 0000000000..d63b17d695
--- /dev/null
+++ b/ofborg/ofborg-viewer/src/gui/index.js
@@ -0,0 +1,158 @@
+import bsod from "../lib/bsod";
+import html from "../lib/html";
+import Log from "./log";
+import eventable from "../mixins/eventable";
+
+/**
+ * Name of the "internal" log, both for system messages
+ * and as a fallback logging mechanism.
+ */
+const INTERNAL_LOG = "-tickborg-";
+
+/**
+ * The "Gui" for the app.
+ *
+ * This handles the tab-like controls to switch the shown log.
+ * This handles keeping track of whether it should follow scroll or not.
+ *
+ * The whole body is scrolled, always.
+ */
+class Gui {
+ constructor() {
+ eventable(this);
+ console.log("Creating log interface...."); // eslint-disable-line
+ this.setFollowing(true);
+
+ // To use as event listener targets.
+ this.handle_select = this.handle_select.bind(this);
+ this.maybe_scroll = this.maybe_scroll.bind(this);
+
+ // Registry of Log instances.
+ // ({`attempt_id`: instance})
+ this.logs = {};
+
+ this.$app = window.document.querySelectorAll("#tickborg-logviewer .app")[0];
+ if (!this.$app) {
+ return bsod("Couldn't hook app.");
+ }
+
+ // Empties app...
+ this.$app.innerHTML = "";
+
+ this.$header = html(`<header></header>`)[0];
+ this.$nav = html(`<ul></ul>`)[0];
+ this.$header.appendChild(this.$nav);
+ this.$app.appendChild(this.$header);
+
+ // Logs DOM instance holder.
+ this.$logs = html(`<div class="logs"></div>`)[0];
+ this.$app.appendChild(this.$logs);
+
+ this.addLog(INTERNAL_LOG);
+
+ // Hooks on scroll
+ window.addEventListener("scroll", () => this.watchScroll());
+ console.log("... log interface created."); // eslint-disable-line
+ }
+
+ addLog(name, metadata) {
+ const log = new Log(name, metadata);
+ this.logs[name] = log;
+ this.$logs.appendChild(log.$node);
+
+ this.$nav.appendChild(log.$tab);
+
+ if (Object.keys(this.logs).length === 1) {
+ log.select();
+ }
+
+ log.addEventListener("select", this.handle_select);
+ log.addEventListener("backlog", this.maybe_scroll)
+
+ return log;
+ }
+
+ handle_select(selected) {
+ this.maybe_scroll();
+ // Uses map as a cheap foreach.
+ Object.values(this.logs).map((l) => {
+ if (selected !== l) {
+ l.unselect();
+ }
+
+ return null;
+ });
+
+ this.sendEvent("select", selected);
+ }
+
+ setFollowing(following) {
+ if (following !== this.following) {
+ this.following = following;
+
+ const body = window.document.body;
+ if (following) {
+ body.classList.add("following");
+ body.classList.remove("not-following");
+ }
+ else {
+ body.classList.add("not-following");
+ body.classList.remove("following");
+ }
+ }
+ }
+
+ /**
+ * Marks the window as auto-scrollable or not when scrolling.
+ */
+ watchScroll() {
+ const body = window.document.documentElement;
+ const scroll_bottom = Math.round(body.scrollTop) + Math.round(window.innerHeight);
+ const total_height = body.scrollHeight;
+
+ // Some fudging around because of higher and fractional DPI issues.
+ // On 1.5 DPI chrome, it is possible to get the scroll to bottom
+ // not matching with the total height *some* times.
+ this.setFollowing(Math.abs(total_height - scroll_bottom) < 5);
+ }
+
+ /**
+ * Logs the message `msg`, tagged with tag `tag` in log instance `log`.
+ */
+ log({msg, tag, log, title}) {
+ // `null` logs to internal log.
+ const used_log = log || INTERNAL_LOG;
+ // Can't find a log?
+ if (!this.logs[used_log]) {
+ // Warn in the console
+ console.warn(`Couldn't find log "${log}"...`); // eslint-disable-line
+
+ // Makes sure we aren't missing the system log...
+ if (used_log === INTERNAL_LOG) {
+ bsod(`Log "${INTERNAL_LOG}" log. This shouldn't have happened.`);
+ }
+
+ // Log instead to the internal log.
+ this.log({
+ msg,
+ tag,
+ log: INTERNAL_LOG,
+ title,
+ });
+
+ return;
+ }
+ this.logs[used_log].log(msg, tag, {title});
+ this.maybe_scroll();
+ }
+
+ // Scroll as needed.
+ maybe_scroll() {
+ const body = window.document.documentElement;
+ if (this.following) {
+ body.scrollTop = body.scrollHeight;
+ }
+ }
+}
+
+export default Gui;
diff --git a/ofborg/ofborg-viewer/src/gui/log.js b/ofborg/ofborg-viewer/src/gui/log.js
new file mode 100644
index 0000000000..b354c66b5f
--- /dev/null
+++ b/ofborg/ofborg-viewer/src/gui/log.js
@@ -0,0 +1,162 @@
+import html from "../lib/html";
+import eventable from "../mixins/eventable";
+import {MAX_LINES} from "../config";
+
+const SEP = " ╱ ";
+
+/**
+ * The line-oriented GUI for the application.
+ *
+ * It presents a (1) node that should be added by the owner to the DOM.
+ */
+class Log {
+ constructor(name, metadata = null, {label = null} = {}) {
+ eventable(this);
+ this.name = name;
+ this.$node = html(`<div class="logger"></div>`)[0];
+ this.$node.classList.add(`name__${name.replace(/[^a-zA-Z0-9]/g, "_")}`);
+
+ let label_text = label;
+
+ // Makes a default label...
+ if (!label_text) {
+ label_text = name;
+
+ // For UUID-like labels
+ // The chances of hitting both conditions on a custom-made string is
+ // quite low.
+ if (label_text.length === 36 && label_text.split("-").length === 5) {
+ label_text = name
+ .split("-")
+ .splice(0, 2)
+ .join("-");
+ }
+ }
+
+ this.$backlog = html(`<div class="backlog logger-log"></div>`)[0];
+ this.$log = html(`<div class="newlog logger-log"></div>`)[0];
+ // Empties app...
+ this.$node.innerHTML = "";
+
+ if (metadata) {
+ this.$identity = html(`<div class="identity"></div>`)[0];
+ this.$node.appendChild(this.$identity);
+ }
+
+ // Appends the "app parts"
+ this.$node.appendChild(this.$backlog);
+ this.$node.appendChild(this.$log);
+
+ // The tab used for navigation.
+ this.$tab = html(`<li><label><input type="radio" name="selected_tab"><span></span></label></li>`)[0];
+ const radio = this.$tab.querySelectorAll("input")[0];
+ const $label = this.$tab.querySelectorAll("label > span")[0];
+ $label.innerText = label_text;
+ radio.value = name;
+ radio.onclick = () => {
+ this.select();
+ };
+
+ if (metadata) {
+ const {attempt_id, identity, system} = metadata;
+ const txt = [];
+ txt.push(`id: ${identity}`);
+ txt.push(`system: ${system}`);
+ this.$identity.innerText = " " + txt.join(SEP) + SEP;
+ this.$identity.title = JSON.stringify(metadata, null, " ");
+ $label.title = txt.concat([`attempt_id: ${attempt_id}`]).join(SEP);
+ }
+
+ radio.onfocus = () => {
+ this.$tab.classList.add("__focus");
+ };
+ radio.onblur = () => {
+ this.$tab.classList.remove("__focus");
+ };
+ }
+
+ /**
+ * Logs to the console.
+ *
+ * This can receive a class for some more styling.
+ */
+ log(text, tag, {title}) {
+ const el = html(`<div></div>`)[0];
+ if (tag) {
+ el.classList.add(tag);
+ }
+ if (title) {
+ el.title = title;
+ }
+ // The replace regex allows more intelligent splitting.
+ // It will prefer splitting words, this way.
+ // .replace(/([,-=/])/g, "\u200B$1\u200B");
+ // It breaks search, until a solution is found we'll manage with crappy
+ // line breaks.
+ el.innerText = text;
+ this.$log.appendChild(el);
+ }
+
+ select() {
+ this.$node.classList.add("selected");
+ this.$tab.classList.add("selected");
+ this.$tab.querySelectorAll("input")[0].checked = true;
+ this.sendEvent("select", this);
+ }
+
+ unselect() {
+ this.$node.classList.remove("selected");
+ this.$tab.classList.remove("selected");
+ this.sendEvent("unselect");
+ }
+
+ backlog(lines, log_url = null) {
+ this.$backlog.classList.remove("loading");
+ // Empties backlog...
+ const start = Math.max(lines.length - MAX_LINES, 0);
+ const length = Math.min(lines.length, MAX_LINES);
+ let line_no = start + 1;
+ const fragment = document.createDocumentFragment(length);
+ this.$backlog.innerText = `(Rendering backlog, ${length} lines long...)**`;
+ lines.slice(start, lines.length)
+ .forEach((text) => {
+ const el = document.createElement("div");
+ el.title = `#${line_no}`;
+ line_no += 1;
+ el.innerText = text;
+ fragment.appendChild(el);
+ });
+ const $link = html(`<a class="truncated">Log has been truncated... (${length} lines shown out of ${lines.length}.)</a>`)[0];
+ if (log_url) {
+ $link.href = log_url;
+ }
+ if (length < lines.length) {
+ this.$backlog.innerText = `(Rendering backlog, ${length} lines out of ${lines.length}...)`;
+ }
+ else {
+ this.$backlog.innerText = `(Rendering backlog, ${length} lines long...)`;
+ }
+
+ // Delays appendChild to allow reflow for previous message.
+ window.setTimeout(() => {
+ this.$backlog.innerText = "";
+ if (length < lines.length) {
+ this.$backlog.appendChild($link);
+ }
+ this.$backlog.appendChild(fragment);
+ this.sendEvent("backlog", this);
+ }, 10);
+ }
+
+ backlog_error(err) {
+ this.$backlog.classList.remove("loading");
+ this.$backlog.innerText = `An error happened fetching the backlog...\n${err}`;
+ }
+
+ backlog_loading() {
+ this.$backlog.classList.add("loading");
+ this.$backlog.innerText = `Fetching the backlog...`;
+ }
+}
+
+export default Log;
diff --git a/ofborg/ofborg-viewer/src/index.js b/ofborg/ofborg-viewer/src/index.js
new file mode 100644
index 0000000000..e2b8683f9e
--- /dev/null
+++ b/ofborg/ofborg-viewer/src/index.js
@@ -0,0 +1,40 @@
+import App from "./app";
+import WELL_KNOWN from "./config";
+
+/**
+ * Entry-point of the application.
+ *
+ * If any polyfilling is needed, do it here.
+ * Then, start the app.
+ */
+
+// Fetch compat.
+{
+ const FETCH_MISSING = "fetch is required for this app to work properly.";
+
+ /**
+ * Acts mostly like a promise.
+ */
+ const pseudo_promise = function() {
+ return {
+ then: () => pseudo_promise(),
+ catch: (fn) => fn(new Error(FETCH_MISSING)),
+ };
+ };
+
+ /**
+ * Replaces fetch when fetch is missing.
+ */
+ const pseudo_fetch = function() { // eslint-disable-line
+ return pseudo_promise();
+ };
+
+ // Ensures calls to `fetch` don't crash the app.
+ if (!window.fetch) {
+ console.warn(FETCH_MISSING); // eslint-disable-line
+ window.fetch = pseudo_fetch; // eslint-disable-line
+ }
+}
+
+// Starts the app.
+window.APP = new App();
diff --git a/ofborg/ofborg-viewer/src/lib/bsod.js b/ofborg/ofborg-viewer/src/lib/bsod.js
new file mode 100644
index 0000000000..6b29d41417
--- /dev/null
+++ b/ofborg/ofborg-viewer/src/lib/bsod.js
@@ -0,0 +1,16 @@
+/**
+ * Borg screen of death.
+ *
+ * Replaces the whole body with an error message.
+ */
+const bsod = function(msg = "Something happened.") {
+ const body = window.document.body;
+ body.innerText =
+`Hmmm, this is embarassing...
+
+-> ${msg}
+`;
+ body.className = "bsod";
+};
+
+export default bsod;
diff --git a/ofborg/ofborg-viewer/src/lib/html.js b/ofborg/ofborg-viewer/src/lib/html.js
new file mode 100644
index 0000000000..dcacecd423
--- /dev/null
+++ b/ofborg/ofborg-viewer/src/lib/html.js
@@ -0,0 +1,11 @@
+/**
+ * Uses the DOM to parse HTML.
+ */
+const html = function(str) {
+ const tmp = document.implementation.createHTMLDocument();
+ tmp.body.innerHTML = str;
+
+ return tmp.body.children;
+};
+
+export default html;
diff --git a/ofborg/ofborg-viewer/src/lib/ready.js b/ofborg/ofborg-viewer/src/lib/ready.js
new file mode 100644
index 0000000000..74f9358a8e
--- /dev/null
+++ b/ofborg/ofborg-viewer/src/lib/ready.js
@@ -0,0 +1,12 @@
+/**
+ * Triggers when the document is ready.
+ */
+const ready = function(fn) {
+ if (document.attachEvent ? document.readyState === "complete" : document.readyState !== "loading"){
+ fn();
+ } else {
+ document.addEventListener('DOMContentLoaded', fn);
+ }
+};
+
+export default ready;
diff --git a/ofborg/ofborg-viewer/src/listener.js b/ofborg/ofborg-viewer/src/listener.js
new file mode 100644
index 0000000000..f2060aa725
--- /dev/null
+++ b/ofborg/ofborg-viewer/src/listener.js
@@ -0,0 +1,90 @@
+import bsod from "./lib/bsod";
+import Stomp from "@stomp/stompjs";
+import {SOCK, AUTH, SOCK_VHOST} from "./config";
+
+/**
+ * Listener interface; subscribes to the queue and uses the given callback.
+ */
+class Listener {
+ constructor({key, logger, fn}) {
+ this.subscription = null;
+ this.key = key;
+ this.fn = fn;
+ this.logger = logger;
+ this.logger("Socket created...", "tickborg");
+ this.client = Stomp.client(SOCK);
+ this.client.debug = (str) => this.debug_callback(str);
+ this.connect();
+ }
+
+ /**
+ * Catches stomp.js debug log.
+ * window.DEBUG can be set (using param debug=true) to help debug issues.
+ */
+ debug_callback(str) {
+ if (window.DEBUG) {
+ /* eslint-disable no-control-regex */
+ const cleaned = str.replace(/[\x00\s]+$/g, "").trim();
+ /* eslint-enable */
+ this.logger(cleaned, "stomp");
+ }
+ }
+
+ connect() {
+ this.client.connect(
+ AUTH, AUTH,
+ () => this.connected(),
+ (err) => this.handle_failure(err),
+ SOCK_VHOST
+ );
+ }
+
+ disconnect() {
+ this.logger("Disconnecting...", "tickborg");
+ this.client.disconnect(
+ () => this.logger("Disconnected.", "tickborg")
+ );
+ }
+
+ connected() {
+ this.succesfully_connected = true;
+ this.logger("Connected...", "tickborg");
+ this.logger(`Subscribing to "${this.key}"...`, "tickborg");
+ this.subscription = this.client.subscribe(
+ `/exchange/logs/${encodeURIComponent(this.key)}`,
+ (m) => this.handle_message(JSON.parse(m.body), m)
+ );
+ }
+
+ handle_failure(err) {
+ console.error("STOMP error...");
+ console.error(err);
+ if (this.succesfully_connected) {
+ this.logger("Uhhh, we lost the websocket connection... refresh to fix this issue.", "stderr")
+ }
+ else {
+ bsod("Couldn't connect to websocket.\n\nMake sure content blockers (noscript, µblock) are not blocking websockets.")
+ }
+ }
+
+ /**
+ * Handler for messages.
+ */
+ handle_message(msg, raw) {
+ // Get the routing key, which will be used to fetch the backlogs.
+ const destination = raw.headers["destination"].split("/");
+ const routing = decodeURIComponent(destination[destination.length - 1]);
+ this.receive(msg, routing);
+ }
+
+ /**
+ * Conditionally calls the callback registered.
+ */
+ receive(...args) {
+ if (this.fn) {
+ return this.fn(...args);
+ }
+ }
+}
+
+export default Listener;
diff --git a/ofborg/ofborg-viewer/src/mixins/eventable.js b/ofborg/ofborg-viewer/src/mixins/eventable.js
new file mode 100644
index 0000000000..a33e8edafd
--- /dev/null
+++ b/ofborg/ofborg-viewer/src/mixins/eventable.js
@@ -0,0 +1,48 @@
+import each from "lodash/each";
+import pull from "lodash/pull";
+
+/**
+ * Adds functions looking like the EventTarget ones on `this`.
+ *
+ * This is NOT compatible, as they are not using Event.
+ * They cannot preventDefault.
+ * They cannot stop propagation.
+ * There is no propagation.
+ */
+const eventable = (self) => {
+ each(
+ // Functions to mix in.
+ {
+ addEventListener(type, listener) {
+ if (!this[`_${type}_listeners`]) {
+ this[`_${type}_listeners`] = [];
+ }
+ const table = this[`_${type}_listeners`];
+
+ table.push(listener);
+ },
+ removeEventListener(type, listener) {
+ if (!this[`_${type}_listeners`]) {
+ this[`_${type}_listeners`] = {};
+ }
+ const table = this[`_${type}_listeners`];
+
+ pull(table, listener);
+ },
+ sendEvent(type, ...params) {
+ if (!this[`_${type}_listeners`]) {
+ this[`_${type}_listeners`] = [];
+ }
+ const table = this[`_${type}_listeners`];
+
+ table.forEach((fn) => fn(...params));
+ }
+ },
+ // Mixing in all those.
+ (fn, name) => {
+ self[name] = fn.bind(self);
+ }
+ );
+};
+
+export default eventable;
diff --git a/ofborg/ofborg-viewer/src/state.js b/ofborg/ofborg-viewer/src/state.js
new file mode 100644
index 0000000000..936d07653a
--- /dev/null
+++ b/ofborg/ofborg-viewer/src/state.js
@@ -0,0 +1,59 @@
+import queryString from "query-string";
+import isEqual from "lodash/isEqual";
+
+/**
+ * Maps the state of the application to the history API.
+ * This is used to make the current view linkable.
+ * In the URL, the hash part is **reserved for line numbers**.
+ * (line number links are not implemented yet.)
+ */
+class State {
+
+ /**
+ * Loads the state from URL.
+ *
+ * Prepares event listeners.
+ */
+ constructor() {
+ const params = queryString.parse(location.search);
+ const {history} = window;
+ // Loads from params in URL, then history in order of importance.
+ this.params = Object.assign({}, params, history.state);
+
+ window.onpopstate = (e) => this.handle_popstate(e);
+ }
+
+ handle_popstate(e) {
+ console.log(e);
+ const {state} = e;
+ if (state) {
+ this.set_state(state, {push: false});
+ if (this.on_state_change) {
+ this.on_state_change(state);
+ }
+ }
+ }
+
+ set_state(new_state, {push} = {push: true}) {
+ const {history} = window;
+ const params = Object.assign({}, this.params, new_state);
+ Object.keys(params).forEach((k) => {
+ if (!params[k]) {
+ Reflect.deleteProperty(params, k);
+ }
+ });
+
+ if (isEqual(params, this.params)) {
+ // set_state won't fire on "identity" change.
+ return;
+ }
+
+ this.params = params;
+
+ if (push) {
+ history.pushState(this.params, "", `?${queryString.stringify(params)}`);
+ }
+ }
+}
+
+export default State;
diff --git a/ofborg/ofborg-viewer/src/styles/index.less b/ofborg/ofborg-viewer/src/styles/index.less
new file mode 100644
index 0000000000..6718eedefb
--- /dev/null
+++ b/ofborg/ofborg-viewer/src/styles/index.less
@@ -0,0 +1,233 @@
+@color_borg: #2D8F34;
+@color_debug: #8F2D87;
+@color_man: #424775;
+@color_man_bg: #EDF3FF;
+@color_header_bg: #eeeeee;
+@color_header_fg: #005FFF;
+@color_tab_bg: #AFFFFF;
+@color_tab_selected: #ffffff;
+@color_tab_focus: #DC00B8;
+
+@line_height: 1.3rem;
+@header_height: @line_height;
+
+#no-select {
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ cursor: default;
+}
+
+body {
+ color: #000000;
+ background-color: #ffffff;
+ margin: 0;
+ padding: 0;
+ line-height: @line_height;
+ font-family:
+ "Go Mono",
+ "DejaVu Sans Mono",
+ "Lucida Console",
+ monospace
+ ;
+}
+
+.bsod {
+ background: @color_borg;
+ color: #ffffff;
+ white-space: pre;
+}
+
+.loading {
+ strong {
+ display: block;
+ }
+ em {
+ color: #666;
+ }
+}
+
+.app {
+ margin: 0;
+}
+
+.app > header {
+ background: @color_header_bg;
+ color: @color_header_fg;
+ height: @header_height;
+ z-index: 10;
+ position: sticky;
+ top: 0;
+ left: 0;
+ right: 0;
+
+ // Temp fix for overflowing tabs.
+ overflow-x: hidden;
+ overflow-y: hidden;
+ white-space: nowrap;
+
+ ul {
+ // The JS app will need to add appropriate text-indent when showing a tab.
+ //text-indent: -100px;
+ // Text-indent shouldn't then apply to the included elements.
+ & > * {
+ text-indent: initial;
+ }
+ }
+
+ // .__active is handled by the JS app.
+ // This unwraps the tabs bar.
+ // The JS app will need to text-indent appropriately to make the tab shown when !&.__active
+ &:hover, &.__active {
+ height: auto;
+ white-space: normal;
+ ul {
+ text-indent: 0;
+ }
+ }
+
+ ul, ul > li {
+ margin: 0;
+ padding: 0;
+ list-style-type: none;
+ }
+
+ ul > li {
+ display: inline-block;
+ background-color: @color_tab_bg;
+ margin-right: 1em;
+ }
+ li > * {
+ position: relative;
+ #no-select();
+ cursor: pointer;
+
+ // Input is hidden, but through opacity and position.
+ // Otherwise, it won't be keyboard-interactable.
+ input {
+ opacity: 0;
+ height: 1px;
+ width: 1px;
+ position: absolute;
+ top: 0;
+ left: 0;
+ }
+ padding-left: 0.5em;
+ padding-right: 0.5em;
+ display: block;
+ color: inherit;
+ text-decoration: inherit;
+ }
+ li {
+ &:focus, &.__focus {
+ color: @color_tab_selected;
+ background-color: @color_tab_focus;
+ }
+ }
+
+ .selected {
+ color: @color_tab_selected;
+ background-color: @color_header_fg;
+ }
+}
+
+.app > .logs {
+ & > :not(.selected) {
+ display: none;
+ }
+}
+
+// A logger pane.
+.logger-log {
+ @left_border: 1em;
+
+ // Makes it mostly behave like pre.
+ // But with more wrapping.
+ white-space: pre-wrap;
+ word-wrap: break-word;
+
+ // All lines.
+ & > * {
+ border-bottom: 0.05em solid #eee;
+ padding-left: @left_border;
+ min-height: @line_height;
+ }
+
+ a {
+ color: @color_header_fg;
+ text-decoration: underline;
+ &:hover, &:focus, &:active {
+ background-color: @color_tab_bg;
+ }
+ &:visited {
+ color: @color_debug;
+ }
+ }
+
+ // Specially tagged messages.
+ .tickborg, .stomp, .man {
+ border-left: @left_border/2 solid transparent;
+ padding-left: @left_border/2;
+ }
+ .tickborg, .stomp {
+ color: #888;
+ }
+
+ // And their (tagged) colors.
+ .tickborg {
+ border-left-color: @color_borg;
+ }
+ .stomp {
+ border-left-color: @color_debug;
+ }
+ .stderr {
+ color: #BA3300;
+ }
+ .man {
+ border-left-color: @color_man;
+ background-color: @color_man_bg;
+ }
+
+ // The previous bits of logs.
+ &.backlog {
+ }
+
+ // The "live" part of the logs.
+ &.newlog {
+ // Adds sensible spacing at the bottom of the screen.
+ padding-bottom: 0.7em;
+ }
+
+ a.truncated {
+ display: block;
+ animation-name: flash;
+ animation-duration: 0.8s;
+ animation-fill-mode: both;
+ animation-timing-function: ease-in-out;
+ }
+}
+
+.logger .identity {
+ z-index: 1;
+ background: #FFFAAF;
+ color: #5270A3;
+ min-height: @line_height;
+ position: sticky;
+ top: @header_height;
+ left: 0;
+ right: 0;
+}
+
+@keyframes flash {
+ @low: 0.1;
+
+ 0% {opacity: @low;}
+ 16.6% {opacity: 1;}
+ 33.3% {opacity: @low;}
+ 50% {opacity: 1;}
+ 66.6% {opacity: @low;}
+ 100% {opacity: 1;}
+}