From: Benjamin Braatz Date: Mon, 22 Mar 2021 21:10:53 +0000 (+0100) Subject: Allow web apps in package directories. X-Git-Tag: v0.3.0~30 X-Git-Url: http://git.graph-it.com/?a=commitdiff_plain;h=49b4ea216ac1d2adf5e121c3069e4749ef18ed4b;p=graphit%2Fcontrolpi-wsserver.git Allow web apps in package directories. --- diff --git a/.gitignore b/.gitignore index aa9a585..af06ced 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__/ +dist/ controlpi_wsserver.egg-info/ venv/ diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..6f2f309 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +recursive-include doc/ * +recursive-include controlpi_plugins/web/ * diff --git a/conf.json b/conf.json index bb7087a..f24a053 100644 --- a/conf.json +++ b/conf.json @@ -2,7 +2,11 @@ "Example Server": { "plugin": "WSServer", "port": 8080, - "web root": "web" + "web": { + "/": {"location": "web"}, + "/Debug": {"module": "controlpi_plugins.wsserver", + "location": "Debug"} + } }, "Example State": { "plugin": "State" diff --git a/controlpi_plugins/web/Debug/controlpi-debug.css b/controlpi_plugins/web/Debug/controlpi-debug.css new file mode 100644 index 0000000..82ca93b --- /dev/null +++ b/controlpi_plugins/web/Debug/controlpi-debug.css @@ -0,0 +1,186 @@ +.clientcontainer { + padding-right: 5px; + padding-bottom: 5px; +} + +.client { + display: inline-block; + vertical-align: top; + width: 600px; + margin-top: 5px; + margin-left: 5px; + border: 2px solid black; + padding: 5px; + background-color: #bcdaf8; +} + +.client > header { + display: flex; + justify-content: space-between; +} + +.client > header > h2 { + vertical-align: top; + font-size: 1.2rem; + font-weight: bold; +} + +.client > header > h3 { + vertical-align: top; + font-size: 0.8rem; + font-weight: bold; +} + +.interfacecontainer { + white-space: nowrap; +} + +.interfacecontainer > h3 { + display: inline-block; + vertical-align: top; + width: 20px; + margin-top: 5px; + font-weight: bold; +} + +.templatecontainer { + display: inline-block; + vertical-align: top; + width: 580px; + white-space: normal; +} + +.lastcontainer { + text-align: center; +} + +.lastcontainer * { + text-align: left; +} + +.message { + display: inline-block; + vertical-align: top; + background-color: #79b5e7; + border: 1px solid black; +} + +.message.green { + background-color: #85be71; +} + +.message.red { + background-color: #ee96a8; +} + +.templatecontainer > .message { + font-size: 0.8rem; + margin-top: 5px; + margin-left: 5px; +} + +.lastcontainer > .message { + margin-top: 5px; +} + +.message > span { + margin: 5px; + font-size: 1rem; +} + +.message > h4 { + margin: 2px; + font-size: 0.8rem; +} + +.message table { + border-spacing: 2px; +} + +.message .array { + display: flex; + flex-direction: row; + flex-wrap: wrap; +} + +.message .array > * { + vertical-align: baseline; +} + +.message .object { + display: flex; + flex-direction: column; + border: 1px solid black; + padding-top: 2px; + padding-left: 2px; + margin-bottom: 2px; +} + +.message .object > .property { + display: flex; + flex-direction: row; + flex-wrap: wrap; +} + +.message .object > .property > .key { + margin-right: 2px; + margin-bottom: 2px; +} + +.message .object > .property > .value { + display: flex; + flex-direction: row; + flex-wrap: wrap; + margin-right: 2px; + margin-bottom: 2px; +} + +.message input { + background-color: #35bfd3; +} + +.message input[readonly] { + background-color: #79b5e7; +} + +.message input:hover, +.message input:focus { + background-color: #afa7ec; +} + +.message input::selection { + background-color: #d69ae2; +} + +.message input[type="submit"] { + border: 1px solid #0f4a53; + border-radius: 12px; + padding: 2px; +} + +.message input[type="submit"]:hover, +.message input[type="submit"]:focus { + border: 1px solid #44405e; +} + +.message input[type="text"], +.message input[type="number"] { + min-width: 5px; +} + +input[type="number"]::-webkit-outer-spin-button, +input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; +} +input[type="number"] { + -moz-appearance: textfield; +} + +.message select { + background-color: #35bfd3; +} + +.message select:hover, +.message select:focus { + background-color: #afa7ec; +} diff --git a/controlpi_plugins/web/Debug/controlpi-debug.js b/controlpi_plugins/web/Debug/controlpi-debug.js new file mode 100644 index 0000000..3cfe956 --- /dev/null +++ b/controlpi_plugins/web/Debug/controlpi-debug.js @@ -0,0 +1,385 @@ +// Create section for client: +function createForClient(client, plugin) { + const section = document.createElement('section') + section.setAttribute('id', client) + section.setAttribute('class', 'client') + const heading = document.createElement('header') + const headingH2 = document.createElement('h2') + headingH2.appendChild(document.createTextNode(client)) + heading.appendChild(headingH2) + const headingH3 = document.createElement('h3') + headingH3.appendChild(document.createTextNode(plugin)) + heading.appendChild(headingH3) + section.appendChild(heading) + const receiveOuter = document.createElement('div') + receiveOuter.setAttribute('class', 'interfacecontainer') + const receiveHeading = document.createElement('h3') + receiveHeading.appendChild(document.createTextNode('=>')) + receiveOuter.appendChild(receiveHeading) + const receiveInner = document.createElement('div') + receiveInner.setAttribute('id', client + ' Receives') + receiveInner.setAttribute('class', 'templatecontainer') + receiveOuter.appendChild(receiveInner) + section.appendChild(receiveOuter) + const sendOuter = document.createElement('div') + sendOuter.setAttribute('class', 'interfacecontainer') + const sendHeading = document.createElement('h3') + sendHeading.appendChild(document.createTextNode('<=')) + sendOuter.appendChild(sendHeading) + const sendInner = document.createElement('div') + sendInner.setAttribute('id', client + ' Sends') + sendInner.setAttribute('class', 'templatecontainer') + sendOuter.appendChild(sendInner) + section.appendChild(sendOuter) + const last = document.createElement('div') + last.setAttribute('id', client + ' Last') + last.setAttribute('class', 'lastcontainer') + section.appendChild(last) + return section +} + +// Create div and table for template: +function createForTemplate(template) { + const div = document.createElement('div') + div.setAttribute('class', 'message') + if (Object.keys(template).length === 0) { + // Create span with '*' for empty templates: + const span = document.createElement('span') + const spanContent = document.createTextNode('*') + span.appendChild(spanContent) + div.appendChild(span) + } else { + const table = document.createElement('table') + for (const key in template) { + // Append table row for key-value pair: + const tr = document.createElement('tr') + const keyTd = document.createElement('td') + const keyTdContent = document.createTextNode(key + ':') + keyTd.appendChild(keyTdContent) + tr.appendChild(keyTd) + const valueTd = document.createElement('td') + schema = template[key] + value = '' + if ('const' in schema) { + value = JSON.stringify(schema['const']) + } else { + value = JSON.stringify(schema) + } + const valueTdContent = document.createTextNode(value) + valueTd.appendChild(valueTdContent) + tr.appendChild(valueTd) + table.appendChild(tr) + } + div.appendChild(table) + } + return div +} + +// Resize input fields to current input: +function resizeInput() { + const tmp = document.createElement('span') + // HACK: Should get this from CSS rather than hardcode it: + tmp.setAttribute('style', 'font-size: 0.8rem;') + var value = this.value + // HACK: 'number' fields for floats do not return trailing dot in + // value, so we add one unconditionally: + if (this.type == 'number' && this.step == 'any' && !value.includes('.')) { + value += '.' + } + const tmpContent = document.createTextNode(value) + tmp.appendChild(tmpContent) + document.body.appendChild(tmp); + const width = tmp.getBoundingClientRect().width + document.body.removeChild(tmp); + this.style.width = width + 'px' +} + +// Create form input (or select) for key-value pairs in receive templates: +function inputsForKeyValue(key, schema) { + result = [] + literal = false + if ('const' in schema) { + literal = true + } + if (schema['type'] == 'boolean' || + (literal && typeof(schema['const']) == 'boolean')) { + // Create select with true and false options for Boolean: + const select = document.createElement('select') + select.setAttribute('name', key) + const optionTrue = document.createElement('option') + optionTrue.setAttribute('value', 'true') + const optionTrueContent = document.createTextNode('true') + optionTrue.appendChild(optionTrueContent) + select.appendChild(optionTrue) + const optionFalse = document.createElement('option') + optionFalse.setAttribute('value', false) + const optionFalseContent = document.createTextNode('false') + optionFalse.appendChild(optionFalseContent) + if (literal) { + // Select set value and disable other value + // for literal Boolean: + if (schema['const']) { + optionTrue.setAttribute('selected', '') + optioniFalse.setAttribute('disabled', '') + } else { + optionFalse.setAttribute('selected', '') + optionTrue.setAttribute('disabled', '') + } + } + select.appendChild(optionFalse) + result.push(select) + } else { + // Create input for everything except Booleans: + if (schema['type'] == 'string' || + (literal && typeof(schema['const']) == 'string' && + key != 'command')) { + // Quote strings: + const openquote = document.createTextNode('"') + result.push(openquote) + } + const input = document.createElement('input') + // Set type of input: + if (key == 'command') { + input.setAttribute('type', 'hidden') + } else if (schema['type'] == 'integer' || + schema['type'] == 'number' || + typeof(schema['const']) == 'number') { + input.setAttribute('type', 'number') + if (schema['type'] == 'integer') { + input.setAttribute('step', '1') + } else if (schema['type'] == 'number') { + input.setAttribute('step', 'any') + } + } else if (schema['type'] == 'string' || + typeof(schema['const']) == 'string') { + input.setAttribute('type', 'text') + } + // Set key as name of input: + input.setAttribute('name', key) + // Set value of input, readonly or required: + if (key == 'command') { + input.setAttribute('value', schema['const']) + } else if (literal) { + input.setAttribute('value', schema['const']) + input.setAttribute('readonly', '') + } else { + input.setAttribute('value', '') + input.setAttribute('required', '') + } + if (key != 'command') { + // Resize input field at every change and at beginning: + input.addEventListener('input', resizeInput) + resizeInput.call(input) + } + result.push(input) + if (schema['type'] == 'string' || + (literal && typeof(schema['const']) == 'string' && + key != 'command')) { + // Quote strings: + const closequote = document.createTextNode('"') + result.push(closequote) + } + if (key == 'command') { + // Add submit button for 'command' key: + const submit = document.createElement('input') + submit.setAttribute('type', 'submit') + submit.setAttribute('value', schema['const']) + result.push(submit) + } + } + return result +} + +// Create div, form and table for command template: +function createForCommand(template) { + const div = document.createElement('div') + div.setAttribute('class', 'message') + const form = document.createElement('form') + form.addEventListener("submit", function(e) { + // Convert names and values of all form elements to key-value pairs + // and send as JSON over websocket: + e.preventDefault(); + var data = {}; + for (const element of form.elements) { + if (element.type == 'text' || element.type == 'hidden') { + data[element.name] = element.value + } else if (element.type == 'number') { + data[element.name] = element.valueAsNumber + } else if (element.type == 'select-one') { + if (element.value == 'true') { + data[element.name] = true + } else if (element.value == 'false') { + data[element.name] = false + } else { + data[element.name] = element.value + } + } + } + websocket.send(JSON.stringify(data)) + }) + const table = document.createElement('table') + for (const key in template) { + // Append table row for key-value pair: + const tr = document.createElement('tr') + const keyTd = document.createElement('td') + const keyTdContent = document.createTextNode(key + ':') + keyTd.appendChild(keyTdContent) + tr.appendChild(keyTd) + const valueTd = document.createElement('td') + for (const element of inputsForKeyValue(key, template[key])) { + valueTd.appendChild(element) + } + tr.appendChild(valueTd) + table.appendChild(tr) + } + form.appendChild(table) + div.appendChild(form) + return div +} + +// Remove (if unregistered) or create (if not existent) elements for +// clients and update interface information: +function processBusMessage(message) { + if (message['event'] == 'unregistered') { + // On deregistration delete client element if it exists: + const clientElement = document.getElementById(message['client']) + if (clientElement != null) { + clientElement.remove() + } + } else { + // On registration or 'get clients' answer: + const clientElement = document.getElementById(message['client']) + if (clientElement == null) { + // Create element for client if not existent: + const main = document.getElementById('ControlPi Debug') + main.appendChild(createForClient(message['client'], + message['plugin'])) + } + // Crate message elements for receives interface: + const receiveContainer = document.getElementById(message['client'] + ' Receives') + receiveContainer.innerHTML = '' + for (const template of message['receives']) { + if (template['command'] != null) { + receiveContainer.appendChild(createForCommand(template)) + } else { + receiveContainer.appendChild(createForTemplate(template)) + } + } + // Create message elements for sends interface: + const sendContainer = document.getElementById(message['client'] + ' Sends') + sendContainer.innerHTML = '' + for (const template of message['sends']) { + sendContainer.appendChild(createForTemplate(template)) + } + } +} + +// Recursively create div structure for arrays, objects and primitive +// values: +function createForMessageValue(value) { + var result = '' + if (typeof(value) == 'object') { + if (Array.isArray(value)) { + result = document.createElement('div') + result.setAttribute('class', 'array') + var counter = value.length + for (const child of value) { + result.appendChild(createForMessageValue(child)) + if (--counter) { + result.appendChild(document.createTextNode(', ')) + } + } + } else { + result = document.createElement('div') + result.setAttribute('class', 'object') + for (const key in value) { + const property = document.createElement('div') + property.setAttribute('class', 'property') + const keyDiv = document.createElement('div') + keyDiv.setAttribute('class', 'key') + keyDiv.appendChild(document.createTextNode(key + ':')) + property.appendChild(keyDiv) + const valueDiv = document.createElement('div') + valueDiv.setAttribute('class', 'value') + valueDiv.appendChild(createForMessageValue(value[key])) + property.appendChild(valueDiv) + result.appendChild(property) + } + } + } else { + result = document.createTextNode(JSON.stringify(value)) + } + return result +} + +// Create div and table for message: +function createForMessage(message) { + const div = document.createElement('div') + div.setAttribute('class', 'message') + // Current (receive) time as heading: + const time = new Date().toLocaleTimeString() + const h4 = document.createElement('h4') + const h4Content = document.createTextNode(time) + h4.appendChild(h4Content) + div.appendChild(h4) + const table = document.createElement('table') + for (const key in message) { + if (key == 'sender') { + // Ignore 'sender' for last received messages + // (information redundantly present in client heading): + continue + } else if (key == 'state') { + // Set background according to state: + if (message[key] === true) { + div.classList.add('green') + } else if (message[key] === false) { + div.classList.add('red') + } + } + // Append table row for key-value pair: + const tr = document.createElement('tr') + const keyTd = document.createElement('td') + const keyTdContent = document.createTextNode(key + ':') + keyTd.appendChild(keyTdContent) + tr.appendChild(keyTd) + const valueTd = document.createElement('td') + valueTd.appendChild(createForMessageValue(message[key])) + tr.appendChild(valueTd) + table.appendChild(tr) + } + div.appendChild(table) + return div +} + +// Create element for client (if not existent) +// and update last received message: +function processClientMessage(message) { + const clientElement = document.getElementById(message['sender']) + if (clientElement == null) { + // Create section for client if not existent: + const main = document.getElementById('ControlPi Debug') + main.appendChild(createForClient(message.sender, '')) + } + // Update last received message: + const lastContainer = document.getElementById(message['sender'] + ' Last') + lastContainer.innerHTML = '' + lastContainer.appendChild(createForMessage(message)) +} + +// Open Websocket back to ControlPi we were loaded from: +const websocket = new WebSocket("ws://" + window.location.host) + +// When Websocket is ready request all clients from bus: +websocket.addEventListener('open', function (event) { + websocket.send(JSON.stringify({target: '', command: 'get clients'})) +}) + +// When receiving message from ControlPi through Websocket: +websocket.addEventListener('message', function (event) { + const message = JSON.parse(event.data) + if (message['sender'] == '') { + processBusMessage(message) + } else { + processClientMessage(message) + } +}) diff --git a/controlpi_plugins/web/Debug/index.css b/controlpi_plugins/web/Debug/index.css new file mode 100644 index 0000000..e5d2c50 --- /dev/null +++ b/controlpi_plugins/web/Debug/index.css @@ -0,0 +1,71 @@ +@import url('https://fonts.googleapis.com/css?family=Roboto'); +@import url('https://fonts.googleapis.com/css?family=Michroma'); + +* { + vertical-align: baseline; + margin: 0; + outline: 0; + border: 0 none; + padding: 0; + font-family: 'Roboto', sans-serif; + font-weight: inherit; + font-style: inherit; + font-size: inherit; +} + +html { + font-size: 16px; +} + +body { + background-color: white; + color: black; +} + +body > header { + padding: 10px; + background-color: #2c343b; +} + +body > header * { + vertical-align: middle; +} + +body > header svg { + height: 40px; +} + +body > header span { + font-family: "Michroma", sans-serif; + font-weight: bold; + font-size: 20px; + text-transform: uppercase; +} + +body > header span.Graph { + margin-left: 10px; + color: white; +} + +body > header span.IT { + margin-right: 10px; + color: #83a1b4; +} + +h1, h2, h3 { + margin-top: 5px; + margin-left: 5px; + font-weight: bold; +} + +h1 { + font-size: 1.4rem; +} + +h2 { + font-size: 1.2rem; +} + +h3 { + font-size: 1.2rem; +} diff --git a/controlpi_plugins/web/Debug/index.html b/controlpi_plugins/web/Debug/index.html new file mode 100644 index 0000000..f6d6ff5 --- /dev/null +++ b/controlpi_plugins/web/Debug/index.html @@ -0,0 +1,31 @@ + + + + + + ControlPi Debug + + + + + + +
+ + + + + + + + + + + + Graph-IT +
+

