This commit is contained in:
retoor 2025-06-08 11:41:25 +02:00
parent f02058b0c0
commit 7b2c93bcef
8 changed files with 264 additions and 21 deletions

View File

@ -51,6 +51,7 @@ from snek.view.register import RegisterView
from snek.view.repository import RepositoryView
from snek.view.rpc import RPCView
from snek.view.search_user import SearchUserView
from snek.view.container import ContainerView
from snek.view.settings.containers import (
ContainersCreateView,
ContainersDeleteView,
@ -250,6 +251,7 @@ class Application(BaseApplication):
show_index=True,
)
self.router.add_view("/profiler.html", profiler_handler)
self.router.add_view("/container/sock/{channel_uid}.json", ContainerView)
self.router.add_view("/about.html", AboutHTMLView)
self.router.add_view("/about.md", AboutMDView)
self.router.add_view("/logout.json", LogoutView)

View File

@ -12,25 +12,26 @@ class ContainerService(BaseService):
self.compose = ComposeFileManager(self.compose_path,self.container_event_handler)
self.event_listeners = {}
async def add_event_listener(self, name, event):
async def add_event_listener(self, name, event,event_handler):
if not name in self.event_listeners:
self.event_listeners[name] = {}
if not event in self.event_listeners[name]:
self.event_listeners[name][event] = []
queue = asyncio.Queue()
self.event_listeners[name][event].append(queue)
return queue.get
self.event_listeners[name][event].append(event_handler)
async def container_event_handler(self, name, event, data):
event_listeners = self.event_listeners.get(name, {})
queues = event_listeners.get(event, [])
for queue in queues:
await queue.put(data)
handlers = event_listeners.get(event, [])
for handler in handlers:
if not await handler(data):
handlers.remove(handler)
async def get_instances(self):
return list(self.compose.list_instances())
async def get_container_name(self, channel_uid):
if channel_uid.startswith("channel-"):
return channel_uid
return f"channel-{channel_uid}"
async def get(self,channel_uid):
@ -45,6 +46,9 @@ class ContainerService(BaseService):
async def get_status(self, channel_uid):
return await self.compose.get_instance_status(await self.get_container_name(channel_uid))
async def write_stdin(self, channel_uid, data):
return await self.compose.write_stdin(await self.get_container_name(channel_uid), data)
async def create(
self,
channel_uid,

View File

@ -9,6 +9,10 @@ html {
height: 100%;
}
.hidden {
display: none;
}
.gallery {
padding: 50px;
height: auto;

View File

@ -0,0 +1,94 @@
import { app } from "./app.js";
import { EventHandler } from "./event-handler.js";
export class Container extends EventHandler{
status = "unknown"
cpus = 0
memory = "0m"
image = "unknown:unknown"
name = null
channelUid = null
log = false
bytesSent = 0
bytesReceived = 0
_container = null
render(el){
if(this._container == null){
this._container = el
this.terminal.open(this._container)
this.terminal.onData(data => this.ws.send(new TextEncoder().encode(data)));
}
this._fitAddon.fit();
this.refresh()
}
refresh(){
this._fitAddon.fit();
this.terminal.write("\x0C");
}
toggle(){
this._container.classList.toggle("hidden")
this.refresh()
}
constructor(channelUid,log){
super()
this.terminal = new Terminal({ cursorBlink: true });
this._fitAddon = new FitAddon.FitAddon();
this.terminal.loadAddon(this._fitAddon);
window.addEventListener("resize", () => this._fitAddon.fit());
this.log = log ? true : false
this.channelUid = channelUid
this.update()
this.addEventListener("stdout", (data) => {
this.bytesReceived += data.length
if(this.log){
console.log(`Container ${this.name}: ${data}`)
}
const fixedData = new Uint8Array(data);
this.terminal.write(new TextDecoder().decode(fixedData));
})
this.ws = new WebSocket(`/container/sock/${channelUid}.json`)
this.ws.binaryType = "arraybuffer"; // Support binary data
this.ws.onmessage = (event) => {
this.emit("stdout", event.data)
}
this.ws.onopen = () => {
this.refresh()
}
window.container = this
}
async start(){
const result = await app.rpc.startContainer(this.channelUid)
await this.refresh()
return result && this.status == 'running'
}
async stop(){
const result = await app.rpc.stopContainer(this.channelUid)
await this.refresh()
return result && this.status == 'stopped'
}
async write(data){
await this.ws.send(data)
this.bytesSent += data.length
return true
}
async update(){
const container = await app.rpc.getContainer(this.channelUid)
this.status = container["status"]
this.cpus = container["cpus"]
this.memory = container["memory"]
this.image = container["image"]
this.name = container["name"]
}
}
/*
window.getContainer = function(){
return new Container(app.channelUid)
}*/

View File

@ -1,5 +1,5 @@
import copy
import json
import yaml
import asyncio
import subprocess
@ -25,6 +25,25 @@ class ComposeFileManager:
def list_instances(self):
return list(self.compose.get("services", {}).keys())
async def _create_readers(self, container_name):
instance = await self.get_instance(container_name)
if not instance:
return False
proc = self.running_instances.get(container_name)
if not proc:
return False
async def reader(event_handler,stream):
while True:
line = await stream.readline()
print("XXX",line)
if not line:
break
await event_handler(container_name,"stdout",line)
await self.stop(container_name)
asyncio.create_task(reader(self.event_handler,proc.stdout))
asyncio.create_task(reader(self.event_handler,proc.stderr))
def create_instance(
self,
name,
@ -38,8 +57,7 @@ class ComposeFileManager:
service = {
"image": image,
}
if command:
service["command"] = command
service["command"] = command or "tail -f /dev/null"
if cpus or memory:
service["deploy"] = {"resources": {"limits": {}}}
if cpus:
@ -68,7 +86,7 @@ class ComposeFileManager:
instance['status'] = await self.get_instance_status(name)
print("INSTANCE",instance)
return instance
return json.loads(json.dumps(instance,default=str))
def duplicate_instance(self, name, new_name):
orig = self.get_instance(name)
@ -100,6 +118,21 @@ class ComposeFileManager:
running_services = stdout.decode().split()
return "running" if name in running_services else "stopped"
async def write_stdin(self, name, data):
await self.event_handler(name, "stdin", data)
proc = self.running_instances.get(name)
print("Found proc:",proc)
print(name,data)
if not proc:
return False
try:
proc.stdin.write(data.encode())
return True
except Exception as ex:
print(ex)
await self.stop(name)
return False
async def stop(self, name):
"""Asynchronously stop a container by doing 'docker compose stop [name]'."""
if name not in self.list_instances():
@ -118,7 +151,12 @@ class ComposeFileManager:
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"Failed to stop {name}: {stderr.decode()}")
return stdout.decode()
if stdout:
await self.event_handler(name,"stdout",stdout)
return stdout.decode(errors="ignore")
await self.event_handler(name,"stdout",stderr)
return stderr.decode(errors="ignore")
async def start(self, name):
"""Asynchronously start a container by doing 'docker compose up -d [name]'."""
@ -126,20 +164,41 @@ class ComposeFileManager:
return False
status = await self.get_instance_status(name)
if name in self.running_instances and status == "running":
if name in self.running_instances and status == "running" and self.running_instances.get(name):
await self.stop(name)
return True
else:
elif name in self.running_instances:
del self.running_instances[name]
proc = await asyncio.create_subprocess_exec(
"docker", "compose", "-f", self.compose_path, "up", "-d", name,
"docker", "compose", "-f", self.compose_path, "up", name, "-d",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
stdout,stderr = await proc.communicate()
print(stdout, stderr)
if proc.returncode != 0:
raise RuntimeError(f"Failed to start {name}: {stderr.decode()}")
return stdout.decode()
print(f"Failed to start {name}: {stderr.decode(errors='ignore')}")
return False
proc = await asyncio.create_subprocess_exec(
"docker", "compose", "-f", self.compose_path, "exec", name, "/bin/bash",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
# stdin,stderr = await proc.communicate()
self.running_instances[name] = proc
#if stdout:
# await self.event_handler(name, "stdout", stdout)
#if stderr:
# await self.event_handler(name,"stdout",stderr)
await self._create_readers(name)
return True
#return stdout and stdout.decode(errors="ignore") or stderr.decode(errors="ignore")
# Example usage:
# mgr = ComposeFileManager()

View File

@ -17,6 +17,11 @@
<script src="/user-list.js"></script>
<script src="/message-list.js" type="module"></script>
<script src="/chat-input.js" type="module"></script>
<script src="/container.js" type="module"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm/css/xterm.css">
<script src="https://cdn.jsdelivr.net/npm/xterm/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit/lib/xterm-addon-fit.js"></script>
<link rel="stylesheet" href="/sandbox.css">
<link rel="stylesheet" href="/user-list.css">
<link rel="stylesheet" href="/fa640.all.min.css">
@ -50,7 +55,13 @@
<chat-window class="chat-area"></chat-window>
{% endblock %}
</main>
<script>
<script type="module">
import { app } from "/app.js";
import { Container } from "/container.js";
app.channelUid = '{{ channel.uid.value }}'
window.getContainer = function(){
return new Container(app.channelUid,true)
}
let installPrompt = null
window.addEventListener("beforeinstallprompt", (e) => {
//e.preventDefault();

View File

@ -5,6 +5,7 @@
{% block main %}
<section class="chat-area">
<div id="terminal" class="hidden"></div>
<message-list class="chat-messages">
{% if not messages %}
@ -42,14 +43,22 @@ const messagesContainer = document.querySelector(".chat-messages");
const chatArea = document.querySelector(".chat-area");
const channelUid = "{{ channel.uid.value }}";
const username = "{{ user.username.value }}";
let container = null
// --- Command completions ---
chatInputField.autoCompletions = {
"/online": showOnline,
"/clear": () => { messagesContainer.innerHTML = ''; },
"/live": () => { chatInputField.liveType = !chatInputField.liveType; },
"/help": showHelp,
"/container": () =>{ containerDialog.openWithStatus()}
"/container": async() =>{
if(container == null){
container = await window.getContainer()
const terminal = document.querySelector("#terminal")
terminal.classList.toggle("hidden")
container.render(terminal)
}
containerDialog.openWithStatus()
}
};
// --- Throttle utility ---

View File

@ -0,0 +1,60 @@
from snek.system.view import BaseView
import functools
from aiohttp import web
class ContainerView(BaseView):
async def stdout_event_handler(self, ws, data):
try:
await ws.send_bytes(data)
except Exception as ex:
print(ex)
await ws.close()
return False
return True
async def create_stdout_event_handler(self, ws):
return functools.partial(self.stdout_event_handler, ws)
async def get(self):
ws = web.WebSocketResponse()
await ws.prepare(self.request)
if not self.request.session.get("logged_in"):
return self.HTTPUnauthorized()
channel_uid = self.request.match_info.get("channel_uid")
channel_member = await self.services.channel_member.get(channel_uid=channel_uid, user_uid=self.request.session.get("uid"))
if not channel_member:
return web.HTTPUnauthorized()
container = await self.services.container.get(channel_uid)
if not container:
return web.HTTPNotFound()
if not container['status'] == 'running':
resp = await self.services.container.start(channel_uid)
await ws.send_str(str(resp))
container_name = await self.services.container.get_container_name(channel_uid)
event_handler = await self.create_stdout_event_handler(ws)
await self.services.container.add_event_listener(container_name, "stdout", event_handler)
while True:
data = await ws.receive()
if data.type == web.WSMsgType.TEXT:
await self.services.container.write_stdin(container_name, data.data)
elif data.type == web.WSMsgType.CLOSE:
break
elif data.type == web.WSMsgType.ERROR:
break
await self.services.container.remove_event_listener(container_name, channel_uid, "stdout")
return ws