diff --git a/main.py b/main.py index 5c3ea7e..c9879d2 100644 --- a/main.py +++ b/main.py @@ -3,12 +3,12 @@ __version__ = "0.2.2" __author__ = "M. Heuwes " -import os import json import logging +import os from tornado.web import authenticated -from tornado import web, ioloop +from tornado import ioloop, web from nojava_ipmi_kvm.config import config, DEFAULT_CONFIG_FILEPATH from nojava_ipmi_kvm import utils @@ -26,6 +26,13 @@ config.read_config(CONFIG_PATH) +def server_labels(): + labels = {} + for server_id in config.get_servers(): + labels[server_id] = config[server_id].full_hostname + return labels + + class MainHandler(BaseHandler): @authenticated @authorized @@ -35,8 +42,10 @@ def get(self): title="Remote KVM", user=self.get_current_user(), servers=config.get_servers(), + server_labels=server_labels(), base_uri=WEBAPP_BASE, websocket_uri="ws" + WEBAPP_BASE[4:], + version=__version__, ) @authenticated @@ -47,18 +56,17 @@ def post(self): title="Remote KVM", user=self.get_current_user(), servers=config.get_servers(), + server_labels=server_labels(), base_uri=WEBAPP_BASE, websocket_uri="ws" + WEBAPP_BASE[4:], server_name=json.dumps(self.get_body_argument("server_name")), password=json.dumps(self.get_body_argument("password")), resolution=json.dumps(self.get_body_argument("resolution")), + version=__version__, ) def make_app(): - """ - returns a tornado.web.Application - """ settings = { "template_path": "templates", "static_path": "static", @@ -71,7 +79,7 @@ def make_app(): } return web.Application( [web.url(r"/oauth/login", OAuth2LoginHandler), web.url(r"/", MainHandler), web.url(r"/kvm", KVMHandler)], - **settings + **settings, ) diff --git a/static/app.core.css b/static/app.core.css new file mode 100644 index 0000000..90718b0 --- /dev/null +++ b/static/app.core.css @@ -0,0 +1,195 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + font-family: Helvetica, Arial, sans-serif; + color: #222222; + background: #ffffff; + line-height: 1.5; +} + +body.session-active { + overflow: hidden; +} + +body.session-active .app-shell { + display: none; +} + +.app-shell { + max-width: 1000px; + margin: 0 auto; + padding: 1.5rem 1.25rem; +} + +.app-header h1 { + margin: 0 0 0.25rem; + font-size: 1.25rem; +} + +.user-meta { + margin: 0 0 1rem; + color: #555555; +} + +.app-footer { + margin-top: 1.5rem; +} + +.status.is-busy { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.status-spinner { + width: 1rem; + height: 1rem; + border: 2px solid #104070; + border-right-color: transparent; + border-radius: 50%; + animation: kvm-spin 0.8s linear infinite; +} + +@keyframes kvm-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .status-spinner { + animation: none; + } +} + +.form-grid { + display: grid; + grid-template-columns: 7.5rem 1fr; + gap: 0.5rem 1rem; + align-items: center; +} + +.form-grid label { + font-size: 0.95rem; +} + +.form-grid input, +.form-grid select { + width: 100%; + max-width: 20em; + padding: 0.4rem 0.5rem; + border: 1px solid #cccccc; + font: inherit; +} + +.form-actions { + grid-column: 1 / -1; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.25rem; + padding: 0 1rem; + border-radius: 4px; + border: 1px solid transparent; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.btn-primary { + background: #1a5fb4; + color: #ffffff; +} + +.btn-secondary { + background: #ffffff; + color: #222222; + border-color: #cccccc; +} + +.status { + display: none; + margin-top: 0.75rem; + padding: 0.5rem 0.75rem; + border-radius: 4px; + font-size: 0.9rem; +} + +.status.is-visible { + display: block; +} + +.status.is-info, +.status.is-busy { + background: #e8f2ff; + color: #104070; +} + +.status.is-error { + background: #fde8ea; + color: #c01c28; +} + +.logs-panel { + margin-top: 1rem; +} + +.logs-panel.is-empty { + display: none; +} + +#logsul { + margin: 0; + padding: 0; + list-style: none; + font-family: ui-monospace, Menlo, Consolas, monospace; + font-size: 0.8rem; + color: #555555; +} + +#logsul li.error-log { + color: #c01c28; +} + +#container { + position: relative; + z-index: 100; +} + +.kvm-iframe { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + border: none; + margin: 0; + padding: 0; + z-index: 999999; + background: #000000; +} + +.hidden { + display: none !important; +} diff --git a/static/app.core.js b/static/app.core.js new file mode 100644 index 0000000..7c63cf2 --- /dev/null +++ b/static/app.core.js @@ -0,0 +1,438 @@ +(function () { + "use strict"; + + var config = window.KVM_UI_CONFIG; + if (!config) { + return; + } + + var pageTitle = document.title; + var ws = null; + var hostName = ""; + var isConnecting = false; + + window.KvmUi = { _connectTimer: -1 }; + + var els = { + card: document.getElementById("kvm-card"), + form: document.getElementById("kvm-form"), + server: document.getElementById("kvm-server"), + password: document.getElementById("kvm-password"), + resolution: document.getElementById("kvm-resolution"), + connectBtn: document.getElementById("connect-btn"), + newTabBtn: document.getElementById("new-tab-btn"), + status: document.getElementById("status"), + logsPanel: document.getElementById("logs"), + logsUl: document.getElementById("logsul"), + container: document.getElementById("container"), + }; + + function isServerSelectable() { + return els.server && els.server.tagName === "SELECT"; + } + + function restorePageTitle() { + document.title = pageTitle; + } + + function formatLogTimestamp() { + return new Date().toLocaleString("de-DE"); + } + + function replaceContainerChildren(container, child) { + if (!container) { + return; + } + if (typeof container.replaceChildren === "function") { + container.replaceChildren(child); + return; + } + while (container.firstChild) { + container.removeChild(container.firstChild); + } + container.appendChild(child); + } + + function setStatus(message, kind) { + if (!els.status) { + return; + } + var visible = Boolean(message) || kind === "busy"; + els.status.className = "status" + (visible ? " is-visible" : "") + (kind ? " is-" + kind : ""); + if (kind === "busy") { + els.status.innerHTML = + '' + + ''; + els.status.querySelector(".status-text").textContent = message; + return; + } + els.status.textContent = message || ""; + } + + function setFormDisabled(disabled) { + isConnecting = disabled; + if (els.card) { + els.card.classList.toggle("is-connecting", disabled); + els.card.setAttribute("aria-busy", disabled ? "true" : "false"); + } + ["connectBtn", "newTabBtn", "server", "password", "resolution"].forEach(function (key) { + if (els[key]) { + els[key].disabled = disabled; + } + }); + updateLogsVisibility(); + } + + function unlockForm(restoreTitle) { + setFormDisabled(false); + if (restoreTitle !== false) { + restorePageTitle(); + } + } + + function updateLogsVisibility() { + if (!els.logsPanel) { + return; + } + var hasEntries = els.logsUl && els.logsUl.children.length > 0; + els.logsPanel.classList.toggle("is-empty", !hasEntries && !isConnecting); + } + + function appendLog(message, isError) { + if (!els.logsUl) { + return; + } + var item = document.createElement("li"); + if (isError) { + item.className = "error-log"; + } + item.textContent = formatLogTimestamp() + ": " + message; + els.logsUl.appendChild(item); + els.logsUl.scrollTop = els.logsUl.scrollHeight; + updateLogsVisibility(); + } + + function clearLogs() { + if (!els.logsUl) { + return; + } + els.logsUl.innerHTML = ""; + updateLogsVisibility(); + } + + function stopConnectingAnimation() { + if (window.KvmUi._connectTimer !== -1) { + clearInterval(window.KvmUi._connectTimer); + window.KvmUi._connectTimer = -1; + } + } + + function startConnectingAnimation() { + stopConnectingAnimation(); + var text = "Connecting to " + hostName + "…"; + setStatus(text, "busy"); + document.title = text; + } + + function getServerCount() { + if (typeof config.serverCount === "number") { + return config.serverCount; + } + if (els.server && els.server.tagName === "SELECT") { + return els.server.options.length; + } + return 1; + } + + function readServerName() { + if (config.autoConnect) { + return config.autoServer || ""; + } + return els.server ? els.server.value.trim() : ""; + } + + function readPassword() { + if (config.autoConnect) { + return config.autoPassword || ""; + } + return els.password ? els.password.value : ""; + } + + function readResolution() { + if (config.autoConnect) { + return config.autoResolution || ""; + } + return els.resolution ? els.resolution.value : ""; + } + + function focusField(field) { + if (field && typeof field.focus === "function" && field.type !== "hidden") { + field.focus(); + } + } + + function applyInitialFocus() { + if (config.autoConnect || !els.password) { + return; + } + if (getServerCount() > 1) { + focusField(els.server); + return; + } + focusField(els.password); + } + + function focusAfterFailure() { + if (config.autoConnect) { + return; + } + focusField(els.password); + } + + function focusAfterNotice(message) { + if (/hostname is not valid/i.test(message) && isServerSelectable()) { + focusField(els.server); + return; + } + if (/already connected/i.test(message) || /no unused port/i.test(message)) { + return; + } + focusAfterFailure(); + } + + function getXsrfToken() { + var input = document.querySelector("input[name='_xsrf']"); + return input ? input.value : ""; + } + + function deleteAllCookies() { + var paths = ["/", "/oauth"]; + document.cookie.split(";").forEach(function (cookie) { + var eqPos = cookie.indexOf("="); + var name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim(); + if (!name) { + return; + } + paths.forEach(function (path) { + document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=" + path; + }); + document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT"; + }); + } + + function enterSession(url) { + document.body.classList.add("session-active"); + var iframe = document.createElement("iframe"); + iframe.className = "kvm-iframe"; + iframe.src = url; + iframe.title = "KVM console"; + iframe.textContent = "Your browser does not support iframes."; + replaceContainerChildren(els.container, iframe); + } + + function handleConnectFailure(statusMessage, focusFn, options) { + options = options || {}; + stopConnectingAnimation(); + unlockForm(); + setStatus(statusMessage, "error"); + document.title = options.title || statusMessage; + if (options.logMessage !== false) { + appendLog(options.logMessage || statusMessage, true); + } + if (focusFn) { + focusFn(); + } + } + + function connectWebSocket() { + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { + return ws; + } + ws = new WebSocket(config.websocketUri + "/kvm"); + ws.onopen = function () { + if (config.autoConnect) { + startKvm(); + } + }; + ws.onmessage = function (evt) { + var data; + try { + data = JSON.parse(evt.data); + } catch (e) { + return; + } + if (data.action === "notice") { + stopConnectingAnimation(); + unlockForm(); + setStatus(data.message, "info"); + appendLog(data.message, false); + focusAfterNotice(data.message); + if (data.refresh) { + window.setTimeout(function () { + window.location.href = "/"; + }, 1500); + } + return; + } + if (data.action === "connected") { + stopConnectingAnimation(); + setFormDisabled(false); + setStatus("", ""); + document.title = "Connected to " + hostName; + enterSession(data.url); + return; + } + if (data.action === "log" || data.action === "error") { + var isError = data.action === "error"; + appendLog(data.message, isError); + if (isError) { + handleConnectFailure("Failed to connect.", focusAfterFailure, { + title: "Failed to connect", + logMessage: false, + }); + } + } + }; + ws.onclose = function () { + if (!isConnecting) { + return; + } + handleConnectFailure("Connection to server lost.", focusAfterFailure); + }; + ws.onerror = function () { + if (!isConnecting) { + setStatus("WebSocket error.", "error"); + return; + } + handleConnectFailure("WebSocket error.", focusAfterFailure); + }; + return ws; + } + + function validateConnectForm() { + hostName = readServerName(); + if (!hostName) { + setStatus("Select a server.", "error"); + if (isServerSelectable()) { + focusField(els.server); + } + return false; + } + if (!readPassword()) { + setStatus("Enter the KVM password.", "error"); + focusField(els.password); + return false; + } + return true; + } + + function saveResolution() {} + + function startKvm() { + if (isConnecting) { + return; + } + if (!validateConnectForm()) { + return; + } + saveResolution(); + clearLogs(); + setFormDisabled(true); + startConnectingAnimation(); + var socket = connectWebSocket(); + var payload = JSON.stringify({ + action: "connect", + server: hostName, + password: readPassword(), + resolution: readResolution(), + }); + function send() { + socket.send(payload); + } + if (socket.readyState === WebSocket.OPEN) { + send(); + } else { + socket.addEventListener("open", send, { once: true }); + } + } + + function openInNewTab() { + if (!validateConnectForm()) { + return; + } + saveResolution(); + var form = document.createElement("form"); + form.method = "POST"; + form.action = "/"; + form.target = "_blank"; + [ + { name: "_xsrf", value: getXsrfToken() }, + { name: "server_name", value: hostName }, + { name: "password", value: readPassword() }, + { name: "resolution", value: readResolution() }, + ].forEach(function (field) { + var input = document.createElement("input"); + input.type = "hidden"; + input.name = field.name; + input.value = field.value; + form.appendChild(input); + }); + document.body.appendChild(form); + form.submit(); + form.remove(); + } + + window.KvmUi = Object.assign(window.KvmUi, { + config: config, + els: els, + getHostName: function () { + return hostName; + }, + setHostName: function (value) { + hostName = value; + }, + setStatus: setStatus, + stopConnectingAnimation: stopConnectingAnimation, + startConnectingAnimation: startConnectingAnimation, + saveResolution: saveResolution, + restoreResolution: function () {}, + applyInitialFocus: applyInitialFocus, + focusField: focusField, + isServerSelectable: isServerSelectable, + getServerCount: getServerCount, + readResolution: readResolution, + stopConnectingAnimation: stopConnectingAnimation, + startConnectingAnimation: startConnectingAnimation, + }); + + var logoutLink = document.getElementById("logout-link"); + if (logoutLink) { + logoutLink.addEventListener("click", deleteAllCookies); + } + if (els.card) { + els.card.setAttribute("aria-busy", "false"); + } + updateLogsVisibility(); + connectWebSocket(); + if (els.form) { + els.form.addEventListener("submit", function (evt) { + evt.preventDefault(); + startKvm(); + }); + } + if (els.newTabBtn) { + els.newTabBtn.addEventListener("click", openInNewTab); + } + if (isServerSelectable()) { + els.server.addEventListener("change", function () { + if (!config.autoConnect && els.password && !els.password.value) { + focusField(els.password); + } + }); + } + if (config.autoConnect) { + hostName = config.autoServer || ""; + } else { + applyInitialFocus(); + } +})(); diff --git a/templates/_resolution_select.tpl b/templates/_resolution_select.tpl new file mode 100644 index 0000000..cf04d7c --- /dev/null +++ b/templates/_resolution_select.tpl @@ -0,0 +1,6 @@ + diff --git a/templates/_server_field.tpl b/templates/_server_field.tpl new file mode 100644 index 0000000..94b67a2 --- /dev/null +++ b/templates/_server_field.tpl @@ -0,0 +1,19 @@ +{# Multi-host +{% end %} +{% else %} + + +{% end %} diff --git a/templates/_session_log.tpl b/templates/_session_log.tpl new file mode 100644 index 0000000..64d31d8 --- /dev/null +++ b/templates/_session_log.tpl @@ -0,0 +1,4 @@ +
+