ControlPi Debug

+
+
+ + diff --git a/controlpi_plugins/wsserver.py b/controlpi_plugins/wsserver.py index 2a8d625..fc7a3fe 100644 --- a/controlpi_plugins/wsserver.py +++ b/controlpi_plugins/wsserver.py @@ -3,20 +3,22 @@ … TODO: documentation, doctests -TODO: Mount multiple web apps from packages and file paths TODO: Let Debug web app collapse/expand nested structures TODO: Make Debug web app work with nested structures in commands +TODO: Let clients filter messages received over websocket """ import os +import sys import json from websockets import (WebSocketServerProtocol, ConnectionClosedOK, ConnectionClosedError, serve) from websockets.http import Headers from http import HTTPStatus -from typing import Optional, Tuple from controlpi import BasePlugin, MessageBus, Message, MessageTemplate +from typing import Optional, Tuple + class Connection: """Connection to websocket. @@ -78,9 +80,17 @@ class WSServer(BasePlugin): the contents in given web root. """ - CONF_SCHEMA = {'properties': {'host': {'type': 'string'}, - 'port': {'type': 'integer'}, - 'web root': {'type': 'string'}}} + CONF_SCHEMA = {'properties': + {'host': {'type': 'string'}, + 'port': {'type': 'integer'}, + 'web': {'type': 'object', + 'patternProperties': + {'^/([A-Z][A-Za-z]*)?$': + {'type': 'object', + 'properties': {'module': {'type': 'string'}, + 'location': {'type': 'string'}}, + 'required': ['location']}}, + 'additionalProperties': False}}} """Schema for WServer plugin configuration. Optional configuration keys: @@ -88,7 +98,9 @@ class WSServer(BasePlugin): - 'host': network interfaces to listen on (default: None, meaning all interfaces) - 'port': port to connect to (default: 80) - - 'web root': root of files to serve (default: 'web') + - 'web': mapping of web paths to locations on disk (either as absolute + path, relative path from the working directory or path from the + path containing a given module) """ async def _handler(self, websocket: WebSocketServerProtocol, @@ -100,49 +112,71 @@ class WSServer(BasePlugin): request_headers: Headers) -> Response: if 'Upgrade' in request_headers: return None - if path == '/': - path = '/index.html' response_headers = Headers() response_headers['Server'] = 'controlpi-wsserver websocket server' response_headers['Connection'] = 'close' - file_path = os.path.realpath(os.path.join(self._web_root, path[1:])) - if os.path.commonpath((self._web_root, file_path)) != self._web_root \ - or not os.path.exists(file_path) \ - or not os.path.isfile(file_path): + if path not in self._web_files: return (HTTPStatus.NOT_FOUND, response_headers, f"File '{path}' not found!".encode()) - mime_type = 'application/octet-stream' - extension = file_path.split('.')[-1] - if extension == 'html': - mime_type = 'text/html' - elif extension == 'js': - mime_type = 'text/javascript' - elif extension == 'css': - mime_type = 'text/css' - response_headers['Content-Type'] = mime_type - body = open(file_path, 'rb').read() + file_info = self._web_files[path] + response_headers['Content-Type'] = file_info['type'] + body = open(file_info['location'], 'rb').read() response_headers['Content-Length'] = str(len(body)) return HTTPStatus.OK, response_headers, body def process_conf(self) -> None: """Get host, port and path settings from configuration.""" + self._host = None + if 'host' in self.conf: + self._host = self.conf['host'] + else: + print(f"'host' not configured for WSServer '{self.name}'." + " Serving on all interfaces.") self._port = 80 if 'port' in self.conf: self._port = self.conf['port'] else: print(f"'port' not configured for WSServer '{self.name}'." - " Using 80.") - web_root = 'web' - if 'web root' in self.conf: - web_root = self.conf['web root'] - else: - print(f"'web root' not configured for WSServer '{self.name}'." - " Using 'web'.") - self._web_root = os.path.realpath(os.path.join(os.getcwd(), - web_root)) + " Using port 80.") + self._web_files = {} + if 'web' in self.conf: + for path in self.conf['web']: + path_conf = self.conf['web'][path] + location = path_conf['location'] + if 'module' in path_conf: + # Determine location relative to module directory: + module_file = sys.modules[path_conf['module']].__file__ + module_dir = os.path.dirname(module_file) + location = os.path.join(module_dir, 'web', location) + else: + # Determine location relative to current working directory: + location = os.path.join(os.getcwd(), location) + location = os.path.realpath(location) + # Walk all files in location recursively: + for (dir_location, _, filenames) in os.walk(location): + dir_path = os.path.join(path, dir_location[len(location):]) + for filename in filenames: + file_path = os.path.join(dir_path, filename) + file_location = os.path.join(dir_location, filename) + file_info = {'location': file_location} + # Determine MIME type: + file_info['type'] = 'application/octet-stream' + extension = filename.split('.')[-1] + if extension == 'html': + file_info['type'] = 'text/html' + elif extension == 'js': + file_info['type'] = 'text/javascript' + elif extension == 'css': + file_info['type'] = 'text/css' + # Register in instance variable: + self._web_files[file_path] = file_info + # Serve index.html also as directory: + if filename == 'index.html': + self._web_files[dir_path] = file_info + self._web_files[dir_path.rstrip('/')] = file_info async def run(self) -> None: """Set up websocket server.""" - await serve(self._handler, port=self._port, + await serve(self._handler, host=self._host, port=self._port, process_request=self._process_request) print(f"WSServer '{self.name}' serving on port {self._port}.") diff --git a/setup.py b/setup.py index 76aca3a..d7067f7 100644 --- a/setup.py +++ b/setup.py @@ -13,6 +13,7 @@ setuptools.setup( long_description_content_type="text/markdown", url="http://docs.graph-it.com/graphit/controlpi-wsserver", packages=["controlpi_plugins"], + include_package_data=True, install_requires=[ "websockets", "controlpi @ git+git://git.graph-it.com/graphit/controlpi.git", diff --git a/web/controlpi-debug.css b/web/controlpi-debug.css deleted file mode 100644 index 82ca93b..0000000 --- a/web/controlpi-debug.css +++ /dev/null @@ -1,186 +0,0 @@ -.clientcontainer { - padding-right: 5px; - padding-bottom: 5px; -} - -.client { - display: inline-block; - vertical-align: top; - width: 600px; - margin-top: 5px; - margin-left: 5px; - border: 2px solid black; - padding: 5px; - background-color: #bcdaf8; -} - -.client > header { - display: flex; - justify-content: space-between; -} - -.client > header > h2 { - vertical-align: top; - font-size: 1.2rem; - font-weight: bold; -} - -.client > header > h3 { - vertical-align: top; - font-size: 0.8rem; - font-weight: bold; -} - -.interfacecontainer { - white-space: nowrap; -} - -.interfacecontainer > h3 { - display: inline-block; - vertical-align: top; - width: 20px; - margin-top: 5px; - font-weight: bold; -} - -.templatecontainer { - display: inline-block; - vertical-align: top; - width: 580px; - white-space: normal; -} - -.lastcontainer { - text-align: center; -} - -.lastcontainer * { - text-align: left; -} - -.message { - display: inline-block; - vertical-align: top; - background-color: #79b5e7; - border: 1px solid black; -} - -.message.green { - background-color: #85be71; -} - -.message.red { - background-color: #ee96a8; -} - -.templatecontainer > .message { - font-size: 0.8rem; - margin-top: 5px; - margin-left: 5px; -} - -.lastcontainer > .message { - margin-top: 5px; -} - -.message > span { - margin: 5px; - font-size: 1rem; -} - -.message > h4 { - margin: 2px; - font-size: 0.8rem; -} - -.message table { - border-spacing: 2px; -} - -.message .array { - display: flex; - flex-direction: row; - flex-wrap: wrap; -} - -.message .array > * { - vertical-align: baseline; -} - -.message .object { - display: flex; - flex-direction: column; - border: 1px solid black; - padding-top: 2px; - padding-left: 2px; - margin-bottom: 2px; -} - -.message .object > .property { - display: flex; - flex-direction: row; - flex-wrap: wrap; -} - -.message .object > .property > .key { - margin-right: 2px; - margin-bottom: 2px; -} - -.message .object > .property > .value { - display: flex; - flex-direction: row; - flex-wrap: wrap; - margin-right: 2px; - margin-bottom: 2px; -} - -.message input { - background-color: #35bfd3; -} - -.message input[readonly] { - background-color: #79b5e7; -} - -.message input:hover, -.message input:focus { - background-color: #afa7ec; -} - -.message input::selection { - background-color: #d69ae2; -} - -.message input[type="submit"] { - border: 1px solid #0f4a53; - border-radius: 12px; - padding: 2px; -} - -.message input[type="submit"]:hover, -.message input[type="submit"]:focus { - border: 1px solid #44405e; -} - -.message input[type="text"], -.message input[type="number"] { - min-width: 5px; -} - -input[type="number"]::-webkit-outer-spin-button, -input[type="number"]::-webkit-inner-spin-button { - -webkit-appearance: none; -} -input[type="number"] { - -moz-appearance: textfield; -} - -.message select { - background-color: #35bfd3; -} - -.message select:hover, -.message select:focus { - background-color: #afa7ec; -} diff --git a/web/controlpi-debug.js b/web/controlpi-debug.js deleted file mode 100644 index 3cfe956..0000000 --- a/web/controlpi-debug.js +++ /dev/null @@ -1,385 +0,0 @@ -// Create section for client: -function createForClient(client, plugin) { - const section = document.createElement('section') - section.setAttribute('id', client) - section.setAttribute('class', 'client') - const heading = document.createElement('header') - const headingH2 = document.createElement('h2') - headingH2.appendChild(document.createTextNode(client)) - heading.appendChild(headingH2) - const headingH3 = document.createElement('h3') - headingH3.appendChild(document.createTextNode(plugin)) - heading.appendChild(headingH3) - section.appendChild(heading) - const receiveOuter = document.createElement('div') - receiveOuter.setAttribute('class', 'interfacecontainer') - const receiveHeading = document.createElement('h3') - receiveHeading.appendChild(document.createTextNode('=>')) - receiveOuter.appendChild(receiveHeading) - const receiveInner = document.createElement('div') - receiveInner.setAttribute('id', client + ' Receives') - receiveInner.setAttribute('class', 'templatecontainer') - receiveOuter.appendChild(receiveInner) - section.appendChild(receiveOuter) - const sendOuter = document.createElement('div') - sendOuter.setAttribute('class', 'interfacecontainer') - const sendHeading = document.createElement('h3') - sendHeading.appendChild(document.createTextNode('<=')) - sendOuter.appendChild(sendHeading) - const sendInner = document.createElement('div') - sendInner.setAttribute('id', client + ' Sends') - sendInner.setAttribute('class', 'templatecontainer') - sendOuter.appendChild(sendInner) - section.appendChild(sendOuter) - const last = document.createElement('div') - last.setAttribute('id', client + ' Last') - last.setAttribute('class', 'lastcontainer') - section.appendChild(last) - return section -} - -// Create div and table for template: -function createForTemplate(template) { - const div = document.createElement('div') - div.setAttribute('class', 'message') - if (Object.keys(template).length === 0) { - // Create span with '*' for empty templates: - const span = document.createElement('span') - const spanContent = document.createTextNode('*') - span.appendChild(spanContent) - div.appendChild(span) - } else { - const table = document.createElement('table') - for (const key in template) { - // Append table row for key-value pair: - const tr = document.createElement('tr') - const keyTd = document.createElement('td') - const keyTdContent = document.createTextNode(key + ':') - keyTd.appendChild(keyTdContent) - tr.appendChild(keyTd) - const valueTd = document.createElement('td') - schema = template[key] - value = '' - if ('const' in schema) { - value = JSON.stringify(schema['const']) - } else { - value = JSON.stringify(schema) - } - const valueTdContent = document.createTextNode(value) - valueTd.appendChild(valueTdContent) - tr.appendChild(valueTd) - table.appendChild(tr) - } - div.appendChild(table) - } - return div -} - -// Resize input fields to current input: -function resizeInput() { - const tmp = document.createElement('span') - // HACK: Should get this from CSS rather than hardcode it: - tmp.setAttribute('style', 'font-size: 0.8rem;') - var value = this.value - // HACK: 'number' fields for floats do not return trailing dot in - // value, so we add one unconditionally: - if (this.type == 'number' && this.step == 'any' && !value.includes('.')) { - value += '.' - } - const tmpContent = document.createTextNode(value) - tmp.appendChild(tmpContent) - document.body.appendChild(tmp); - const width = tmp.getBoundingClientRect().width - document.body.removeChild(tmp); - this.style.width = width + 'px' -} - -// Create form input (or select) for key-value pairs in receive templates: -function inputsForKeyValue(key, schema) { - result = [] - literal = false - if ('const' in schema) { - literal = true - } - if (schema['type'] == 'boolean' || - (literal && typeof(schema['const']) == 'boolean')) { - // Create select with true and false options for Boolean: - const select = document.createElement('select') - select.setAttribute('name', key) - const optionTrue = document.createElement('option') - optionTrue.setAttribute('value', 'true') - const optionTrueContent = document.createTextNode('true') - optionTrue.appendChild(optionTrueContent) - select.appendChild(optionTrue) - const optionFalse = document.createElement('option') - optionFalse.setAttribute('value', false) - const optionFalseContent = document.createTextNode('false') - optionFalse.appendChild(optionFalseContent) - if (literal) { - // Select set value and disable other value - // for literal Boolean: - if (schema['const']) { - optionTrue.setAttribute('selected', '') - optioniFalse.setAttribute('disabled', '') - } else { - optionFalse.setAttribute('selected', '') - optionTrue.setAttribute('disabled', '') - } - } - select.appendChild(optionFalse) - result.push(select) - } else { - // Create input for everything except Booleans: - if (schema['type'] == 'string' || - (literal && typeof(schema['const']) == 'string' && - key != 'command')) { - // Quote strings: - const openquote = document.createTextNode('"') - result.push(openquote) - } - const input = document.createElement('input') - // Set type of input: - if (key == 'command') { - input.setAttribute('type', 'hidden') - } else if (schema['type'] == 'integer' || - schema['type'] == 'number' || - typeof(schema['const']) == 'number') { - input.setAttribute('type', 'number') - if (schema['type'] == 'integer') { - input.setAttribute('step', '1') - } else if (schema['type'] == 'number') { - input.setAttribute('step', 'any') - } - } else if (schema['type'] == 'string' || - typeof(schema['const']) == 'string') { - input.setAttribute('type', 'text') - } - // Set key as name of input: - input.setAttribute('name', key) - // Set value of input, readonly or required: - if (key == 'command') { - input.setAttribute('value', schema['const']) - } else if (literal) { - input.setAttribute('value', schema['const']) - input.setAttribute('readonly', '') - } else { - input.setAttribute('value', '') - input.setAttribute('required', '') - } - if (key != 'command') { - // Resize input field at every change and at beginning: - input.addEventListener('input', resizeInput) - resizeInput.call(input) - } - result.push(input) - if (schema['type'] == 'string' || - (literal && typeof(schema['const']) == 'string' && - key != 'command')) { - // Quote strings: - const closequote = document.createTextNode('"') - result.push(closequote) - } - if (key == 'command') { - // Add submit button for 'command' key: - const submit = document.createElement('input') - submit.setAttribute('type', 'submit') - submit.setAttribute('value', schema['const']) - result.push(submit) - } - } - return result -} - -// Create div, form and table for command template: -function createForCommand(template) { - const div = document.createElement('div') - div.setAttribute('class', 'message') - const form = document.createElement('form') - form.addEventListener("submit", function(e) { - // Convert names and values of all form elements to key-value pairs - // and send as JSON over websocket: - e.preventDefault(); - var data = {}; - for (const element of form.elements) { - if (element.type == 'text' || element.type == 'hidden') { - data[element.name] = element.value - } else if (element.type == 'number') { - data[element.name] = element.valueAsNumber - } else if (element.type == 'select-one') { - if (element.value == 'true') { - data[element.name] = true - } else if (element.value == 'false') { - data[element.name] = false - } else { - data[element.name] = element.value - } - } - } - websocket.send(JSON.stringify(data)) - }) - const table = document.createElement('table') - for (const key in template) { - // Append table row for key-value pair: - const tr = document.createElement('tr') - const keyTd = document.createElement('td') - const keyTdContent = document.createTextNode(key + ':') - keyTd.appendChild(keyTdContent) - tr.appendChild(keyTd) - const valueTd = document.createElement('td') - for (const element of inputsForKeyValue(key, template[key])) { - valueTd.appendChild(element) - } - tr.appendChild(valueTd) - table.appendChild(tr) - } - form.appendChild(table) - div.appendChild(form) - return div -} - -// Remove (if unregistered) or create (if not existent) elements for -// clients and update interface information: -function processBusMessage(message) { - if (message['event'] == 'unregistered') { - // On deregistration delete client element if it exists: - const clientElement = document.getElementById(message['client']) - if (clientElement != null) { - clientElement.remove() - } - } else { - // On registration or 'get clients' answer: - const clientElement = document.getElementById(message['client']) - if (clientElement == null) { - // Create element for client if not existent: - const main = document.getElementById('ControlPi Debug') - main.appendChild(createForClient(message['client'], - message['plugin'])) - } - // Crate message elements for receives interface: - const receiveContainer = document.getElementById(message['client'] + ' Receives') - receiveContainer.innerHTML = '' - for (const template of message['receives']) { - if (template['command'] != null) { - receiveContainer.appendChild(createForCommand(template)) - } else { - receiveContainer.appendChild(createForTemplate(template)) - } - } - // Create message elements for sends interface: - const sendContainer = document.getElementById(message['client'] + ' Sends') - sendContainer.innerHTML = '' - for (const template of message['sends']) { - sendContainer.appendChild(createForTemplate(template)) - } - } -} - -// Recursively create div structure for arrays, objects and primitive -// values: -function createForMessageValue(value) { - var result = '' - if (typeof(value) == 'object') { - if (Array.isArray(value)) { - result = document.createElement('div') - result.setAttribute('class', 'array') - var counter = value.length - for (const child of value) { - result.appendChild(createForMessageValue(child)) - if (--counter) { - result.appendChild(document.createTextNode(', ')) - } - } - } else { - result = document.createElement('div') - result.setAttribute('class', 'object') - for (const key in value) { - const property = document.createElement('div') - property.setAttribute('class', 'property') - const keyDiv = document.createElement('div') - keyDiv.setAttribute('class', 'key') - keyDiv.appendChild(document.createTextNode(key + ':')) - property.appendChild(keyDiv) - const valueDiv = document.createElement('div') - valueDiv.setAttribute('class', 'value') - valueDiv.appendChild(createForMessageValue(value[key])) - property.appendChild(valueDiv) - result.appendChild(property) - } - } - } else { - result = document.createTextNode(JSON.stringify(value)) - } - return result -} - -// Create div and table for message: -function createForMessage(message) { - const div = document.createElement('div') - div.setAttribute('class', 'message') - // Current (receive) time as heading: - const time = new Date().toLocaleTimeString() - const h4 = document.createElement('h4') - const h4Content = document.createTextNode(time) - h4.appendChild(h4Content) - div.appendChild(h4) - const table = document.createElement('table') - for (const key in message) { - if (key == 'sender') { - // Ignore 'sender' for last received messages - // (information redundantly present in client heading): - continue - } else if (key == 'state') { - // Set background according to state: - if (message[key] === true) { - div.classList.add('green') - } else if (message[key] === false) { - div.classList.add('red') - } - } - // Append table row for key-value pair: - const tr = document.createElement('tr') - const keyTd = document.createElement('td') - const keyTdContent = document.createTextNode(key + ':') - keyTd.appendChild(keyTdContent) - tr.appendChild(keyTd) - const valueTd = document.createElement('td') - valueTd.appendChild(createForMessageValue(message[key])) - tr.appendChild(valueTd) - table.appendChild(tr) - } - div.appendChild(table) - return div -} - -// Create element for client (if not existent) -// and update last received message: -function processClientMessage(message) { - const clientElement = document.getElementById(message['sender']) - if (clientElement == null) { - // Create section for client if not existent: - const main = document.getElementById('ControlPi Debug') - main.appendChild(createForClient(message.sender, '')) - } - // Update last received message: - const lastContainer = document.getElementById(message['sender'] + ' Last') - lastContainer.innerHTML = '' - lastContainer.appendChild(createForMessage(message)) -} - -// Open Websocket back to ControlPi we were loaded from: -const websocket = new WebSocket("ws://" + window.location.host) - -// When Websocket is ready request all clients from bus: -websocket.addEventListener('open', function (event) { - websocket.send(JSON.stringify({target: '', command: 'get clients'})) -}) - -// When receiving message from ControlPi through Websocket: -websocket.addEventListener('message', function (event) { - const message = JSON.parse(event.data) - if (message['sender'] == '') { - processBusMessage(message) - } else { - processClientMessage(message) - } -}) diff --git a/web/index.css b/web/index.css index e0f29d1..e5d2c50 100644 --- a/web/index.css +++ b/web/index.css @@ -52,10 +52,20 @@ body > header span.IT { color: #83a1b4; } -h1 { +h1, h2, h3 { margin-top: 5px; margin-left: 5px; - margin-right: 5px; - font-size: 1.4rem; font-weight: bold; } + +h1 { + font-size: 1.4rem; +} + +h2 { + font-size: 1.2rem; +} + +h3 { + font-size: 1.2rem; +} diff --git a/web/index.html b/web/index.html index f6d6ff5..59c384e 100644 --- a/web/index.html +++ b/web/index.html @@ -6,8 +6,6 @@ ControlPi Debug - -
@@ -24,8 +22,7 @@ Graph-IT
-

ControlPi Debug

-
-
+

ControlPi

+

Debug