diff --git a/src/snek/balancer.py b/src/snek/balancer.py new file mode 100644 index 0000000..09d0b5a --- /dev/null +++ b/src/snek/balancer.py @@ -0,0 +1,123 @@ +import asyncio +import sys + +class LoadBalancer: + def __init__(self, backend_ports): + self.backend_ports = backend_ports + self.backend_processes = [] + self.client_counts = [0] * len(backend_ports) + self.lock = asyncio.Lock() + + async def start_backend_servers(self,port,workers): + for x in range(workers): + port += 1 + process = await asyncio.create_subprocess_exec( + sys.executable, + sys.argv[0], + 'backend', + str(port), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + port += 1 + self.backend_processes.append(process) + print(f"Started backend server on port {(port-1)/port} with PID {process.pid}") + + async def handle_client(self, reader, writer): + async with self.lock: + min_clients = min(self.client_counts) + server_index = self.client_counts.index(min_clients) + self.client_counts[server_index] += 1 + backend = ('127.0.0.1', self.backend_ports[server_index]) + try: + backend_reader, backend_writer = await asyncio.open_connection(*backend) + + async def forward(r, w): + try: + while True: + data = await r.read(1024) + if not data: + break + w.write(data) + await w.drain() + except asyncio.CancelledError: + pass + finally: + w.close() + + task1 = asyncio.create_task(forward(reader, backend_writer)) + task2 = asyncio.create_task(forward(backend_reader, writer)) + await asyncio.gather(task1, task2) + except Exception as e: + print(f"Error: {e}") + finally: + writer.close() + async with self.lock: + self.client_counts[server_index] -= 1 + + async def monitor(self): + while True: + await asyncio.sleep(5) + print("Connected clients per server:") + for i, count in enumerate(self.client_counts): + print(f"Server {self.backend_ports[i]}: {count} clients") + + async def start(self, host='0.0.0.0', port=8081,workers=5): + await self.start_backend_servers(port,workers) + server = await asyncio.start_server(self.handle_client, host, port) + monitor_task = asyncio.create_task(self.monitor()) + + # Handle shutdown gracefully + try: + async with server: + await server.serve_forever() + except asyncio.CancelledError: + pass + finally: + # Terminate backend processes + for process in self.backend_processes: + process.terminate() + await asyncio.gather(*(p.wait() for p in self.backend_processes)) + print("Backend processes terminated.") + +async def backend_echo_server(port): + async def handle_echo(reader, writer): + try: + while True: + data = await reader.read(1024) + if not data: + break + writer.write(data) + await writer.drain() + except Exception: + pass + finally: + writer.close() + + server = await asyncio.start_server(handle_echo, '127.0.0.1', port) + print(f"Backend echo server running on port {port}") + await server.serve_forever() + +async def main(): + backend_ports = [8001, 8003, 8005, 8006] + # Launch backend echo servers + # Wait a moment for servers to start + lb = LoadBalancer(backend_ports) + await lb.start() + +if __name__ == "__main__": + if len(sys.argv) > 1: + if sys.argv[1] == 'backend': + port = int(sys.argv[2]) + from snek.app import Application + snek = Application(port=port) + web.run_app(snek, port=port, host='127.0.0.1') + elif sys.argv[1] == 'sync': + from snek.sync import app + web.run_app(snek, port=port, host='127.0.0.1') + else: + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("Shutting down...") + diff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py index f8a000f..d9ba45d 100644 --- a/src/snek/service/channel_message.py +++ b/src/snek/service/channel_message.py @@ -30,7 +30,7 @@ class ChannelMessageService(BaseService): except Exception as ex: print(ex, flush=True) - if await self.save(model): + if await super().save(model): return model raise Exception(f"Failed to create channel message: {model.errors}.") @@ -50,6 +50,12 @@ class ChannelMessageService(BaseService): "username": user["username"], } + async def save(self, model): + context = model.record + template = self.app.jinja2_env.get_template("message.html") + model["html"] = template.render(**context) + return await super().save(model) + async def offset(self, channel_uid, page=0, timestamp=None, page_size=30): results = [] offset = page * page_size diff --git a/src/snek/service/chat.py b/src/snek/service/chat.py index 388d5c0..7fbe787 100644 --- a/src/snek/service/chat.py +++ b/src/snek/service/chat.py @@ -36,4 +36,4 @@ class ChatService(BaseService): self.services.notification.create_channel_message(channel_message_uid) ) - return True + return channel_message diff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js index 91d80d3..210c2e8 100644 --- a/src/snek/static/message-list.js +++ b/src/snek/static/message-list.js @@ -5,8 +5,64 @@ // The code seems to rely on some external dependencies like 'models.Message', 'app', and 'Schedule'. These should be imported or defined elsewhere in your application. // MIT License: This is free software. Permission is granted to use, copy, modify, and/or distribute this software for any purpose with or without fee. The software is provided "as is" without any warranty. + import {app} from '../app.js' + class MessageList extends HTMLElement { + constructor() { + super(); + app.ws.addEventListener("update_message_text",(data)=>{ + this.updateMessageText(data.data.message_uid,data.data.text) + }) + app.ws.addEventListener("set_typing",(data)=>{ + this.triggerGlow(data.data.user_uid) -class MessageListElement extends HTMLElement { + }) + + this.items = []; + } + updateMessageText(uid,text){ + const messageDiv = this.querySelector("div[data-uid=\""+uid+"\"]") + if(!messageDiv){ + return + } + const textElement = messageDiv.querySelector(".text") + textElement.innerText = text + textElement.style.display = text == '' ? 'none' : 'block' + + } + triggerGlow(uid) { + let lastElement = null; + this.querySelectorAll(".avatar").forEach((el)=>{ + const div = el.closest('a'); + if(el.href.indexOf(uid)!=-1){ + lastElement = el + } + + }) + if(lastElement){ + lastElement.classList.add("glow") + setTimeout(()=>{ + lastElement.classList.remove("glow") + },1000) + } + + } + + set data(items) { + this.items = items; + this.render(); + } + render() { + this.innerHTML = ''; + + //this.insertAdjacentHTML("beforeend", html); + + } + + } + + customElements.define('message-list', MessageList); + +class MessageListElementOLD extends HTMLElement { static get observedAttributes() { return ["messages"]; } @@ -167,4 +223,4 @@ class MessageListElement extends HTMLElement { } } -customElements.define('message-list', MessageListElement); +//customElements.define('message-list', MessageListElement); diff --git a/src/snek/static/online-users.js b/src/snek/static/online-users.js new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/snek/static/online-users.js @@ -0,0 +1 @@ + diff --git a/src/snek/static/user-list.css b/src/snek/static/user-list.css new file mode 100644 index 0000000..d831c2f --- /dev/null +++ b/src/snek/static/user-list.css @@ -0,0 +1,28 @@ + .user-list__item { + display: flex; + margin-bottom: 1em; + border: 1px solid #ccc; + padding: 10px; + border-radius: 8px; + } + .user-list__item-avatar { + margin-right: 10px; + border-radius: 50%; + overflow: hidden; + width: 40px; + height: 40px; + display: block; + } + .user-list__item-content { + flex: 1; + } + .user-list__item-name { + font-weight: bold; + } + .user-list__item-text { + margin: 5px 0; + } + .user-list__item-time { + font-size: 0.8em; + color: gray; + } diff --git a/src/snek/static/user-list.js b/src/snek/static/user-list.js new file mode 100644 index 0000000..5aaba50 --- /dev/null +++ b/src/snek/static/user-list.js @@ -0,0 +1,59 @@ + class UserList extends HTMLElement { + constructor() { + super(); + this.users = []; + } + + set data(userArray) { + this.users = userArray; + this.render(); + } + + formatRelativeTime(timestamp) { + const now = new Date(); + const msgTime = new Date(timestamp); + const diffMs = now - msgTime; + const minutes = Math.floor(diffMs / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${days} day${days > 1 ? 's' : ''} ago`; + } else if (hours > 0) { + return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${hours} hour${hours > 1 ? 's' : ''} ago`; + } else { + return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${minutes} min ago`; + } + } + + render() { + this.innerHTML = ''; + + this.users.forEach(user => { + const html = ` +
+ `; + this.insertAdjacentHTML("beforeend", html); + }); + } + } + + customElements.define('user-list', UserList); diff --git a/src/snek/sync.py b/src/snek/sync.py new file mode 100644 index 0000000..fb2a9af --- /dev/null +++ b/src/snek/sync.py @@ -0,0 +1,135 @@ + + + + +class DatasetWebSocketView: + def __init__(self): + self.ws = None + self.db = dataset.connect('sqlite:///snek.db') + self.setattr(self, "db", self.get) + self.setattr(self, "db", self.set) + ) + super() + + def format_result(self, result): + + try: + return dict(result) + except: + pass + try: + return [dict(row) for row in result] + except: + pass + return result + + async def send_str(self, msg): + return await self.ws.send_str(msg) + + def get(self, key): + returnl loads(dict(self.db['_kv'].get(key=key)['value'])) + + def set(self, key, value): + return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key']) + + + + async def handle(self, request): + ws = web.WebSocketResponse() + await ws.prepare(request) + self.ws = ws + + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + try: + data = json.loads(msg.data) + call_uid = data.get("call_uid") + method = data.get("method") + table_name = data.get("table") + args = data.get("args", {}) + kwargs = data.get("kwargs", {}) + + + function = getattr(self.db, method, None) + if table_name: + function = getattr(self.db[table_name], method, None) + + print(method, table_name, args, kwargs,flush=True) + + if function: + response = {} + try: + result = function(*args, **kwargs) + print(result) + response['result'] = self.format_result(result) + response["call_uid"] = call_uid + response["success"] = True + except Exception as e: + response["call_uid"] = call_uid + response["success"] = False + response["error"] = str(e) + response["traceback"] = traceback.format_exc() + + if call_uid: + await self.send_str(json.dumps(response,default=str)) + else: + await self.send_str(json.dumps({"status": "error", "error":"Method not found.","call_uid": call_uid})) + except Exception as e: + await self.send_str(json.dumps({"success": False,"call_uid": call_uid, "error": str(e), "error": str(e), "traceback": traceback.format_exc()},default=str)) + elif msg.type == aiohttp.WSMsgType.ERROR: + print('ws connection closed with exception %s' % ws.exception()) + + return ws + + class BroadCastSocketView: + def __init__(self): + self.ws = None + super() + + def format_result(self, result): + + try: + return dict(result) + except: + pass + try: + return [dict(row) for row in result] + except: + pass + return result + + async def send_str(self, msg): + return await self.ws.send_str(msg) + + def get(self, key): + returnl loads(dict(self.db['_kv'].get(key=key)['value'])) + + def set(self, key, value): + return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key']) + + + + async def handle(self, request): + ws = web.WebSocketResponse() + await ws.prepare(request) + self.ws = ws + app = request.app + app['broadcast_clients'].append(ws) + + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + print(msg.data) + for client in app['broadcast_clients'] if not client == ws: + await client.send_str(msg.data) + elif msg.type == aiohttp.WSMsgType.ERROR: + print('ws connection closed with exception %s' % ws.exception()) + app['broadcast_clients'].remove(ws) + return ws + + +app = web.Application() +view = DatasetWebSocketView() +app['broadcast_clients'] = [] +app.router.add_get('/db', view.handle) +app.router.add_get('/broadcast', sync_view.handle) + diff --git a/src/snek/templates/app.html b/src/snek/templates/app.html index a373c2d..7baa67a 100644 --- a/src/snek/templates/app.html +++ b/src/snek/templates/app.html @@ -16,6 +16,10 @@ + + + + +/* Ensure the dialog appears centered when open as modal */ +#online-users { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + border: none; + border-radius: 12px; + padding: 24px; + background-color: #111; /* Deep black */ + color: #f1f1f1; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.8); + width: 90%; + max-width: 400px; + + animation: fadeIn 0.3s ease-out, scaleIn 0.3s ease-out; + z-index: 1000; +} + +/* Backdrop styling */ +#online-users::backdrop { + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); +} + +/* Title and content */ +#online-users .dialog-title { + font-size: 1.5rem; + font-weight: bold; + margin-bottom: 16px; + color: #fff; +} + +#online-users .dialog-content { + font-size: 1rem; + color: #ccc; + margin-bottom: 20px; +} + +/* Button layout */ +#online-users .dialog-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +/* Buttons */ +#online-users .dialog-button { + padding: 8px 16px; + font-size: 0.95rem; + border-radius: 8px; + border: none; + cursor: pointer; + transition: background 0.2s ease; +} + +#online-users .dialog-button.primary { + background-color: #4f46e5; + color: white; +} + +#online-users .dialog-button.primary:hover { + background-color: #4338ca; +} + +#online-users .dialog-button.secondary { + background-color: #333; + color: #eee; +} + +#online-users .dialog-button.secondary:hover { + background-color: #444; +} + +/* Animations */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes scaleIn { + from { transform: scale(0.95) translate(-50%, -50%); opacity: 0; } + to { transform: scale(1) translate(-50%, -50%); opacity: 1; } +} + + + + + + + + diff --git a/src/snek/templates/web.html b/src/snek/templates/web.html index 689efd3..38f723c 100644 --- a/src/snek/templates/web.html +++ b/src/snek/templates/web.html @@ -9,19 +9,19 @@