Session log

+ +
diff --git a/templates/base.tpl b/templates/base.tpl new file mode 100644 index 0000000..863fb40 --- /dev/null +++ b/templates/base.tpl @@ -0,0 +1,40 @@ +{% import os %} +{% if version %}{% set ui_cache = version %}{% else %}{% set ui_cache = os.environ.get("UI_CACHE_VERSION", "1") %}{% end %} + + + + + + {{ title }} + + + +
+
+

{{ title }}

+ {% if user['name'] != 'anonymous' %} +

Hello {{ user['name'] }} ({{ user['email'] }})

+ {% end %} +
+ + {% block main %}{% end %} + + {% block footer %} + {% if 'OAUTH_HOST' in os.environ %} + + {% end %} + {% end %} +
+ +
+ + {% block after_container %}{% end %} + + + + + diff --git a/templates/index.tpl b/templates/index.tpl index 98f3b19..9a0543a 100644 --- a/templates/index.tpl +++ b/templates/index.tpl @@ -1,180 +1,37 @@ - - - {{ title }} - - - -
-

Hello {{ user['name'] }} ({{ user['email'] }})

- -
- {% module xsrf_form_html() %} - - - {% for server in servers %} - - {% end %} - - -
- - - -
- - - -
- - - - {% import os %} - {% if 'OAUTH_HOST' in os.environ %} - - {% end %} -
-
-
- -
- - - - +{% extends "base.tpl" %} + +{% block main %} +
+
+ {% module xsrf_form_html() %} + +
+ {% include "_server_field.tpl" %} + + + + + + {% include "_resolution_select.tpl" %} + +
+ + +
+
+
+ +
+ + {% set logs_empty = True %} + {% include "_session_log.tpl" %} +
+{% end %} + +{% block kvm_config %} +{ + websocketUri: "{{ websocket_uri }}", + autoConnect: false, + serverCount: {{ len(servers) }} +} +{% end %} diff --git a/templates/index_instant.tpl b/templates/index_instant.tpl index d37bc7f..2d6d608 100644 --- a/templates/index_instant.tpl +++ b/templates/index_instant.tpl @@ -1,10 +1,23 @@ -{% extends 'index.tpl' %} -{% block ws_onopen %} +{% extends "base.tpl" %} -document.getElementById('kvm-server').value = {% raw server_name %}; -document.getElementById('kvm-password').value = {% raw password %}; -document.getElementById('kvm-resolution').value = {% raw resolution %}; - -start_kvm(); +{% block main %} +
+
+ + Connecting… +
+ {% set logs_empty = False %} + {% include "_session_log.tpl" %} +
+{% end %} +{% block kvm_config %} +{ + websocketUri: "{{ websocket_uri }}", + autoConnect: true, + autoServer: {{ server_name }}, + autoPassword: {{ password }}, + autoResolution: {{ resolution }}, + serverCount: 1 +} {% end %}