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 Mining from "./pages/mining/Mining.svelte";
import Footer from "./Footer.svelte"; import Footer from "./Footer.svelte";
import BaseLayout from "./BaseLayout.svelte"; import BaseLayout from "./BaseLayout.svelte";
import Monitoring from "./pages/monitoring/Monitoring.svelte";
import Bar from "./pages/playground/Bar.svelte";
onMount(async () => { onMount(async () => {
const res = await fetch("/user/me"); const res = await fetch("/user/me");
@ -25,7 +27,7 @@
</BaseLayout> </BaseLayout>
</Route> </Route>
<Route path="/foo"> <Route path="foo">
<BaseLayout> <BaseLayout>
<section class="hero is-danger is-fullheight"> <section class="hero is-danger is-fullheight">
<div class="hero-body"> <div class="hero-body">
@ -38,19 +40,19 @@
</BaseLayout> </BaseLayout>
</Route> </Route>
<Route path="/bar"> <Route path="bar">
<BaseLayout>bar</BaseLayout> <BaseLayout><Bar /></BaseLayout>
</Route> </Route>
<Route path="/monitoring"> <Route path="monitoring/*">
<BaseLayout>monitoring</BaseLayout> <BaseLayout><Monitoring /></BaseLayout>
</Route> </Route>
<Route path="/mining"> <Route path="mining">
<Mining /> <Mining />
</Route> </Route>
<Route path="/stats"> <Route path="stats">
<BaseLayout>stats</BaseLayout> <BaseLayout>stats</BaseLayout>
</Route> </Route>

View File

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

View File

@ -18,6 +18,11 @@ $fa-font-path: "@fortawesome/fontawesome-free/webfonts";
@import "@fortawesome/fontawesome-free/scss/v4-shims.scss"; @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 // 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; 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 fb = require("framebuffer")
local ringbuffer = require("ringbuffer") local ringbuffer = require("ringbuffer")
local UUID <const> = "8b9faf9f-9470-4a50-b405-0af5f0152550" 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") 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 import json
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
@ -6,14 +7,20 @@ from .settings import settings
from .user import user_auth from .user import user_auth
from .map_tiles import map_tiles, map_meta from .map_tiles import map_tiles, map_meta
from .templates import j2env from .templates import j2env
from .monitoring import monitoring from .monitoring import monitoring, ws_manager
app = FastAPI() app = FastAPI()
app.mount("/user/", user_auth) app.mount("/user/", user_auth)
app.mount("/map/", map_meta) app.mount("/map/", map_meta)
app.mount("/tiles/", map_tiles) 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() frontend = FastAPI()

View File

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