diff --git a/db/.gitignore b/db/.gitignore new file mode 100644 index 0000000..5ebf0ae --- /dev/null +++ b/db/.gitignore @@ -0,0 +1 @@ +secret.env diff --git a/db/docker-compose.yml b/db/docker-compose.yml new file mode 100644 index 0000000..5f96734 --- /dev/null +++ b/db/docker-compose.yml @@ -0,0 +1,19 @@ +# Use root/example as user/password credentials +version: '3.1' + +services: + mongo: + image: mongo + restart: always + ports: + - 27017:27017 + env_file: + - secret.env + + mongo-express: + image: mongo-express + restart: always + ports: + - 8081:8081 + env_file: + - secret.env diff --git a/db/secret.env.example b/db/secret.env.example new file mode 100644 index 0000000..77092e7 --- /dev/null +++ b/db/secret.env.example @@ -0,0 +1,5 @@ +MONGO_INITDB_ROOT_USERNAME="user" +MONGO_INITDB_ROOT_PASSWORD="pass" +ME_CONFIG_MONGODB_ADMINUSERNAME="user" +ME_CONFIG_MONGODB_ADMINPASSWORD="pass" +ME_CONFIG_MONGODB_URL="mongodb://user:pass@mongo:27017/" diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index f6913ef..4ab0ec1 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -9,6 +9,8 @@ import Mining from "./pages/mining/Mining.svelte"; import Footer from "./Footer.svelte"; import BaseLayout from "./BaseLayout.svelte"; + import Monitoring from "./pages/monitoring/Monitoring.svelte"; + import Bar from "./pages/playground/Bar.svelte"; onMount(async () => { const res = await fetch("/user/me"); @@ -25,7 +27,7 @@ - +
@@ -38,19 +40,19 @@ - - bar + + - - monitoring + + - + - + stats diff --git a/frontend/src/Navlink.svelte b/frontend/src/Navlink.svelte index bfee97a..0c567ce 100644 --- a/frontend/src/Navlink.svelte +++ b/frontend/src/Navlink.svelte @@ -10,7 +10,7 @@ diff --git a/frontend/src/app.scss b/frontend/src/app.scss index 53bb026..b2eb317 100644 --- a/frontend/src/app.scss +++ b/frontend/src/app.scss @@ -18,6 +18,11 @@ $fa-font-path: "@fortawesome/fontawesome-free/webfonts"; @import "@fortawesome/fontawesome-free/scss/v4-shims.scss"; // https://github.com/mefechoel/svelte-navigator#what-are-the-weird-rectangles-around-the-headings-in-my-app -h1:focus { +h1:focus, +h2:focus, +h3:focus, +h4:focus, +h5:focus, +h6:focus { outline: none; -} +} \ No newline at end of file diff --git a/frontend/src/pages/monitoring/Monitoring.svelte b/frontend/src/pages/monitoring/Monitoring.svelte new file mode 100644 index 0000000..0701ece --- /dev/null +++ b/frontend/src/pages/monitoring/Monitoring.svelte @@ -0,0 +1,27 @@ + + +
+ +
+

Yo wassup

