Implement monitoring infrastructure
This commit is contained in:
parent
2af0136703
commit
7fa635170a
@ -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>
|
||||
|
||||
|
@ -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'
|
||||
: ''}"
|
||||
>
|
||||
|
@ -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;
|
||||
}
|
27
frontend/src/pages/monitoring/Monitoring.svelte
Normal file
27
frontend/src/pages/monitoring/Monitoring.svelte
Normal 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>
|
121
frontend/src/pages/monitoring/Screen.svelte
Normal file
121
frontend/src/pages/monitoring/Screen.svelte
Normal 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} />
|
65
frontend/src/pages/monitoring/Viewer.svelte
Normal file
65
frontend/src/pages/monitoring/Viewer.svelte
Normal 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>
|
25
frontend/src/pages/monitoring/font.ts
Normal file
25
frontend/src/pages/monitoring/font.ts
Normal 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);
|
||||
}
|
12
frontend/src/pages/monitoring/proto.ts
Normal file
12
frontend/src/pages/monitoring/proto.ts
Normal 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[]
|
||||
}
|
BIN
frontend/src/pages/monitoring/term_font.png
Normal file
BIN
frontend/src/pages/monitoring/term_font.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.8 KiB |
21
frontend/src/pages/playground/Bar.svelte
Normal file
21
frontend/src/pages/playground/Bar.svelte
Normal 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>
|
24
frontend/src/pages/playground/Canvas.svelte
Normal file
24
frontend/src/pages/playground/Canvas.svelte
Normal 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>
|
@ -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
96
server/dummy-client.py
Normal 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")
|
@ -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()
|
||||
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user