diff options
| author | Mehmet Samet Duman <yongdohyun@projecttick.org> | 2026-04-04 23:51:10 +0300 |
|---|---|---|
| committer | Mehmet Samet Duman <yongdohyun@projecttick.org> | 2026-04-04 23:51:10 +0300 |
| commit | 1dec73cfa93cb2f93eab3d02c105201674128137 (patch) | |
| tree | bd959443511976ffbe1e80641405f163af385761 /ofborg/ofborg-viewer/src | |
| parent | 33a52fa710287f634fc2f5b5208eb9ea8423c4c6 (diff) | |
| download | Project-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.html | 22 | ||||
| -rw-r--r-- | ofborg/ofborg-viewer/src/app.js | 201 | ||||
| -rw-r--r-- | ofborg/ofborg-viewer/src/backlog.js | 8 | ||||
| -rw-r--r-- | ofborg/ofborg-viewer/src/config.js | 9 | ||||
| -rw-r--r-- | ofborg/ofborg-viewer/src/gui/index.js | 158 | ||||
| -rw-r--r-- | ofborg/ofborg-viewer/src/gui/log.js | 162 | ||||
| -rw-r--r-- | ofborg/ofborg-viewer/src/index.js | 40 | ||||
| -rw-r--r-- | ofborg/ofborg-viewer/src/lib/bsod.js | 16 | ||||
| -rw-r--r-- | ofborg/ofborg-viewer/src/lib/html.js | 11 | ||||
| -rw-r--r-- | ofborg/ofborg-viewer/src/lib/ready.js | 12 | ||||
| -rw-r--r-- | ofborg/ofborg-viewer/src/listener.js | 90 | ||||
| -rw-r--r-- | ofborg/ofborg-viewer/src/mixins/eventable.js | 48 | ||||
| -rw-r--r-- | ofborg/ofborg-viewer/src/state.js | 59 | ||||
| -rw-r--r-- | ofborg/ofborg-viewer/src/styles/index.less | 233 |
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;} +} |
