Update.
This commit is contained in:
parent
f02058b0c0
commit
7b2c93bcef
src/snek
@ -51,6 +51,7 @@ from snek.view.register import RegisterView
|
|||||||
from snek.view.repository import RepositoryView
|
from snek.view.repository import RepositoryView
|
||||||
from snek.view.rpc import RPCView
|
from snek.view.rpc import RPCView
|
||||||
from snek.view.search_user import SearchUserView
|
from snek.view.search_user import SearchUserView
|
||||||
|
from snek.view.container import ContainerView
|
||||||
from snek.view.settings.containers import (
|
from snek.view.settings.containers import (
|
||||||
ContainersCreateView,
|
ContainersCreateView,
|
||||||
ContainersDeleteView,
|
ContainersDeleteView,
|
||||||
@ -250,6 +251,7 @@ class Application(BaseApplication):
|
|||||||
show_index=True,
|
show_index=True,
|
||||||
)
|
)
|
||||||
self.router.add_view("/profiler.html", profiler_handler)
|
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.html", AboutHTMLView)
|
||||||
self.router.add_view("/about.md", AboutMDView)
|
self.router.add_view("/about.md", AboutMDView)
|
||||||
self.router.add_view("/logout.json", LogoutView)
|
self.router.add_view("/logout.json", LogoutView)
|
||||||
|
@ -12,25 +12,26 @@ class ContainerService(BaseService):
|
|||||||
self.compose = ComposeFileManager(self.compose_path,self.container_event_handler)
|
self.compose = ComposeFileManager(self.compose_path,self.container_event_handler)
|
||||||
self.event_listeners = {}
|
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:
|
if not name in self.event_listeners:
|
||||||
self.event_listeners[name] = {}
|
self.event_listeners[name] = {}
|
||||||
if not event in self.event_listeners[name]:
|
if not event in self.event_listeners[name]:
|
||||||
self.event_listeners[name][event] = []
|
self.event_listeners[name][event] = []
|
||||||
queue = asyncio.Queue()
|
self.event_listeners[name][event].append(event_handler)
|
||||||
self.event_listeners[name][event].append(queue)
|
|
||||||
return queue.get
|
|
||||||
|
|
||||||
async def container_event_handler(self, name, event, data):
|
async def container_event_handler(self, name, event, data):
|
||||||
event_listeners = self.event_listeners.get(name, {})
|
event_listeners = self.event_listeners.get(name, {})
|
||||||
queues = event_listeners.get(event, [])
|
handlers = event_listeners.get(event, [])
|
||||||
for queue in queues:
|
for handler in handlers:
|
||||||
await queue.put(data)
|
if not await handler(data):
|
||||||
|
handlers.remove(handler)
|
||||||
|
|
||||||
async def get_instances(self):
|
async def get_instances(self):
|
||||||
return list(self.compose.list_instances())
|
return list(self.compose.list_instances())
|
||||||
|
|
||||||
async def get_container_name(self, channel_uid):
|
async def get_container_name(self, channel_uid):
|
||||||
|
if channel_uid.startswith("channel-"):
|
||||||
|
return channel_uid
|
||||||
return f"channel-{channel_uid}"
|
return f"channel-{channel_uid}"
|
||||||
|
|
||||||
async def get(self,channel_uid):
|
async def get(self,channel_uid):
|
||||||
@ -45,6 +46,9 @@ class ContainerService(BaseService):
|
|||||||
async def get_status(self, channel_uid):
|
async def get_status(self, channel_uid):
|
||||||
return await self.compose.get_instance_status(await self.get_container_name(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(
|
async def create(
|
||||||
self,
|
self,
|
||||||
channel_uid,
|
channel_uid,
|
||||||
|
@ -9,6 +9,10 @@ html {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.gallery {
|
.gallery {
|
||||||
padding: 50px;
|
padding: 50px;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
94
src/snek/static/container.js
Normal file
94
src/snek/static/container.js
Normal 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)
|
||||||
|
}*/
|
||||||
|
|
@ -1,5 +1,5 @@
|
|||||||
import copy
|
import copy
|
||||||
|
import json
|
||||||
import yaml
|
import yaml
|
||||||
import asyncio
|
import asyncio
|
||||||
import subprocess
|
import subprocess
|
||||||
@ -25,6 +25,25 @@ class ComposeFileManager:
|
|||||||
def list_instances(self):
|
def list_instances(self):
|
||||||
return list(self.compose.get("services", {}).keys())
|
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(
|
def create_instance(
|
||||||
self,
|
self,
|
||||||
name,
|
name,
|
||||||
@ -38,8 +57,7 @@ class ComposeFileManager:
|
|||||||
service = {
|
service = {
|
||||||
"image": image,
|
"image": image,
|
||||||
}
|
}
|
||||||
if command:
|
service["command"] = command or "tail -f /dev/null"
|
||||||
service["command"] = command
|
|
||||||
if cpus or memory:
|
if cpus or memory:
|
||||||
service["deploy"] = {"resources": {"limits": {}}}
|
service["deploy"] = {"resources": {"limits": {}}}
|
||||||
if cpus:
|
if cpus:
|
||||||
@ -68,7 +86,7 @@ class ComposeFileManager:
|
|||||||
instance['status'] = await self.get_instance_status(name)
|
instance['status'] = await self.get_instance_status(name)
|
||||||
print("INSTANCE",instance)
|
print("INSTANCE",instance)
|
||||||
|
|
||||||
return instance
|
return json.loads(json.dumps(instance,default=str))
|
||||||
|
|
||||||
def duplicate_instance(self, name, new_name):
|
def duplicate_instance(self, name, new_name):
|
||||||
orig = self.get_instance(name)
|
orig = self.get_instance(name)
|
||||||
@ -100,6 +118,21 @@ class ComposeFileManager:
|
|||||||
running_services = stdout.decode().split()
|
running_services = stdout.decode().split()
|
||||||
return "running" if name in running_services else "stopped"
|
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):
|
async def stop(self, name):
|
||||||
"""Asynchronously stop a container by doing 'docker compose stop [name]'."""
|
"""Asynchronously stop a container by doing 'docker compose stop [name]'."""
|
||||||
if name not in self.list_instances():
|
if name not in self.list_instances():
|
||||||
@ -118,7 +151,12 @@ class ComposeFileManager:
|
|||||||
stdout, stderr = await proc.communicate()
|
stdout, stderr = await proc.communicate()
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
raise RuntimeError(f"Failed to stop {name}: {stderr.decode()}")
|
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):
|
async def start(self, name):
|
||||||
"""Asynchronously start a container by doing 'docker compose up -d [name]'."""
|
"""Asynchronously start a container by doing 'docker compose up -d [name]'."""
|
||||||
@ -126,20 +164,41 @@ class ComposeFileManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
status = await self.get_instance_status(name)
|
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
|
return True
|
||||||
else:
|
elif name in self.running_instances:
|
||||||
del self.running_instances[name]
|
del self.running_instances[name]
|
||||||
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
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,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
stdout, stderr = await proc.communicate()
|
stdout,stderr = await proc.communicate()
|
||||||
|
print(stdout, stderr)
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
raise RuntimeError(f"Failed to start {name}: {stderr.decode()}")
|
print(f"Failed to start {name}: {stderr.decode(errors='ignore')}")
|
||||||
return stdout.decode()
|
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:
|
# Example usage:
|
||||||
# mgr = ComposeFileManager()
|
# mgr = ComposeFileManager()
|
||||||
|
@ -17,6 +17,11 @@
|
|||||||
<script src="/user-list.js"></script>
|
<script src="/user-list.js"></script>
|
||||||
<script src="/message-list.js" type="module"></script>
|
<script src="/message-list.js" type="module"></script>
|
||||||
<script src="/chat-input.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="/sandbox.css">
|
||||||
<link rel="stylesheet" href="/user-list.css">
|
<link rel="stylesheet" href="/user-list.css">
|
||||||
<link rel="stylesheet" href="/fa640.all.min.css">
|
<link rel="stylesheet" href="/fa640.all.min.css">
|
||||||
@ -50,7 +55,13 @@
|
|||||||
<chat-window class="chat-area"></chat-window>
|
<chat-window class="chat-area"></chat-window>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</main>
|
</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
|
let installPrompt = null
|
||||||
window.addEventListener("beforeinstallprompt", (e) => {
|
window.addEventListener("beforeinstallprompt", (e) => {
|
||||||
//e.preventDefault();
|
//e.preventDefault();
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
{% block main %}
|
{% block main %}
|
||||||
|
|
||||||
<section class="chat-area">
|
<section class="chat-area">
|
||||||
|
<div id="terminal" class="hidden"></div>
|
||||||
<message-list class="chat-messages">
|
<message-list class="chat-messages">
|
||||||
{% if not messages %}
|
{% if not messages %}
|
||||||
|
|
||||||
@ -42,14 +43,22 @@ const messagesContainer = document.querySelector(".chat-messages");
|
|||||||
const chatArea = document.querySelector(".chat-area");
|
const chatArea = document.querySelector(".chat-area");
|
||||||
const channelUid = "{{ channel.uid.value }}";
|
const channelUid = "{{ channel.uid.value }}";
|
||||||
const username = "{{ user.username.value }}";
|
const username = "{{ user.username.value }}";
|
||||||
|
let container = null
|
||||||
// --- Command completions ---
|
// --- Command completions ---
|
||||||
chatInputField.autoCompletions = {
|
chatInputField.autoCompletions = {
|
||||||
"/online": showOnline,
|
"/online": showOnline,
|
||||||
"/clear": () => { messagesContainer.innerHTML = ''; },
|
"/clear": () => { messagesContainer.innerHTML = ''; },
|
||||||
"/live": () => { chatInputField.liveType = !chatInputField.liveType; },
|
"/live": () => { chatInputField.liveType = !chatInputField.liveType; },
|
||||||
"/help": showHelp,
|
"/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 ---
|
// --- Throttle utility ---
|
||||||
|
60
src/snek/view/container.py
Normal file
60
src/snek/view/container.py
Normal 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
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user