Implement monitoring infrastructure

This commit is contained in:
Kai Vogelgesang 2022-09-22 19:27:01 +02:00
parent 2af0136703
commit 7fa635170a
Signed by: kai
GPG Key ID: 0A95D3B6E62C0879
15 changed files with 499 additions and 29 deletions

View File

@ -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 @@
</BaseLayout>
</Route>
<Route path="/foo">
<Route path="foo">
<BaseLayout>
<section class="hero is-danger is-fullheight">
<div class="hero-body">
@ -38,19 +40,19 @@
</BaseLayout>
</Route>
<Route path="/bar">
<BaseLayout>bar</BaseLayout>
<Route path="bar">
<BaseLayout><Bar /></BaseLayout>
</Route>
<Route path="/monitoring">
<BaseLayout>monitoring</BaseLayout>
<Route path="monitoring/*">
<BaseLayout><Monitoring /></BaseLayout>
</Route>
<Route path="/mining">
<Route path="mining">
<Mining />
</Route>
<Route path="/stats">
<Route path="stats">
<BaseLayout>stats</BaseLayout>
</Route>

View File

@ -10,7 +10,7 @@
<Link
to={target}
class="navbar-item is-tab {$location.pathname === target
class="navbar-item is-tab {$location.pathname.startsWith(target)
? 'is-active'
: ''}"
>

View File

@ -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;
}

View File

@ -0,0 +1,27 @@
<script lang="ts">
import { Link, Route, useParams } from "svelte-navigator";
import Viewer from "./Viewer.svelte";
const uuids = ["8b9faf9f-9470-4a50-b405-0af5f0152550"];
</script>
<section class="section">
<Route path="/">
<div class="content">
<p>Yo wassup</p>
<ul>
{#each uuids as id}
<li><Link to="./{id}">{id}</Link></li>
{/each}
</ul>
</div>
</Route>
<Route path=":id" let:params>
<div class="container">
<h1 class="title">ASSUMING DIRECT CONTROL</h1>
<Viewer uuid={params.id} />
<Link to="..">fuck go back</Link>
</div>
</Route>
</section>

View File

@ -0,0 +1,121 @@
<script lang="ts">
import termFont from "./term_font.png";
import { loadFont, charWidth, charHeight } from "./font";
import type { ScreenContent } from "./proto";
import { onDestroy, onMount } from "svelte";
export let content: ScreenContent;
let canvas: HTMLCanvasElement;
let composeCanvas: HTMLCanvasElement;
let font: ImageBitmap[];
const paddingX = 2,
paddingY = 2;
const toColor = (n: number) => `#${n.toString(16).padStart(6, "0")}`;
// SAFETY: Only called if all of (content, canvas, composeCanvas, font) are defined
function drawChar(
charCode: number,
x: number,
y: number,
textColor: number,
backgroundColor: number
) {
let cx = canvas.getContext("2d");
let ccx = composeCanvas.getContext("2d");
const offsetX = paddingX + x * charWidth;
const offsetY = paddingY + y * charHeight;
let x0 = offsetX;
let y0 = offsetY;
let w = charWidth;
let h = charHeight;
// handle padding
if (x === 0) {
x0 -= paddingX;
w += paddingX;
} else if (x === content.width - 1) {
w += paddingX;
}
if (y === 0) {
y0 -= paddingY;
h += paddingY;
} else if (y === content.height - 1) {
h += paddingY;
}
// draw background
cx.fillStyle = toColor(content.palette[backgroundColor]);
cx.fillRect(x0, y0, w, h);
// compose foreground
ccx.globalCompositeOperation = "source-over";
ccx.clearRect(0, 0, charWidth, charHeight);
ccx.fillStyle = toColor(content.palette[textColor]);
ccx.fillRect(0, 0, charWidth, charHeight);
ccx.globalCompositeOperation = "destination-in";
ccx.drawImage(font[charCode], 0, 0);
// draw foreground
cx.drawImage(composeCanvas, offsetX, offsetY);
}
function redraw(content: ScreenContent) {
if (!canvas) return;
if (!font) return;
const cx = canvas.getContext("2d");
if (!content) {
cx.fillStyle = "#ff6600";
cx.fillRect(0, 0, canvas.width, canvas.height);
return;
}
canvas.width = content.width * charWidth + 2 * paddingX;
canvas.height = content.height * charHeight + 2 * paddingY;
cx.clearRect(0, 0, canvas.width, canvas.height);
for (let y = 0; y < content.height; ++y) {
const line = content.text[y];
const fgLine = content.fg_color[y];
const bgLine = content.bg_color[y];
for (let x = 0; x < content.width; ++x) {
drawChar(
line.charCodeAt(x),
x,
y,
parseInt(fgLine.charAt(x), 16),
parseInt(bgLine.charAt(x), 16)
);
}
}
}
$: redraw(content);
const blinkInt = setInterval(() => {
}, 400);
onMount(async () => {
font = await loadFont(termFont);
composeCanvas = document.createElement("canvas");
composeCanvas.width = charWidth;
composeCanvas.height = charHeight;
redraw(content);
});
onDestroy(() => {
composeCanvas = null;
clearInterval(blinkInt);
});
</script>
<canvas bind:this={canvas} />

View File

@ -0,0 +1,65 @@
<script lang="ts">
export let uuid: string;
import { onDestroy, onMount } from "svelte";
import Screen from "./Screen.svelte";
const wsUrl = new URL(`/ipmi/browser/${uuid}/ws`, window.location.href);
wsUrl.protocol = wsUrl.protocol.replace("http", "ws");
const dummyData = {
x: -1,
y: -1,
width: 13,
height: 5,
blink: false,
fg: 0,
text: [
" ",
" ",
" [NO SIGNAL] ",
" ",
" ",
],
fg_color: [
"0000000000000",
"0000000000000",
"0800000000080",
"0000000000000",
"0000000000000",
],
bg_color: [
"7777777777777",
"7777777777777",
"7777777777777",
"7777777777777",
"7777777777777",
],
palette: [
15790320, 15905331, 15040472, 10072818, 14605932, 8375321, 15905484,
5000268, 10066329, 5020082, 11691749, 3368652, 8349260, 5744206,
13388876, 1118481,
],
};
let socket: WebSocket | null = null;
let data;
onMount(async () => {
socket = new WebSocket(wsUrl);
socket.addEventListener("message", (event) => {
data = JSON.parse(event.data);
});
});
onDestroy(() => {
console.log("onDestroy");
if (socket !== null) {
socket.close();
}
});
</script>
<Screen content={data || dummyData} />
<pre>{JSON.stringify(data, null, 2)}</pre>

View File

@ -0,0 +1,25 @@
export const charWidth = 6, charHeight = 9;
export async function loadFont(src: RequestInfo | URL): Promise<ImageBitmap[]> {
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);
}