+
    + {#each uuids as id} +
  • {id}
  • + {/each} +
+
+
+ + +
+

ASSUMING DIRECT CONTROL

+ + fuck go back +
+
+
diff --git a/frontend/src/pages/monitoring/Screen.svelte b/frontend/src/pages/monitoring/Screen.svelte new file mode 100644 index 0000000..baa94ad --- /dev/null +++ b/frontend/src/pages/monitoring/Screen.svelte @@ -0,0 +1,121 @@ + + + diff --git a/frontend/src/pages/monitoring/Viewer.svelte b/frontend/src/pages/monitoring/Viewer.svelte new file mode 100644 index 0000000..3622cb0 --- /dev/null +++ b/frontend/src/pages/monitoring/Viewer.svelte @@ -0,0 +1,65 @@ + + + +
{JSON.stringify(data, null, 2)}
diff --git a/frontend/src/pages/monitoring/font.ts b/frontend/src/pages/monitoring/font.ts new file mode 100644 index 0000000..df4fad7 --- /dev/null +++ b/frontend/src/pages/monitoring/font.ts @@ -0,0 +1,25 @@ +export const charWidth = 6, charHeight = 9; + +export async function loadFont(src: RequestInfo | URL): Promise { + const fontImg = await fetch(src); + const fontBlob = await fontImg.blob(); + + async function getCharBitmap(x: number, y: number) { + const fontOffsetX = 1, fontOffsetY = 1, fontPaddingX = 2, fontPaddingY = 2; + + const offsetX = (charWidth + fontPaddingX) * x + fontOffsetX; + const offsetY = (charHeight + fontPaddingY) * y + fontOffsetY; + + return await createImageBitmap(fontBlob, offsetX, offsetY, charWidth, charHeight); + } + + const font = Array(256); + + for (let y = 0; y < 16; ++y) { + for (let x = 0; x < 16; ++x) { + const i = 16 * y + x; + font[i] = getCharBitmap(x, y); + } + } + return await Promise.all(font); +} \ No newline at end of file diff --git a/frontend/src/pages/monitoring/proto.ts b/frontend/src/pages/monitoring/proto.ts new file mode 100644 index 0000000..2eb5029 --- /dev/null +++ b/frontend/src/pages/monitoring/proto.ts @@ -0,0 +1,12 @@ +export type ScreenContent = { + x: number + y: number + width: number + height: number + blink: boolean + fg: number + text: string[] + fg_color: string[] + bg_color: string[] + palette: number[] +} \ No newline at end of file diff --git a/frontend/src/pages/monitoring/term_font.png b/frontend/src/pages/monitoring/term_font.png new file mode 100644 index 0000000..7bf23be Binary files /dev/null and b/frontend/src/pages/monitoring/term_font.png differ diff --git a/frontend/src/pages/playground/Bar.svelte b/frontend/src/pages/playground/Bar.svelte new file mode 100644 index 0000000..3f756a7 --- /dev/null +++ b/frontend/src/pages/playground/Bar.svelte @@ -0,0 +1,21 @@ + + +
+ +
diff --git a/frontend/src/pages/playground/Canvas.svelte b/frontend/src/pages/playground/Canvas.svelte new file mode 100644 index 0000000..748ed3f --- /dev/null +++ b/frontend/src/pages/playground/Canvas.svelte @@ -0,0 +1,24 @@ + + + +
color: {color}
+redraws: {redraws}
diff --git a/lua/.gitignore b/lua/.gitignore new file mode 100644 index 0000000..c585e19 --- /dev/null +++ b/lua/.gitignore @@ -0,0 +1 @@ +out \ No newline at end of file diff --git a/lua/cc.d.tl b/lua/cc.d.tl new file mode 100644 index 0000000..9072ae2 --- /dev/null +++ b/lua/cc.d.tl @@ -0,0 +1,20 @@ +global sleep: function(time: number) + +global write: function(text: string): number +--global print: function(...: any): number +global printError: function(...: any) + +--global type ReadCompletionFunction = function(partial: string): { string } | nil +--global read: function(replaceChar: string | nil, history: table | nil, completeFn: ReadCompletionFunction | nil, default: string | nil): string + +global _HOST: string +global _CC_DEFAULT_SETTINGS: string + +require("types/colors") +require("types/term") +require("types/parallel") +require("types/http") +require("types/shell") +require("types/window") +require("types/os") + diff --git a/lua/framebuffer.tl b/lua/framebuffer.tl new file mode 100644 index 0000000..94fc03d --- /dev/null +++ b/lua/framebuffer.tl @@ -0,0 +1,139 @@ +local record ScreenContent + x: integer + y: integer + width: integer + height: integer + blink: boolean + fg: integer + text: {string} + fg_color: {string} + bg_color: {string} + palette: {integer} +end + +local record Buffer + target: term.Redirect + serialize: function(): ScreenContent + is_dirty: function(): boolean + clear_dirty: function() +end + +local COLOR_LOOKUP : {number: integer} = { + [colors.white] = 0x0, + [colors.orange] = 0x1, + [colors.magenta] = 0x2, + [colors.lightBlue] = 0x3, + [colors.yellow] = 0x4, + [colors.lime] = 0x5, + [colors.pink] = 0x6, + [colors.gray] = 0x7, + [colors.lightGray] = 0x8, + [colors.cyan] = 0x9, + [colors.purple] = 0xA, + [colors.blue] = 0xB, + [colors.brown] = 0xC, + [colors.green] = 0xD, + [colors.red] = 0xE, + [colors.black] = 0xF, +} + +local function wrap(parent: term.Redirect): Buffer + local x, y = parent.getCursorPos() + local width, height = parent.getSize() + local blink = parent.getCursorBlink() + local fg = COLOR_LOOKUP[parent.getTextColor()] + local palette: {number} = {} + for c = 0, 15 do + palette[c+1] = colors.packRGB(parent.getPaletteColor(2^c)) + end + local dirty: boolean = false + + local win = window.create(parent, 1, 1, width, height) + + local overrides: table = {} + + overrides.setCursorPos = function(new_x: integer, new_y: integer) + win.setCursorPos(new_x, new_y) + x = new_x + y = new_y + end + + overrides.setCursorBlink = function(new_blink: boolean) + win.setCursorBlink(new_blink) + blink = new_blink + end + + overrides.setTextColor = function(new_color: number) + local r = { pcall(win.setTextColor, new_color) } + if not r[1] then + error((r[2] as string):sub(8)) + end + fg = COLOR_LOOKUP[new_color] + end + overrides.setTextColour = overrides.setTextColor + + overrides.setPaletteColor = function(color: number, r_rgb: number, g: number | nil, b: number | nil) + local r = { pcall(win.setPaletteColor, color, r_rgb, g, b) } + if not r[1] then + error((r[2] as string):sub(8)) + end + local index = COLOR_LOOKUP[color] + if g == nil then + palette[1 + index] = r_rgb + else + palette[1 + index] = colors.packRGB(r_rgb, g, b) + end + end + overrides.setPaletteColour = overrides.setPaletteColor + + local target = setmetatable(overrides, { + __index = function(_: table, k: any): any + dirty = true + return (win as table)[k] + end + }) as term.Redirect + + target.setTextColor(colors.white) + target.setBackgroundColor(colors.black) + target.setCursorPos(1,1) + target.clear() + + local buffer: Buffer = { + target = target + } + + buffer.serialize = function(): ScreenContent + local text: {string} = {} + local fg_color: {string} = {} + local bg_color: {string} = {} + for i = 1, height do + local t, f, b = win.getLine(i) + table.insert(text, t) + table.insert(fg_color, f) + table.insert(bg_color, b) + end + return { + x = x, + y = y, + width = width, + height = height, + blink = blink, + fg = fg, + text = text, + fg_color = fg_color, + bg_color = bg_color, + palette = palette + } + end + + buffer.is_dirty = function(): boolean + return dirty + end + + buffer.clear_dirty = function() dirty = false end + + return buffer + +end + +return { wrap = wrap, Buffer = Buffer, ScreenContent = ScreenContent } \ No newline at end of file diff --git a/lua/json.d.tl b/lua/json.d.tl new file mode 100644 index 0000000..4449415 --- /dev/null +++ b/lua/json.d.tl @@ -0,0 +1,6 @@ +local record Json + encode: function(value: any): string + decode: function(str: string): any +end + +return Json \ No newline at end of file diff --git a/lua/json.lua b/lua/json.lua new file mode 100644 index 0000000..711ef78 --- /dev/null +++ b/lua/json.lua @@ -0,0 +1,388 @@ +-- +-- json.lua +-- +-- Copyright (c) 2020 rxi +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy of +-- this software and associated documentation files (the "Software"), to deal in +-- the Software without restriction, including without limitation the rights to +-- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +-- of the Software, and to permit persons to whom the Software is furnished to do +-- so, subject to the following conditions: +-- +-- The above copyright notice and this permission notice shall be included in all +-- copies or substantial portions of the Software. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +-- SOFTWARE. +-- + +local json = { _version = "0.1.2" } + +------------------------------------------------------------------------------- +-- Encode +------------------------------------------------------------------------------- + +local encode + +local escape_char_map = { + [ "\\" ] = "\\", + [ "\"" ] = "\"", + [ "\b" ] = "b", + [ "\f" ] = "f", + [ "\n" ] = "n", + [ "\r" ] = "r", + [ "\t" ] = "t", +} + +local escape_char_map_inv = { [ "/" ] = "/" } +for k, v in pairs(escape_char_map) do + escape_char_map_inv[v] = k +end + + +local function escape_char(c) + return "\\" .. (escape_char_map[c] or string.format("u%04x", c:byte())) +end + + +local function encode_nil(val) + return "null" +end + + +local function encode_table(val, stack) + local res = {} + stack = stack or {} + + -- Circular reference? + if stack[val] then error("circular reference") end + + stack[val] = true + + if rawget(val, 1) ~= nil or next(val) == nil then + -- Treat as array -- check keys are valid and it is not sparse + local n = 0 + for k in pairs(val) do + if type(k) ~= "number" then + error("invalid table: mixed or invalid key types") + end + n = n + 1 + end + if n ~= #val then + error("invalid table: sparse array") + end + -- Encode + for i, v in ipairs(val) do + table.insert(res, encode(v, stack)) + end + stack[val] = nil + return "[" .. table.concat(res, ",") .. "]" + + else + -- Treat as an object + for k, v in pairs(val) do + if type(k) ~= "string" then + error("invalid table: mixed or invalid key types") + end + table.insert(res, encode(k, stack) .. ":" .. encode(v, stack)) + end + stack[val] = nil + return "{" .. table.concat(res, ",") .. "}" + end +end + + +local function encode_string(val) + return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"' +end + + +local function encode_number(val) + -- Check for NaN, -inf and inf + if val ~= val or val <= -math.huge or val >= math.huge then + error("unexpected number value '" .. tostring(val) .. "'") + end + return string.format("%.14g", val) +end + + +local type_func_map = { + [ "nil" ] = encode_nil, + [ "table" ] = encode_table, + [ "string" ] = encode_string, + [ "number" ] = encode_number, + [ "boolean" ] = tostring, +} + + +encode = function(val, stack) + local t = type(val) + local f = type_func_map[t] + if f then + return f(val, stack) + end + error("unexpected type '" .. t .. "'") +end + + +function json.encode(val) + return ( encode(val) ) +end + + +------------------------------------------------------------------------------- +-- Decode +------------------------------------------------------------------------------- + +local parse + +local function create_set(...) + local res = {} + for i = 1, select("#", ...) do + res[ select(i, ...) ] = true + end + return res +end + +local space_chars = create_set(" ", "\t", "\r", "\n") +local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",") +local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u") +local literals = create_set("true", "false", "null") + +local literal_map = { + [ "true" ] = true, + [ "false" ] = false, + [ "null" ] = nil, +} + + +local function next_char(str, idx, set, negate) + for i = idx, #str do + if set[str:sub(i, i)] ~= negate then + return i + end + end + return #str + 1 +end + + +local function decode_error(str, idx, msg) + local line_count = 1 + local col_count = 1 + for i = 1, idx - 1 do + col_count = col_count + 1 + if str:sub(i, i) == "\n" then + line_count = line_count + 1 + col_count = 1 + end + end + error( string.format("%s at line %d col %d", msg, line_count, col_count) ) +end + + +local function codepoint_to_utf8(n) + -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa + local f = math.floor + if n <= 0x7f then + return string.char(n) + elseif n <= 0x7ff then + return string.char(f(n / 64) + 192, n % 64 + 128) + elseif n <= 0xffff then + return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128) + elseif n <= 0x10ffff then + return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128, + f(n % 4096 / 64) + 128, n % 64 + 128) + end + error( string.format("invalid unicode codepoint '%x'", n) ) +end + + +local function parse_unicode_escape(s) + local n1 = tonumber( s:sub(1, 4), 16 ) + local n2 = tonumber( s:sub(7, 10), 16 ) + -- Surrogate pair? + if n2 then + return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000) + else + return codepoint_to_utf8(n1) + end +end + + +local function parse_string(str, i) + local res = "" + local j = i + 1 + local k = j + + while j <= #str do + local x = str:byte(j) + + if x < 32 then + decode_error(str, j, "control character in string") + + elseif x == 92 then -- `\`: Escape + res = res .. str:sub(k, j - 1) + j = j + 1 + local c = str:sub(j, j) + if c == "u" then + local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1) + or str:match("^%x%x%x%x", j + 1) + or decode_error(str, j - 1, "invalid unicode escape in string") + res = res .. parse_unicode_escape(hex) + j = j + #hex + else + if not escape_chars[c] then + decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string") + end + res = res .. escape_char_map_inv[c] + end + k = j + 1 + + elseif x == 34 then -- `"`: End of string + res = res .. str:sub(k, j - 1) + return res, j + 1 + end + + j = j + 1 + end + + decode_error(str, i, "expected closing quote for string") +end + + +local function parse_number(str, i) + local x = next_char(str, i, delim_chars) + local s = str:sub(i, x - 1) + local n = tonumber(s) + if not n then + decode_error(str, i, "invalid number '" .. s .. "'") + end + return n, x +end + + +local function parse_literal(str, i) + local x = next_char(str, i, delim_chars) + local word = str:sub(i, x - 1) + if not literals[word] then + decode_error(str, i, "invalid literal '" .. word .. "'") + end + return literal_map[word], x +end + + +local function parse_array(str, i) + local res = {} + local n = 1 + i = i + 1 + while 1 do + local x + i = next_char(str, i, space_chars, true) + -- Empty / end of array? + if str:sub(i, i) == "]" then + i = i + 1 + break + end + -- Read token + x, i = parse(str, i) + res[n] = x + n = n + 1 + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "]" then break end + if chr ~= "," then decode_error(str, i, "expected ']' or ','") end + end + return res, i +end + + +local function parse_object(str, i) + local res = {} + i = i + 1 + while 1 do + local key, val + i = next_char(str, i, space_chars, true) + -- Empty / end of object? + if str:sub(i, i) == "}" then + i = i + 1 + break + end + -- Read key + if str:sub(i, i) ~= '"' then + decode_error(str, i, "expected string for key") + end + key, i = parse(str, i) + -- Read ':' delimiter + i = next_char(str, i, space_chars, true) + if str:sub(i, i) ~= ":" then + decode_error(str, i, "expected ':' after key") + end + i = next_char(str, i + 1, space_chars, true) + -- Read value + val, i = parse(str, i) + -- Set + res[key] = val + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "}" then break end + if chr ~= "," then decode_error(str, i, "expected '}' or ','") end + end + return res, i +end + + +local char_func_map = { + [ '"' ] = parse_string, + [ "0" ] = parse_number, + [ "1" ] = parse_number, + [ "2" ] = parse_number, + [ "3" ] = parse_number, + [ "4" ] = parse_number, + [ "5" ] = parse_number, + [ "6" ] = parse_number, + [ "7" ] = parse_number, + [ "8" ] = parse_number, + [ "9" ] = parse_number, + [ "-" ] = parse_number, + [ "t" ] = parse_literal, + [ "f" ] = parse_literal, + [ "n" ] = parse_literal, + [ "[" ] = parse_array, + [ "{" ] = parse_object, +} + + +parse = function(str, idx) + local chr = str:sub(idx, idx) + local f = char_func_map[chr] + if f then + return f(str, idx) + end + decode_error(str, idx, "unexpected character '" .. chr .. "'") +end + + +function json.decode(str) + if type(str) ~= "string" then + error("expected argument of type string, got " .. type(str)) + end + local res, idx = parse(str, next_char(str, 1, space_chars, true)) + idx = next_char(str, idx, space_chars, true) + if idx <= #str then + decode_error(str, idx, "trailing garbage") + end + return res +end + + +return json diff --git a/lua/justfile b/lua/justfile new file mode 100644 index 0000000..cfe7f6d --- /dev/null +++ b/lua/justfile @@ -0,0 +1,30 @@ +default: + @just --list + +lua_files := `find . -type f -name "*.lua" ! -path './out/*' ! -name tlconfig.lua -printf "%p "` +teal_files := `find . -type f -name "*.tl" ! -name '*.d.tl' -printf "%p "` + +build: + mkdir -p out + for file in {{lua_files}}; do \ + cp $file out; \ + done + for file in {{teal_files}}; do \ + tl gen $file; \ + mv ${file%.tl}.lua out; \ + done + +alias b := build + +clean: + rm -r out + +alias c := clean + +watch: + while sleep 0.1; do \ + find . -type f ! -path './out/*' | entr -d just build; \ + [ $? -eq 0 ] && exit 0; \ + done + +alias w := watch \ No newline at end of file diff --git a/lua/main.tl b/lua/main.tl new file mode 100644 index 0000000..04fd9cb --- /dev/null +++ b/lua/main.tl @@ -0,0 +1,164 @@ +local json = require("json") +local Framebuffer = require("framebuffer") +local Ringbuffer = require("ringbuffer") +local Socket = require("socket") +local UUID = "8b9faf9f-9470-4a50-b405-0af5f0152550" +local ENDPOINT = "ws://localhost:8000/ipmi/computer/" .. UUID .. "/ws" + +print("[MAIN] Init") + +local socket = Socket.new(ENDPOINT) + +-- Set up framebuffer capture and statusline + +print("[MAIN] Setup framebuffer") + +local prev_term = term.current() +local orig_native = term.native +local buffer = Framebuffer.wrap(orig_native()) +term.native = function(): term.Redirect + return buffer.target +end + +local width, height = term.getSize() +local top_line = window.create(buffer.target, 1, 1, width, 1) +local main_view = window.create(buffer.target, 1, 2, width, height - 1) +term.redirect(main_view as term.Redirect) + +local function set_bar(text: string, fg: string | nil, bg: string | nil) + fg = fg or ("9"):rep(text:len()) + bg = bg or ("f"):rep(text:len()) + top_line.clear() + top_line.setCursorPos(1,1) + top_line.blit(text, fg, bg) + main_view.restoreCursor() +end + +-- Create tasks + +local bar_codes: { Socket.State: {string} } = { + ["reset"] = {"[WS] RST", "78870111"}, + ["error"] = {"[WS] ERR", "78870EEE"}, + ["ok"] = {"[WS] OK\x03", "78870DD5"}, + ["viewer_connected"] = {"[WS] CON", "78870999"}, +} + +socket:on_state_change(function(new_state: Socket.State) + set_bar(table.unpack(bar_codes[new_state])) +end) + +local ws_task = coroutine.create(function() + while true do + if socket:is_bad_state() then + socket:reconnect() + end + sleep(1) + end +end) + +local report_task = coroutine.create(function() + local last_report = -1.0 + while true do + local now = os.clock() + local interval = (socket.state == "viewer_connected") and 0.05 or 1 + if now - last_report >= interval then + local message = json.encode({ + screen = buffer.serialize() + }) + socket:send(message) + last_report = now + end + sleep(0) -- until next gametick + end +end) + +-- basically parallel.waitForAny + +local record Task + coro: thread + filter: string | nil +end + +local background_tasks: {Task} = { + {coro = ws_task}, + {coro = report_task}, +} + +local shell_task: Task = { + coro = coroutine.create(function() shell.run("shell") end) +} + +local function handle_event(e: table, pid: integer) + if e[1] == "terminate" then return end + + local task = background_tasks[pid] + if task.filter == nil or task.filter == e[1] then + local ok, param = coroutine.resume(task.coro, table.unpack(e as {any})) + if not ok then + term.native = orig_native + term.redirect(term.native()) + term.clear() + term.setCursorPos(1,1) + print(("OMEGABIG OOF @ PID %d"):format(pid)) + error(param, 0) + else + task.filter = param as string + end + end +end + +local event_queue: Ringbuffer.Ringbuffer = Ringbuffer.new(64) +event_queue:push({n = 0}) + +local shell_deaths: {any} = {} + +local shell_running = true +while shell_running do + local e: table + + if not event_queue:is_empty() then + e = event_queue:pop() as table + else + e = table.pack(os.pullEventRaw()) + end + + if e[1] == "websocket_message" and e[2] == ENDPOINT then + local payload = json.decode(e[3] as string) as table + if payload["type"] == "push_event" then + event_queue:push(payload["event"] as table) + elseif payload["type"] == "viewer_connect" then + socket:signal_viewer_connect(true) + elseif payload["type"] == "viewer_disconnect" then + socket:signal_viewer_connect(false) + end + else + for pid = 1, #background_tasks do + handle_event(e, pid) + end + + if shell_task.filter == nil or shell_task.filter == e[1] or e[1] == "terminate" then + local ok, param = coroutine.resume(shell_task.coro, table.unpack(e as {any})) + if not ok then + -- shell died i guess? + table.insert(shell_deaths, param) + else + shell_task.filter = param as string + end + end + + if coroutine.status(shell_task.coro) == "dead" then + shell_running = false + end + end +end + +socket:close() + +term.native = orig_native +term.redirect(prev_term) +term.clear() +term.setCursorPos(1,1) + +for i = 1, #shell_deaths do + print(shell_deaths[i]) +end \ No newline at end of file diff --git a/lua/ringbuffer.tl b/lua/ringbuffer.tl new file mode 100644 index 0000000..fb8c3e1 --- /dev/null +++ b/lua/ringbuffer.tl @@ -0,0 +1,42 @@ +local record Ringbuffer + {T} + push: function(self: Ringbuffer, el: T): boolean + pop: function(self: Ringbuffer): T | nil + is_empty: function(self: Ringbuffer): boolean + + head: integer + n: integer + size: integer +end + +local impl: table = {} + +impl.push = function(self: Ringbuffer, el: T): boolean + if self.n == self.size then return false end + -- items are at head + 0, head + 1, ..., head + (n-1) + local tail = (self.head + self.n) % self.size + self[1 + tail] = el + self.n = self.n + 1 + return true +end + +impl.pop = function(self: Ringbuffer): T | nil + if self.n == 0 then return nil end + local res = self[1 + self.head] + self.head = (self.head + 1) % self.size + self.n = self.n - 1 + return res +end + +impl.is_empty = function(self: Ringbuffer): boolean + return self.n == 0 +end + +local function new(size: integer): Ringbuffer + return setmetatable({ head = 0, n = 0, size = size }, { __index = impl }) +end + +return { + new = new, + Ringbuffer = Ringbuffer +} \ No newline at end of file diff --git a/lua/socket.tl b/lua/socket.tl new file mode 100644 index 0000000..7ecc127 --- /dev/null +++ b/lua/socket.tl @@ -0,0 +1,92 @@ +local enum State + "reset" + "error" + "ok" + "viewer_connected" +end + +local BAD_STATES : {State: boolean} = { + ["reset"] = true, + ["error"] = true, +} + +local type StateCallback = function(new_state: State) + +local record Socket + state: State + is_bad_state: function(self: Socket): boolean + _set_state: function(self: Socket, state: State) + on_state_change: function(self: Socket, cb: StateCallback) + send: function(self: Socket, message: string) + reconnect: function(self: Socket) + close: function(self: Socket) + signal_viewer_connect: function(self: Socket, connected: boolean) + _endpoint: string + _callback: StateCallback + _ws: http.Websocket +end + +local impl: table = {} + +impl.is_bad_state = function(self: Socket): boolean + return BAD_STATES[self.state] ~= nil +end + +impl._set_state = function(self: Socket, state: State) + self.state = state + self._callback(state) +end + +impl.on_state_change = function(self: Socket, cb: StateCallback) + self._callback = cb +end + +impl.send = function(self: Socket, message: string) + -- "message" needs to be valid JSON + -- otherwise the server will not accept it + + if self:is_bad_state() then return end + + local r = { pcall(self._ws.send, message) } + + if r[1] == false then + if (r[2] as string):sub(-11) == "closed file" then + self:_set_state("reset") + elseif (r[2] as string):sub(-9) == "too large" then + -- TODO handle + -- the connection stays open though + end + end +end + +impl.reconnect = function(self: Socket) + local r = http.websocket(self._endpoint) + if r ~= false then + self._ws = r as http.Websocket + self:_set_state("ok") + else + self:_set_state("error") + end +end + +impl.close = function(self: Socket) + if self:is_bad_state() then return end + self._ws.close() +end + +impl.signal_viewer_connect = function(self: Socket, connected: boolean) + if self:is_bad_state() then return end --how? + local new_state: State = connected and "viewer_connected" or "ok" + self:_set_state(new_state) +end + +local function new(endpoint: string): Socket + return setmetatable({ + state = "reset", + _endpoint = endpoint, + _callback = function(_: State) end, + _ws = nil, + }, { __index = impl }) +end + +return { new = new, State = State, StateCallback = StateCallback, Socket = Socket } \ No newline at end of file diff --git a/lua/tlconfig.lua b/lua/tlconfig.lua new file mode 100644 index 0000000..52911f6 --- /dev/null +++ b/lua/tlconfig.lua @@ -0,0 +1,4 @@ +return { + global_env_def = "cc", + gen_target = "5.1", +} \ No newline at end of file diff --git a/lua/types/colors.d.tl b/lua/types/colors.d.tl new file mode 100644 index 0000000..a6e122d --- /dev/null +++ b/lua/types/colors.d.tl @@ -0,0 +1,51 @@ +global record colors + white: number + orange: number + magenta: number + lightBlue: number + yellow: number + lime: number + pink: number + gray: number + lightGray: number + cyan: number + purple: number + blue: number + brown: number + green: number + red: number + black: number + + combine: function(...: number): number + subtract: function(colors: number, ...: number): number + test: function(colors: number, color: number): boolean + packRGB: function(r: number, g: number, b: number): number + unpackRGB: function(rgb: number): number, number, number + toBlit: function(color: number): string +end + +global record colours + white: number + orange: number + magenta: number + lightBlue: number + yellow: number + lime: number + pink: number + grey: number + lightGrey: number + cyan: number + purple: number + blue: number + brown: number + green: number + red: number + black: number + + combine: function(...: number): number + subtract: function(colors: number, ...: number): number + test: function(colors: number, color: number): boolean + packRGB: function(r: number, g: number, b: number): number + unpackRGB: function(rgb: number): number, number, number + toBlit: function(color: number): string +end \ No newline at end of file diff --git a/lua/types/http.d.tl b/lua/types/http.d.tl new file mode 100644 index 0000000..26677bb --- /dev/null +++ b/lua/types/http.d.tl @@ -0,0 +1,70 @@ +global record http + record Response + getResponseCode: function(): integer, string + getResponseHeaders: function(): {string: string} + read: function(count: integer | nil): integer, string | nil + readAll: function(): string | nil + readLine: function(withTrailing: boolean | nil): string | nil + seek: function( + whence: string | nil, + offset: integer | nil + ): integer | nil, nil | string + close: function() + end + + record _RequestParams + url: string + body: string | nil + headers: {string: string} | nil + binary: boolean | nil + method: string | nil + redirect: boolean | nil + end + + request: function( + url_or_params: string | _RequestParams, + body: string | nil, + headers: {string: string} | nil, + binary: boolean | nil + ) + + record _GetParams + url: string + headers: {string: string} | nil + binary: boolean | nil + method: string | nil + redirect: boolean | nil + end + + get: function( + url_or_params: string | _GetParams, + headers: {string: string} | nil, + binary: boolean | nil + ): Response | nil, nil | string, nil | Response + + post: function( + url_or_params: string | _RequestParams, + body: string | nil, + headers: {string: string} | nil, + binary: boolean | nil + ): Response | nil, nil | string, nil | Response + + checkURLAsync: function(url: string): boolean, string | nil + checkURL: function(url: string): boolean, string | nil + + record Websocket + receive: function(timeout: number | nil): string | nil, nil | boolean + send: function(message: any, binary: boolean | nil) + close: function() + end + + websocket: function( + url: string, + headers: {string: string} | nil + ): Websocket | boolean, nil | string + + websocketAsync: function( + url: string, + headers: {string: string} | nil + ) +end \ No newline at end of file diff --git a/lua/types/os.d.tl b/lua/types/os.d.tl new file mode 100644 index 0000000..8d4e75f --- /dev/null +++ b/lua/types/os.d.tl @@ -0,0 +1,27 @@ +global record os + pullEvent: function(filter: string | nil): string, any... + pullEventRaw: function(filter: string | nil): string, any... + sleep: function(time: number) + version: function(): string + run: function(env: table, path: string, ...: any): boolean + queueEvent: function(name: string, ...: any) + startTimer: function(time: number): integer + cancelTimer: function(token: integer) + setAlarm: function(time: number): integer + cancelAlarm: function(token: integer) + shutdown: function() + reboot: function() + getComputerID: function(): integer + computerID: function(): integer + getComputerLabel: function(): string + computerLabel: function(): string + setComputerLabel: function(label: string | nil) + clock: function(): number + time: function(locale: string | nil): number + time: function(locale: table): integer + day: function(args: string | nil): integer + epoch: function(args: string | nil): integer + date: function(): string + date: function(format: string): string | table + date: function(format: string, time: number): string | table +end \ No newline at end of file diff --git a/lua/types/parallel.d.tl b/lua/types/parallel.d.tl new file mode 100644 index 0000000..fbb3a80 --- /dev/null +++ b/lua/types/parallel.d.tl @@ -0,0 +1,4 @@ +global record parallel + waitForAny: function(...: function) + waitForAll: function(...: function) +end \ No newline at end of file diff --git a/lua/types/shell.d.tl b/lua/types/shell.d.tl new file mode 100644 index 0000000..51375ab --- /dev/null +++ b/lua/types/shell.d.tl @@ -0,0 +1,33 @@ +global record shell + execute: function(command: string, ...: string): boolean + run: function(...: string): boolean + exit: function() + dir: function(): string + setDir: function(dir: string) + path: function(): string + setPath: function(path: string) + resolve: function(path: string): string + resolveProgram: function(command: string): string | nil + programs: function(include_hidden: boolean | nil): {string} + complete: function(sLine: string): {string} | nil + completeProgram: function(program: string): {string} + + type _CompletionFunction = function( + shell: table, + index: integer, + argument: string, + previous: {string} + ): {string} | nil + setCompletionFunction: function(program: string, complete: _CompletionFunction) + + record _CompletionInfo + fnComplete: function + end + getCompletionInfo: function(): {string: _CompletionInfo} + getRunningProgram: function(): string + setAlias: function(command: string, program: string) + clearAlias: function(command: string) + aliases: function(): {string:string} + openTab: function(...: string): integer + switchTab: function(id: integer) +end \ No newline at end of file diff --git a/lua/types/term.d.tl b/lua/types/term.d.tl new file mode 100644 index 0000000..0f790a0 --- /dev/null +++ b/lua/types/term.d.tl @@ -0,0 +1,65 @@ +global record term + + record Redirect + write: function(text: string) + blit: function(text: string, textColor: string, backgroundColor: string) + scroll: function(y: integer) + clear: function() + clearLine: function() + + getCursorPos: function(): integer, integer + setCursorPos: function(x: integer, y: integer) + getCursorBlink: function(): boolean + setCursorBlink: function(blink: boolean) + getSize: function(): integer, integer + + isColor: function(): boolean + isColour: function(): boolean + getTextColor: function(): number + getTextColour: function(): number + setTextColor: function(color: number) + setTextColour: function(colour: number) + getBackgroundColor: function(): number + getBackgroundColour: function(): number + setBackgroundColor: function(color: number) + setBackgroundColour: function(colour: number) + getPaletteColor: function(color: number): number, number, number + getPaletteColour: function(colour: number): number, number, number + setPaletteColour: function(color: number, r_rgb: number, g: number | nil, b: number | nil) + setPaletteColor: function(colour: number, r_rgb: number, g: number | nil, b: number | nil) + end + + write: function(text: string) + blit: function(text: string, textColor: string, backgroundColor: string) + scroll: function(y: integer) + clear: function() + clearLine: function() + + getCursorPos: function(): integer, integer + setCursorPos: function(x: integer, y: integer) + getCursorBlink: function(): boolean + setCursorBlink: function(blink: boolean) + getSize: function(): integer, integer + + isColor: function(): boolean + isColour: function(): boolean + getTextColor: function(): number + getTextColour: function(): number + setTextColor: function(color: number) + setTextColour: function(colour: number) + getBackgroundColor: function(): number + getBackgroundColour: function(): number + setBackgroundColor: function(color: number) + setBackgroundColour: function(colour: number) + getPaletteColor: function(color: number): number, number, number + getPaletteColour: function(colour: number): number, number, number + setPaletteColour: function(color: number, r_rgb: number, g: number | nil, b: number | nil) + setPaletteColor: function(colour: number, r_rgb: number, g: number | nil, b: number | nil) + + nativePaletteColor: function(color: number): number, number, number + nativePaletteColour: function(colour: number): number, number, number + + redirect: function(target: Redirect): Redirect + current: function(): Redirect + native: function(): Redirect +end \ No newline at end of file diff --git a/lua/types/window.d.tl b/lua/types/window.d.tl new file mode 100644 index 0000000..91730d5 --- /dev/null +++ b/lua/types/window.d.tl @@ -0,0 +1,40 @@ +global record window + record Window + write: function(text: string) + blit: function(text: string, textColor: string, backgroundColor: string) + scroll: function(y: integer) + clear: function() + clearLine: function() + + getCursorPos: function(): integer, integer + setCursorPos: function(x: integer, y: integer) + getCursorBlink: function(): boolean + setCursorBlink: function(blink: boolean) + getSize: function(): integer, integer + + isColor: function(): boolean + isColour: function(): boolean + getTextColor: function(): number + getTextColour: function(): number + setTextColor: function(color: number) + setTextColour: function(colour: number) + getBackgroundColor: function(): number + getBackgroundColour: function(): number + setBackgroundColor: function(color: number) + setBackgroundColour: function(colour: number) + getPaletteColor: function(color: number): number, number, number + getPaletteColour: function(colour: number): number, number, number + setPaletteColour: function(color: number, r_rgb: number, g: number | nil, b: number | nil) + setPaletteColor: function(colour: number, r_rgb: number, g: number | nil, b: number | nil) + + getLine: function(y: integer): string, string, string + setVisible: function(visible: boolean) + isVisible: function(): boolean + redraw: function() + restoreCursor: function() + getPosition: function(): integer, integer + reposition: function(new_x: integer, new_y: integer, new_width: integer | nil, new_height: integer | nil, new_parent: term.Redirect | nil) + end + + create: function(parent: term.Redirect, x: integer, y: integer, width: integer, height: integer, startVisible: boolean | nil): Window +end \ No newline at end of file diff --git a/server/.gitignore b/server/.gitignore index 20471dc..4999ca2 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -2,3 +2,4 @@ secret.env unmined-out **/__pycache__ +db.env diff --git a/server/README.md b/server/README.md index 97dd0c0..feba9c3 100644 --- a/server/README.md +++ b/server/README.md @@ -13,7 +13,7 @@ Requires [poetry](https://archlinux.org/packages/community/any/python-poetry/) $ cd ../frontend $ npm run dev # in a different terminal -$ DEV_MODE=1 poetry run uvicorn server:app --reload +$ DEV_MODE=1 poetry run uvicorn server:app --reload --env-file db.env ``` ### prod @@ -21,5 +21,5 @@ $ DEV_MODE=1 poetry run uvicorn server:app --reload $ pushd ../frontend $ npm run build $ popd -$ poetry run uvicorn server:app -``` \ No newline at end of file +$ poetry run uvicorn server:app --env-file db.env +``` diff --git a/server/db.env.example b/server/db.env.example new file mode 100644 index 0000000..67fad12 --- /dev/null +++ b/server/db.env.example @@ -0,0 +1 @@ +MONGO_URI="mongodb://user:pass@localhost:27017/" diff --git a/server/dummy-client.py b/server/dummy-client.py new file mode 100644 index 0000000..d9cc40b --- /dev/null +++ b/server/dummy-client.py @@ -0,0 +1,96 @@ +import asyncio +import json +import websockets + +ENDPOINT = "ws://localhost:8000/ipmi/computer/8b9faf9f-9470-4a50-b405-0af5f0152550/ws" + + +def gen_payload(tick: int): + return { + "x": 3, + "y": 4, + "width": 39, + "height": 13, + "blink": True, + "fg": 0, + "text": [ + "[WS] OK\u0003 ", + "FG 0123456789ABCDEF ", + "BG 0123456789ABCDEF ", + " ", + f"Tick: {tick:8d} ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ], + "fg_color": [ + "78870dd50000000000000000000000000000000", + "0000123456789abcdef00000000000000000000", + "000f00000000000000000000000000000000000", + "440000000000000000000000000000000000000", + "000000000000000000000000000000000000000", + "000000000000000000000000000000000000000", + "000000000000000000000000000000000000000", + "000000000000000000000000000000000000000", + "000000000000000000000000000000000000000", + "000000000000000000000000000000000000000", + "000000000000000000000000000000000000000", + "000000000000000000000000000000000000000", + "000000000000000000000000000000000000000", + ], + "bg_color": [ + "fffffffffffffffffffffffffffffffffffffff", + "ffffffffffffffffff0ffffffffffffffffffff", + "fff0123456789abcdefffffffffffffffffffff", + "fffffffffffffffffffffffffffffffffffffff", + "fffffffffffffffffffffffffffffffffffffff", + "fffffffffffffffffffffffffffffffffffffff", + "fffffffffffffffffffffffffffffffffffffff", + "fffffffffffffffffffffffffffffffffffffff", + "fffffffffffffffffffffffffffffffffffffff", + "fffffffffffffffffffffffffffffffffffffff", + "fffffffffffffffffffffffffffffffffffffff", + "fffffffffffffffffffffffffffffffffffffff", + "fffffffffffffffffffffffffffffffffffffff", + ], + "palette": [ + 15790320, + 15905331, + 15040472, + 10072818, + 14605932, + 8375321, + 15905484, + 5000268, + 10066329, + 5020082, + 11691749, + 3368652, + 8349260, + 5744206, + 13388876, + 1118481, + ], + } + + +async def main(): + tick = 0 + + async with websockets.connect(ENDPOINT) as socket: + while True: + await socket.send(json.dumps({"screen": gen_payload(tick)})) + await asyncio.sleep(1 / 20) + tick += 1 + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("this handler gets it") diff --git a/server/lua b/server/lua new file mode 120000 index 0000000..df6ec1a --- /dev/null +++ b/server/lua @@ -0,0 +1 @@ +../lua/out \ No newline at end of file diff --git a/server/poetry.lock b/server/poetry.lock index 4206215..d728a8f 100644 --- a/server/poetry.lock +++ b/server/poetry.lock @@ -196,6 +196,26 @@ category = "main" optional = false python-versions = ">=3.7" +[[package]] +name = "motor" +version = "3.0.0" +description = "Non-blocking MongoDB driver for Tornado or asyncio" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +pymongo = ">=4.1,<5" + +[package.extras] +aws = ["pymongo[aws] (>=4.1,<5)"] +encryption = ["pymongo[encryption] (>=4.1,<5)"] +gssapi = ["pymongo[gssapi] (>=4.1,<5)"] +ocsp = ["pymongo[ocsp] (>=4.1,<5)"] +snappy = ["pymongo[snappy] (>=4.1,<5)"] +srv = ["pymongo[srv] (>=4.1,<5)"] +zstd = ["pymongo[zstd] (>=4.1,<5)"] + [[package]] name = "pycparser" version = "2.21" @@ -219,6 +239,23 @@ typing-extensions = ">=4.1.0" dotenv = ["python-dotenv (>=0.10.4)"] email = ["email-validator (>=1.0.3)"] +[[package]] +name = "pymongo" +version = "4.2.0" +description = "Python driver for MongoDB " +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +aws = ["pymongo-auth-aws (<2.0.0)"] +encryption = ["pymongocrypt (>=1.3.0,<2.0.0)"] +gssapi = ["pykerberos"] +ocsp = ["certifi", "pyopenssl (>=17.2.0)", "requests (<3.0.0)", "service-identity (>=18.1.0)"] +snappy = ["python-snappy"] +srv = ["dnspython (>=1.16.0,<3.0.0)"] +zstd = ["zstandard"] + [[package]] name = "python-dotenv" version = "0.21.0" @@ -339,7 +376,7 @@ python-versions = ">=3.7" [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "302816b99259a9b9b37589635b575875a945d1d5ebead0f9ea7795837cd62e71" +content-hash = "d47fdb1bb98fcd0b8e33a25f1e2e78d2ebfc2a568ad7b8b08a553987e46a2fd5" [metadata.files] anyio = [ @@ -558,6 +595,10 @@ MarkupSafe = [ {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, ] +motor = [ + {file = "motor-3.0.0-py3-none-any.whl", hash = "sha256:b076de44970f518177f0eeeda8b183f52eafa557775bfe3294e93bda18867a71"}, + {file = "motor-3.0.0.tar.gz", hash = "sha256:3e36d29406c151b61342e6a8fa5e90c00c4723b76e30f11276a4373ea2064b7d"}, +] pycparser = [ {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, @@ -600,6 +641,75 @@ pydantic = [ {file = "pydantic-1.10.1-py3-none-any.whl", hash = "sha256:f8b10e59c035ff3dcc9791619d6e6c5141e0fa5cbe264e19e267b8d523b210bf"}, {file = "pydantic-1.10.1.tar.gz", hash = "sha256:d41bb80347a8a2d51fbd6f1748b42aca14541315878447ba159617544712f770"}, ] +pymongo = [ + {file = "pymongo-4.2.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:b9e4981a65f8500a3a46bb3a1e81b9feb45cf0b2115ad9c4f8d517326d026940"}, + {file = "pymongo-4.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1c81414b706627f15e921e29ae2403aab52e33e36ed92ed989c602888d7c3b90"}, + {file = "pymongo-4.2.0-cp310-cp310-manylinux1_i686.whl", hash = "sha256:c549bb519456ee230e92f415c5b4d962094caac0fdbcc4ed22b576f66169764e"}, + {file = "pymongo-4.2.0-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:70216ec4c248213ae95ea499b6314c385ce01a5946c448fb22f6c8395806e740"}, + {file = "pymongo-4.2.0-cp310-cp310-manylinux2014_i686.whl", hash = "sha256:8a86e8c2ac2ec87141e1c6cb00bdb18a4560f06e5f96769abcd1dda24dc0e764"}, + {file = "pymongo-4.2.0-cp310-cp310-manylinux2014_ppc64le.whl", hash = "sha256:314b556afd72eb21a6a10bd1f45ef252509f014f80207db59c97372103c88237"}, + {file = "pymongo-4.2.0-cp310-cp310-manylinux2014_s390x.whl", hash = "sha256:902e2c9030cb042c49750bc70d72d830d42c64ea0df5ff8630c171e065c93dd7"}, + {file = "pymongo-4.2.0-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:c69ef5906dcd6ec565d4d887ba97ceb2a84f3b614307ee3b4780cb1ea40b1867"}, + {file = "pymongo-4.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07564178ecc203a84f63e72972691af6c0c82d2dc0c9da66ba711695276089ba"}, + {file = "pymongo-4.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f47d5f10922cf7f7dfcd1406bd0926cef6d866a75953c3745502dffd7ac197dd"}, + {file = "pymongo-4.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4cadaaa5c19ad23fc84559e90284f2eb003c36958ebb2c06f286b678f441285f"}, + {file = "pymongo-4.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d94f535df9f539615bc3dbbef185ded3b609373bb44ca1afffcabac70202678a"}, + {file = "pymongo-4.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:147a23cd96feb67606ac957744d8d25b013426cdc3c7164a4f99bd8253f649e3"}, + {file = "pymongo-4.2.0-cp310-cp310-win32.whl", hash = "sha256:ecdcb0d4e9b08b739035f57a09330efc6f464bd7f942b63897395d996ca6ebd5"}, + {file = "pymongo-4.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:8c223aea52c359cc8fdee5bd3475532590755c269ec4d4fe581acd47a44e9952"}, + {file = "pymongo-4.2.0-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:fe0820d169635e41c14a5d21514282e0b93347878666ec9d5d3bf0eed0649948"}, + {file = "pymongo-4.2.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:e39cacee70a98758f9b2da53ee175378f07c60113b1fa4fae40cbaee5583181e"}, + {file = "pymongo-4.2.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:701d331060dae72bf3ebdb82924405d14136a69282ccb00c89fc69dee21340b4"}, + {file = "pymongo-4.2.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:e08fe1731f5429435b8dea1db9663f9ed1812915ff803fc9991c7c4841ed62ad"}, + {file = "pymongo-4.2.0-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:60c470a58c5b62b1b12a5f5458f8e2f2f67b94e198d03dc5352f854d9230c394"}, + {file = "pymongo-4.2.0-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:b211e161b6cc2790e0d640ad38e0429d06c944e5da23410f4dc61809dba25095"}, + {file = "pymongo-4.2.0-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:ed90a9de4431cbfb2f3b2ef0c5fd356e61c85117b2be4db3eae28cb409f6e2d5"}, + {file = "pymongo-4.2.0-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:68e1e49a5675748233f7b05330f092582cd52f2850b4244939fd75ba640593ed"}, + {file = "pymongo-4.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:764fc15418d94bce5c2f8ebdbf66544f96f42efb1364b61e715e5b33281b388d"}, + {file = "pymongo-4.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e64442aba81ed4df1ca494b87bf818569a1280acaa73071c68014f7a884e83f1"}, + {file = "pymongo-4.2.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:83168126ae2457d1a19b2af665cafa7ef78c2dcff192d7d7b5dad6b36c73ae24"}, + {file = "pymongo-4.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69d0180bca594e81cdb4a2af328bdb4046f59e10aaeef7619496fe64f2ec918c"}, + {file = "pymongo-4.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80cbf0b043061451660099fff9001a7faacb2c9c983842b4819526e2f944dc6c"}, + {file = "pymongo-4.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e1b8f5e2f9637492b0da4d51f78ecb17786e61d6c461ead8542c944750faf4f9"}, + {file = "pymongo-4.2.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:1a957cdc2b26eeed4d8f1889a40c6023dd1bd94672dd0f5ce327314f2caaefd4"}, + {file = "pymongo-4.2.0-cp37-cp37m-win32.whl", hash = "sha256:6bd5888997ea3eae9830c6cc7964b61dcfbc50eb3a5a6ce56ad5f86d5579b11c"}, + {file = "pymongo-4.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:dc24737d24ce0de762bee9c2a884639819485f679bbac8ab5be9c161ef6f9b2c"}, + {file = "pymongo-4.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:193cc97d44b1e6d2253ea94e30c6f94f994efb7166e2452af4df55825266e88b"}, + {file = "pymongo-4.2.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e152c26ffc30331e9d57591fc4c05453c209aa20ba299d1deb7173f7d1958c22"}, + {file = "pymongo-4.2.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8a9bc4dcfc2bda69ee88cdb7a89b03f2b8eca668519b704384a264dea2db4209"}, + {file = "pymongo-4.2.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8cbb868e88c4eee1c53364bb343d226a3c0e959e791e6828030cb78f46cfcbe3"}, + {file = "pymongo-4.2.0-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:2bfe6b59f431f40fa545547616f4acf0c0c4b64518b1f951083e3bad06eb368b"}, + {file = "pymongo-4.2.0-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:ff66014687598823b6b23751884b4aa67eb934445406d95894dfc60cb7bfcc18"}, + {file = "pymongo-4.2.0-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:31c50da4a080166bc29403aa91f4c76e0889b4f24928d1b60508a37c1bf87f9a"}, + {file = "pymongo-4.2.0-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:ccfdc7722df445c49dc6b5d514c3544cad99b53189165f7546793933050ac7fb"}, + {file = "pymongo-4.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc7ebc37b03956a070260665079665eae69e5e96007694214f3a2107af96816a"}, + {file = "pymongo-4.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c8b4a782aac43948308087b962c9ecb030ba98886ce6dee3ad7aafe8c5e1ce80"}, + {file = "pymongo-4.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1c23527f8e13f526fededbb96f2e7888f179fe27c51d41c2724f7059b75b2fa"}, + {file = "pymongo-4.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83cc3c35aeeceb67143914db67f685206e1aa37ea837d872f4bc28d7f80917c9"}, + {file = "pymongo-4.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e09cdf5aad507c8faa30d97884cc42932ed3a9c2b7f22cc3ccc607bae03981b3"}, + {file = "pymongo-4.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0f53253f4777cbccc426e669a2af875f26c95bd090d88593287b9a0a8ac7fa25"}, + {file = "pymongo-4.2.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:21238b19243a42f9a34a6d39e7580ceebc6da6d2f3cf729c1cff9023cb61a5f1"}, + {file = "pymongo-4.2.0-cp38-cp38-win32.whl", hash = "sha256:766acb5b1a19eae0f7467bcd3398748f110ea5309cdfc59faa5185dcc7fd4dca"}, + {file = "pymongo-4.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:10f09c4f09757c2e2a707ad7304f5d69cb8fdf7cbfb644dbacfe5bbe8afe311b"}, + {file = "pymongo-4.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a6bf01b9237f794fa3bdad5089474067d28be7e199b356a18d3f247a45775f26"}, + {file = "pymongo-4.2.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d8bb745321716e7a11220a67c88212ecedde4021e1de4802e563baef9df921d2"}, + {file = "pymongo-4.2.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:3be53e9888e759c49ae35d747ff77a04ff82b894dd64601e0f3a5a159b406245"}, + {file = "pymongo-4.2.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:a3efdf154844244e0dabe902cf1827fdced55fa5b144adec2a86e5ce50a99b97"}, + {file = "pymongo-4.2.0-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:a7eb5b06744b911b6668b427c8abc71b6d624e72d3dfffed00988fa1b4340f97"}, + {file = "pymongo-4.2.0-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:b0be613d926c5dbb0d3fc6b58e4f2be4979f80ae76fda6e47309f011b388fe0c"}, + {file = "pymongo-4.2.0-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:e7dcb73f683c155885a3488646fcead3a895765fed16e93c9b80000bc69e96cb"}, + {file = "pymongo-4.2.0-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:b537dd282de1b53d9ae7cf9f3df36420c8618390f2da92100391f3ba8f3c141a"}, + {file = "pymongo-4.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d98d2a8283c9928a9e5adf2f3c0181e095579e9732e1613aaa55d386e2bcb6c5"}, + {file = "pymongo-4.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76892bbce743eb9f90360b3626ea92f13d338010a1004b4488e79e555b339921"}, + {file = "pymongo-4.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:124d0e880b66f9b0778613198e89984984fdd37a3030a9007e5f459a42dfa2d3"}, + {file = "pymongo-4.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:773467d25c293f8e981b092361dab5fd800e1ba318403b7959d35004c67faedc"}, + {file = "pymongo-4.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6673ab3fbf3135cc1a8c0f70d480db5b2378c3a70af8d602f73f76b8338bdf97"}, + {file = "pymongo-4.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:153b8f8705970756226dfeeb7bb9637e0ad54a4d79b480b4c8244e34e16e1662"}, + {file = "pymongo-4.2.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:01721da74558f2f64a9f162ee063df403ed656b7d84229268d8e4ae99cfba59c"}, + {file = "pymongo-4.2.0-cp39-cp39-win32.whl", hash = "sha256:a25c0eb2d610b20e276e684be61c337396813b636b69373c17314283cb1a3b14"}, + {file = "pymongo-4.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:44b36ccb90aac5ea50be23c1a6e8f24fbfc78afabdef114af16c6e0a80981364"}, + {file = "pymongo-4.2.0.tar.gz", hash = "sha256:72f338f6aabd37d343bd9d1fdd3de921104d395766bcc5cdc4039e4c2dd97766"}, +] python-dotenv = [ {file = "python-dotenv-0.21.0.tar.gz", hash = "sha256:b77d08274639e3d34145dfa6c7008e66df0f04b7be7a75fd0d5292c191d79045"}, {file = "python_dotenv-0.21.0-py3-none-any.whl", hash = "sha256:1684eb44636dd462b66c3ee016599815514527ad99965de77f43e0944634a7e5"}, diff --git a/server/pyproject.toml b/server/pyproject.toml index ad35bdf..9207fe4 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -13,6 +13,7 @@ Authlib = "^1.0.1" httpx = "^0.23.0" itsdangerous = "^2.1.2" Jinja2 = "^3.1.2" +motor = "^3.0.0" [build-system] diff --git a/server/server/__init__.py b/server/server/__init__.py index b485732..e044d6c 100644 --- a/server/server/__init__.py +++ b/server/server/__init__.py @@ -1,24 +1,42 @@ +import asyncio import json from fastapi import FastAPI, Request -from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.responses import HTMLResponse, PlainTextResponse from .settings import settings from .user import user_auth from .map_tiles import map_tiles, map_meta from .templates import j2env +from .monitoring import monitoring, ws_manager app = FastAPI() app.mount("/user/", user_auth) app.mount("/map/", map_meta) app.mount("/tiles/", map_tiles) +app.mount("/ipmi/", monitoring) + +installer = j2env.get_template("install.lua").render(deploy_path=settings.deploy_path) + + +@app.get("/install") +async def get_installer(): + return PlainTextResponse(installer) + + +app.mount("/lua/", StaticFiles(directory=settings.lua_out_path)) + + +@app.on_event("startup") +async def on_startup(): + asyncio.get_running_loop().create_task(ws_manager.queue_task()) + frontend = FastAPI() manifest = dict() if not settings.dev_mode: - from fastapi.staticfiles import StaticFiles - with open(f"{settings.frontend_path}/manifest.json", "r") as f: manifest = json.load(f) diff --git a/server/server/db.py b/server/server/db.py new file mode 100644 index 0000000..bef9abe --- /dev/null +++ b/server/server/db.py @@ -0,0 +1,24 @@ +from bson import ObjectId +import motor.motor_asyncio as motor +from .settings import settings + +client: motor.AsyncIOMotorClient = motor.AsyncIOMotorClient(settings.mongo_uri) + +db = client["controlpanel"] +events: motor.AsyncIOMotorCollection = db["events"] + + +class PyObjectId(ObjectId): + @classmethod + def __get_validators__(cls): + yield cls.validate + + @classmethod + def validate(cls, v): + if not ObjectId.is_valid(v): + raise ValueError("Invalid objectid") + return ObjectId(v) + + @classmethod + def __modify_schema__(cls, field_schema): + field_schema.update(type="string") diff --git a/server/server/monitoring.py b/server/server/monitoring.py new file mode 100644 index 0000000..9c8ef40 --- /dev/null +++ b/server/server/monitoring.py @@ -0,0 +1,175 @@ +import asyncio +from typing import Any +from uuid import UUID +from datetime import datetime +from bson import ObjectId + +from fastapi import ( + Body, + FastAPI, + HTTPException, + WebSocket, + WebSocketDisconnect, + status, +) +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field, ValidationError + +from .db import PyObjectId, events + +monitoring = FastAPI() + + +class ScreenContent(BaseModel): + x: int + y: int + width: int + height: int + blink: bool + fg: int + text: list[str] + fg_color: list[str] + bg_color: list[str] + palette: list[int] + + +class Update(BaseModel): + screen: ScreenContent | None + + +class WSManager: + def __init__(self): + self.computers: dict[UUID, WebSocket] = dict() + self.viewers: dict[UUID, set[WebSocket]] = dict() + self.queue: asyncio.Queue[tuple[UUID, any]] = asyncio.Queue() + + async def send_connect(self, uuid: UUID): + if uuid not in self.computers: + return + + await self.computers[uuid].send_json({"type": "viewer_connect"}) + + async def send_disconnect(self, uuid: UUID): + if uuid not in self.computers: + return + + await self.computers[uuid].send_json({"type": "viewer_disconnect"}) + + async def queue_task(self): + print("[WS] queue task started") + while True: + (uuid, message) = await self.queue.get() + + if uuid not in self.viewers: + continue + + viewers = self.viewers[uuid] + await asyncio.gather(*(viewer.send_json(message) for viewer in viewers)) + + async def broadcast(self, uuid: UUID, message): + await self.queue.put((uuid, message)) + + async def on_computer_connect(self, socket: WebSocket, uuid: UUID): + if uuid in self.computers: + print(f"[WS] Closing duplicate connection for {uuid}") + await socket.close() + return + + print(f"[WS] Computer {uuid} connected") + self.computers[uuid] = socket + + if len(self.viewers.get(uuid, [])) > 0: + await self.send_connect(uuid) + + while True: + try: + data = await socket.receive_json() + data = Update.parse_obj(data) + + if data.screen: + await self.broadcast(uuid, data.screen.dict()) + + except ValidationError as e: + print(f"[WS] Received invalid message from {uuid}:") + print(e.json) + except WebSocketDisconnect: + break + + del self.computers[uuid] + print(f"[WS] Computer {uuid} disconnected") + + async def on_browser_connect(self, socket: WebSocket, uuid: UUID): + print(f"[WS] Browser connected for {uuid}") + + if uuid not in self.viewers: + self.viewers[uuid] = set() + + if len(self.viewers[uuid]) == 0: + await self.send_connect(uuid) + + self.viewers[uuid].add(socket) + + while True: + try: + data = await socket.receive_json() + except WebSocketDisconnect: + break + + self.viewers[uuid].remove(socket) + if len(self.viewers[uuid]) == 0: + await self.send_disconnect(uuid) + + print(f"[WS] Browser disconnected for {uuid}") + + +ws_manager = WSManager() + + +@monitoring.websocket("/computer/{uuid}/ws") +async def computer_ws(socket: WebSocket, uuid: UUID): + await socket.accept() + await ws_manager.on_computer_connect(socket, uuid) + + +@monitoring.websocket("/browser/{uuid}/ws") +async def browser_ws(socket: WebSocket, uuid: UUID): + await socket.accept() + await ws_manager.on_browser_connect(socket, uuid) + + +class Event(BaseModel): + id: PyObjectId = Field(default_factory=PyObjectId, alias="_id") + timestamp: datetime + value: Any + + class Config: + allow_population_by_field_name = True + arbitrary_types_allowed = True + json_encoders = {ObjectId: str} + + +@monitoring.get("/events", response_model=list[Event]) +async def get_events(): + print("get /events") + return await events.find().to_list(1000) + + +@monitoring.get("/events/{id}", response_model=Event) +async def get_single_event(id: PyObjectId): + if (event := await events.find_one({"_id": id})) is not None: + return event + + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + +@monitoring.post( + "/push_event", response_model=Event, status_code=status.HTTP_201_CREATED +) +async def push_event(value: Any = Body(...)): + event = { + "timestamp": datetime.now(), + "value": value, + } + new_event = await events.insert_one(event) + created_event = await events.find_one({"_id": new_event.inserted_id}) + return created_event diff --git a/server/server/settings.py b/server/server/settings.py index 6191c93..348860a 100644 --- a/server/server/settings.py +++ b/server/server/settings.py @@ -7,6 +7,11 @@ class Settings(BaseSettings): frontend_path: str = "frontend" unmined_out_path: str = "unmined-out" + lua_out_path: str = "lua" + + mongo_uri: str + + deploy_path: str = "http://localhost:8000" settings = Settings() diff --git a/server/templates/install.lua b/server/templates/install.lua new file mode 100644 index 0000000..9f0d87b --- /dev/null +++ b/server/templates/install.lua @@ -0,0 +1,6 @@ +local path = "{{ deploy_path }}" +files = { "main.lua", "json.lua", "framebuffer.lua", "ringbuffer.lua", "socket.lua" } +for _, file in ipairs(files) do + fs.delete(file) + shell.run(("wget %s/lua/%s"):format(path, file)) +end \ No newline at end of file