View File

@ -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[]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -0,0 +1,21 @@
<script lang="ts">
import { onDestroy } from "svelte";
import Canvas from "./Canvas.svelte";
let colors = ["#ff6600", "#00ff66", "#6600ff"];
let i = 0;
$: color = colors[i];
let int = setInterval(() => {
i = (i + 1) % colors.length;
}, 1000);
onDestroy(() => {
clearInterval(int);
});
</script>
<section class="section">
<Canvas {color} />
</section>

View File

@ -0,0 +1,24 @@
<script lang="ts">
import { onMount } from "svelte";
export let color: string;
let canvas: HTMLCanvasElement;
let redraws = 0;
function redraw(color) {
if (!canvas) return;
redraws += 1;
let cx = canvas.getContext("2d");
cx.fillStyle = color;
cx.fillRect(0, 0, cx.canvas.width, cx.canvas.height);
}
$: redraw(color);
</script>
<canvas bind:this={canvas} />
<pre>color: {color}
redraws: {redraws}</pre>

View File

@ -2,7 +2,7 @@ local json = require("json")
local fb = require("framebuffer")
local ringbuffer = require("ringbuffer")
local UUID <const> = "8b9faf9f-9470-4a50-b405-0af5f0152550"
local ENDPOINT <const> = "ws://localhost:8000/monitoring/computer/" .. UUID .. "/ws"
local ENDPOINT <const> = "ws://localhost:8000/ipmi/computer/" .. UUID .. "/ws"
print("[MAIN] Init")

96
server/dummy-client.py Normal file
View File

@ -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")

View File

@ -1,3 +1,4 @@
import asyncio
import json
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
@ -6,14 +7,20 @@ 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
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("/monitoring/", monitoring)
app.mount("/ipmi/", monitoring)
@app.on_event("startup")
async def on_startup():
asyncio.get_running_loop().create_task(ws_manager.queue_task())
frontend = FastAPI()

View File

@ -1,10 +1,13 @@
import asyncio
import json
from uuid import UUID
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from pydantic import BaseModel, ValidationError
monitoring = FastAPI()
class ScreenContent(BaseModel):
x: int
y: int
@ -15,24 +18,86 @@ class ScreenContent(BaseModel):
text: list[str]
fg_color: list[str]
bg_color: list[str]
palette: list[int]
class Ping(BaseModel):
screen: ScreenContent
@monitoring.post("/ping")
async def ping(request: Request, data: Ping):
print("[PING]")
for line in data.screen.text:
print(line)
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 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
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()
self.viewers[uuid].add(socket)
while True:
try:
data = await socket.receive_json()
except WebSocketDisconnect:
break
self.viewers[uuid].remove(socket)
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()
print(f"[WS] Computer {uuid} connected")
while True:
try:
data = await socket.receive_json()
#print(f"[WS] rx {json.dumps(data)}")
except WebSocketDisconnect:
break
print(f"[WS] Computer {uuid} disconnected")
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)