Compare commits

...
Author SHA1 Message Date
retoor bde3819510 Update. 2025-06-01 00:24:45 +02:00
retoor 097889ba3f Make database asnyc. 2025-05-31 23:30:41 +02:00
retoor 4854d40508 Update. 2025-05-31 13:52:04 +02:00
retoor 7dd3133475 Merge pull request 'Add URL embedding functionality with metadata extraction and responsive design' (#48) from BordedDev/snek:feat/url-embedding into main
Reviewed-on: #48
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-30 19:53:22 +02:00
retoor 24dfa39f91 Update chat input. 2025-05-30 19:32:18 +02:00
BordedDev 7ec65f7c12 Cleaned up imports 2025-05-30 02:14:45 +02:00
BordedDev 4f8edef42b Add URL embedding functionality with metadata extraction and responsive design 2025-05-30 02:13:36 +02:00
retoor 7818410d55 Upddated interval. 2025-05-29 02:42:31 +02:00
retoor 1762191b03 Update. 2025-05-28 21:49:30 +02:00
retoor 2df92e809e Added iinput mode. 2025-05-28 13:40:12 +02:00
retoor 59a8d32e40 Added iinput mode. 2025-05-28 13:36:43 +02:00
retoor c3b3963760 Fix cross typing. 2025-05-28 12:31:17 +02:00
retoor a0cd39e3bc Fix cross typing. 2025-05-28 12:29:01 +02:00
retoor e48b2258e0 Made live typing default. 2025-05-28 11:38:16 +02:00
retoor 35aaf8824f Fixed directory does not exist bug. 2025-05-28 10:40:05 +02:00
11 changed files with 308 additions and 46 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ class ChatService(BaseService):
channel["last_message_on"] = now() channel["last_message_on"] = now()
await self.services.channel.save(channel) await self.services.channel.save(channel)
await self.services.socket.broadcast( await self.services.socket.broadcast(
channel_uid, channel['uid'],
{ {
"message": channel_message["message"], "message": channel_message["message"],
"html": channel_message["html"], "html": channel_message["html"],
+1 -1
View File
@@ -45,7 +45,7 @@ async def start_ssh_server(app,host,port):
logger.info("Starting SFTP server setup") logger.info("Starting SFTP server setup")
host_key_path = Path("drive") / ".ssh" / "sftp_server_key" host_key_path = Path("drive") / ".ssh" / "sftp_server_key"
host_key_path.parent.mkdir(exist_ok=True) host_key_path.parent.mkdir(exist_ok=True, parents=True)
try: try:
if not host_key_path.exists(): if not host_key_path.exists():
logger.info(f"Generating new host key at {host_key_path}") logger.info(f"Generating new host key at {host_key_path}")
+9 -3
View File
@@ -160,10 +160,14 @@ export class App extends EventHandler {
typeLock = null; typeLock = null;
typeListener = null; typeListener = null;
typeEventChannelUid = null; typeEventChannelUid = null;
_debug = false
async set_typing(channel_uid) { async set_typing(channel_uid) {
this.typeEventChannel_uid = channel_uid; this.typeEventChannel_uid = channel_uid;
} }
debug() {
this._debug = !this._debug;
this.ws._debug = this._debug;
}
async ping(...args) { async ping(...args) {
if (this.is_pinging) return false; if (this.is_pinging) return false;
this.is_pinging = true; this.is_pinging = true;
@@ -202,8 +206,10 @@ export class App extends EventHandler {
this.ws.addEventListener("channel-message", (data) => { this.ws.addEventListener("channel-message", (data) => {
me.emit("channel-message", data); me.emit("channel-message", data);
}); });
this.ws.addEventListener("event", (data) => { this.ws.addEventListener("data", (data) => {
console.info("aaaa"); if(this._debug){
console.debug(data)
}
}); });
this.rpc.getUser(null).then((user) => { this.rpc.getUser(null).then((user) => {
me.user = user; me.user = user;
+41
View File
@@ -544,3 +544,44 @@ dialog .dialog-button.secondary:hover {
} }
.embed-url-link {
display: flex;
flex-direction: column;
}
.embed-url-link img,
.embed-url-link video,
.embed-url-link iframe,
.embed-url-link div {
width: auto;
height: auto;
max-width: 100%;
max-height: 400px;
object-fit: contain;
border-radius: 12px 12px 0 0;
}
.embed-url-link .page-site {
font-size: 0.9em;
color: #aaa;
margin-bottom: 5px;
}
.embed-url-link .page-name {
font-size: 1.2em;
color: #f05a28;
margin-bottom: 5px;
}
.embed-url-link .page-description {
font-size: 1em;
color: #e6e6e6;
margin-bottom: 10px;
}
.embed-url-link .page-link {
font-size: 0.9em;
color: #f05a28;
text-decoration: none;
margin-top: 10px;
}
+27 -16
View File
@@ -4,13 +4,23 @@ class ChatInputComponent extends HTMLElement {
autoCompletions = { autoCompletions = {
"example 1": () => {}, "example 1": () => {},
"example 2": () => {}, "example 2": () => {},
}; }
hiddenCompletions = {
"/starsRender": () => {
app.rpc.starsRender(this.channelUid,this.value.replace("/starsRender ",""))
}
}
users = []
textarea = null
_value = ""
lastUpdateEvent = null
previousValue = ""
lastChange = null
changed = false
constructor() { constructor() {
super(); super();
this.lastUpdateEvent = new Date(); this.lastUpdateEvent = new Date();
this.textarea = document.createElement("textarea"); this.textarea = document.createElement("textarea");
this._value = "";
this.value = this.getAttribute("value") || ""; this.value = this.getAttribute("value") || "";
this.previousValue = this.value; this.previousValue = this.value;
this.lastChange = new Date(); this.lastChange = new Date();
@@ -25,12 +35,15 @@ class ChatInputComponent extends HTMLElement {
this._value = value || ""; this._value = value || "";
this.textarea.value = this._value; this.textarea.value = this._value;
} }
get allAutoCompletions() {
return Object.assign({},this.autoCompletions,this.hiddenCompletions)
}
resolveAutoComplete() { resolveAutoComplete() {
let count = 0; let count = 0;
let value = null; let value = null;
Object.keys(this.autoCompletions).forEach((key) => {
if (key.startsWith(this.value)) { Object.keys(this.allAutoCompletions).forEach((key) => {
if (key.startsWith(this.value.split(" ")[0])) {
count++; count++;
value = key; value = key;
} }
@@ -149,7 +162,7 @@ levenshteinDistance(a, b) {
const me = this; const me = this;
this.liveType = this.getAttribute("live-type") === "true"; this.liveType = this.getAttribute("live-type") === "true";
this.liveTypeInterval = this.liveTypeInterval =
parseInt(this.getAttribute("live-type-interval")) || 3; parseInt(this.getAttribute("live-type-interval")) || 6;
this.channelUid = this.getAttribute("channel"); this.channelUid = this.getAttribute("channel");
app.rpc.getRecentUsers(this.channelUid).then(users=>{ app.rpc.getRecentUsers(this.channelUid).then(users=>{
@@ -179,9 +192,6 @@ levenshteinDistance(a, b) {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
this.value = ""; this.value = "";
e.target.value = ""; e.target.value = "";
if(this.messageUid){
app.rpc.finalizeMessage(this.messageUid)
}
return; return;
} }
this.value = e.target.value; this.value = e.target.value;
@@ -191,9 +201,10 @@ levenshteinDistance(a, b) {
this.textarea.addEventListener("keydown", (e) => { this.textarea.addEventListener("keydown", (e) => {
this.value = e.target.value; this.value = e.target.value;
let autoCompletion = null;
if (e.key === "Tab") { if (e.key === "Tab") {
e.preventDefault(); e.preventDefault();
let autoCompletion = this.resolveAutoComplete(); autoCompletion = this.resolveAutoComplete();
if (autoCompletion) { if (autoCompletion) {
e.target.value = autoCompletion; e.target.value = autoCompletion;
this.value = autoCompletion; this.value = autoCompletion;
@@ -209,18 +220,18 @@ levenshteinDistance(a, b) {
if (!message) { if (!message) {
return; return;
} }
let autoCompletionHandler = this.allAutoCompletions[this.value.split(" ")[0]];
let autoCompletion = this.autoCompletions[message]; if (autoCompletionHandler) {
if (autoCompletion) { autoCompletionHandler();
this.value = ""; this.value = "";
this.previousValue = ""; this.previousValue = "";
e.target.value = ""; e.target.value = "";
autoCompletion();
return; return;
} }
this.updateMessage() this.updateMessage()
app.rpc.finalizeMessage(this.messageUid)
this.value = ""; this.value = "";
this.previousValue = ""; this.previousValue = "";
this.messageUid = null; this.messageUid = null;
+3 -3
View File
@@ -51,8 +51,6 @@ class MessageList extends HTMLElement {
return this.isElementVisible(this.querySelector(".message-list-bottom")); return this.isElementVisible(this.querySelector(".message-list-bottom"));
} }
scrollToBottom(force) { scrollToBottom(force) {
console.info("Scrolling down")
// if (force) {
this.scrollTop = this.scrollHeight; this.scrollTop = this.scrollHeight;
this.querySelector(".message-list-bottom").scrollIntoView(); this.querySelector(".message-list-bottom").scrollIntoView();
@@ -61,7 +59,6 @@ class MessageList extends HTMLElement {
this.scrollTop = this.scrollHeight; this.scrollTop = this.scrollHeight;
this.querySelector(".message-list-bottom").scrollIntoView(); this.querySelector(".message-list-bottom").scrollIntoView();
},200) },200)
// }
} }
updateMessageText(uid, message) { updateMessageText(uid, message) {
const messageDiv = this.querySelector('div[data-uid="' + uid + '"]'); const messageDiv = this.querySelector('div[data-uid="' + uid + '"]');
@@ -69,12 +66,15 @@ class MessageList extends HTMLElement {
if (!messageDiv) { if (!messageDiv) {
return; return;
} }
const scrollToBottom = this.isScrolledToBottom();
const receivedHtml = document.createElement("div"); const receivedHtml = document.createElement("div");
receivedHtml.innerHTML = message.html; receivedHtml.innerHTML = message.html;
const html = receivedHtml.querySelector(".text").innerHTML; const html = receivedHtml.querySelector(".text").innerHTML;
const textElement = messageDiv.querySelector(".text"); const textElement = messageDiv.querySelector(".text");
textElement.innerHTML = html; textElement.innerHTML = html;
textElement.style.display = message.text == "" ? "none" : "block"; textElement.style.display = message.text == "" ? "none" : "block";
if(scrollToBottom)
this.scrollToBottom(true)
} }
triggerGlow(uid,color) { triggerGlow(uid,color) {
app.starField.glowColor(color) app.starField.glowColor(color)
+7
View File
@@ -17,6 +17,8 @@ export class Socket extends EventHandler {
shouldReconnect = true; shouldReconnect = true;
_debug = false;
get isConnected() { get isConnected() {
return this.ws && this.ws.readyState === WebSocket.OPEN; return this.ws && this.ws.readyState === WebSocket.OPEN;
} }
@@ -112,6 +114,11 @@ export class Socket extends EventHandler {
get(_, prop) { get(_, prop) {
return (...args) => { return (...args) => {
const functionName = me._camelToSnake(prop); const functionName = me._camelToSnake(prop);
if(me._debug){
const call = {}
call[functionName] = args
console.debug(call)
}
return me.call(functionName, ...args); return me.call(functionName, ...args);
}; };
}, },
+16 -8
View File
@@ -1,6 +1,6 @@
DEFAULT_LIMIT = 30 DEFAULT_LIMIT = 30
import typing import typing
import asyncio
from snek.system.model import BaseModel from snek.system.model import BaseModel
@@ -19,6 +19,13 @@ class BaseMapper:
def db(self): def db(self):
return self.app.db return self.app.db
@property
def loop(self):
return asyncio.get_event_loop()
async def run_in_executor(self, func, *args, **kwargs):
return await self.loop.run_in_executor(None, lambda: func(*args, **kwargs))
async def new(self): async def new(self):
return self.model_class(mapper=self, app=self.app) return self.model_class(mapper=self, app=self.app)
@@ -29,7 +36,8 @@ class BaseMapper:
async def get(self, uid: str = None, **kwargs) -> BaseModel: async def get(self, uid: str = None, **kwargs) -> BaseModel:
if uid: if uid:
kwargs["uid"] = uid kwargs["uid"] = uid
record = self.table.find_one(**kwargs)
record = await self.run_in_executor(self.table.find_one,**kwargs)
if not record: if not record:
return None return None
record = dict(record) record = dict(record)
@@ -40,31 +48,31 @@ class BaseMapper:
return await self.model_class.from_record(mapper=self, record=record) return await self.model_class.from_record(mapper=self, record=record)
async def exists(self, **kwargs): async def exists(self, **kwargs):
return self.table.exists(**kwargs) return await self.run_in_executor(self.table.exists,**kwargs)
async def count(self, **kwargs) -> int: async def count(self, **kwargs) -> int:
return self.table.count(**kwargs) return await self.run_in_executor(self.table.count, **kwargs)
async def save(self, model: BaseModel) -> bool: async def save(self, model: BaseModel) -> bool:
if not model.record.get("uid"): if not model.record.get("uid"):
raise Exception(f"Attempt to save without uid: {model.record}.") raise Exception(f"Attempt to save without uid: {model.record}.")
model.updated_at.update() model.updated_at.update()
return self.table.upsert(model.record, ["uid"]) return await self.run_in_executor(self.table.upsert, model.record, ["uid"])
async def find(self, **kwargs) -> typing.AsyncGenerator: async def find(self, **kwargs) -> typing.AsyncGenerator:
if not kwargs.get("_limit"): if not kwargs.get("_limit"):
kwargs["_limit"] = self.default_limit kwargs["_limit"] = self.default_limit
for record in self.table.find(**kwargs): for record in await self.run_in_executor(self.table.find, **kwargs):
model = await self.new() model = await self.new()
for key, value in record.items(): for key, value in record.items():
model[key] = value model[key] = value
yield model yield model
async def query(self, sql, *args): async def query(self, sql, *args):
for record in self.db.query(sql, *args): for record in await self.run_in_executor(self.db.query,sql, *args):
yield dict(record) yield dict(record)
async def delete(self, **kwargs) -> int: async def delete(self, **kwargs) -> int:
if not kwargs or not isinstance(kwargs, dict): if not kwargs or not isinstance(kwargs, dict):
raise Exception("Can't execute delete with no filter.") raise Exception("Can't execute delete with no filter.")
return self.table.delete(**kwargs) return await self.run_in_executor(self.table.delete, **kwargs)
+156 -4
View File
@@ -1,9 +1,11 @@
import re
from urllib.parse import urlparse, parse_qs
from types import SimpleNamespace
import mimetypes import mimetypes
import re
from functools import lru_cache
from types import SimpleNamespace
from urllib.parse import urlparse, parse_qs
from app.cache import time_cache
import emoji import emoji
import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from jinja2 import TemplateSyntaxError, nodes from jinja2 import TemplateSyntaxError, nodes
from jinja2.ext import Extension from jinja2.ext import Extension
@@ -231,6 +233,153 @@ def linkify_https(text):
return set_link_target_blank(str(soup)) return set_link_target_blank(str(soup))
@time_cache(timeout=60*60)
def get_url_content(url):
try:
response = requests.get(url, timeout=5)
response.raise_for_status()
return response.text
except Exception as e:
print(f"Error fetching {url}: {e}")
return None
def embed_url(text):
soup = BeautifulSoup(text, "html.parser")
attachments = {}
for element in soup.find_all("a"):
if "href" in element.attrs and element.attrs["href"].startswith("http"):
page_url = urlparse(element.attrs["href"])
page = get_url_content(element.attrs["href"])
if page:
parsed_page = BeautifulSoup(page, "html.parser")
head_info = parsed_page.find("head")
if head_info:
def get_element_options(
elem=None, meta=None, ograph=None, twitter=None
):
if twitter:
tw_tag = head_info.find(
"meta", attrs={"name": "twitter:" + twitter}
) or head_info.find(
"meta", attrs={"property": "twitter:" + twitter}
)
if tw_tag:
return tw_tag.get("content", tw_tag.get("value", None))
if ograph:
og_tag = head_info.find(
"meta", attrs={"property": "og:" + ograph}
) or head_info.find("meta", attrs={"name": "og:" + ograph})
if og_tag:
return og_tag.get("content", og_tag.get("value", None))
if meta:
meta_tag = head_info.find(
"meta", attrs={"name": meta}
) or head_info.find("meta", attrs={"property": meta})
if meta_tag:
return meta_tag.get(
"content", meta_tag.get("value", None)
)
if elem:
elem_tag = head_info.find(elem)
if elem_tag:
return elem_tag.text
return None
original_link_name = element.attrs["href"]
if original_link_name in attachments:
continue
page_name = (
get_element_options("title", "title", "title", "title")
or page_url.netloc
)
page_site = (
get_element_options(None, "site", "site", "site")
or page_url.netloc
)
page_description = get_element_options(
None, "description", "description", "description"
)
page_image = get_element_options(None, "image", "image", "image")
page_image_alt = get_element_options(
None, "image:alt", "image:alt", "image:alt"
)
page_video = get_element_options(None, "video", "video", "video")
page_audio = get_element_options(None, "audio", "audio", "audio")
preview_size = (
get_element_options(None, None, None, "card")
or "summary_large_image"
)
attachment_base = BeautifulSoup(str(element), "html.parser")
attachments[original_link_name] = attachment_base
attachment = next(attachment_base.children)
attachment.clear()
attachment.attrs["class"] = "embed-url-link"
render_element = attachment
if page_image:
image_template = f'<span><img src="{page_image}" alt="{page_image_alt or page_name}" title="{page_name}" width="420" height="240" /></span>'
render_element.append(
BeautifulSoup(image_template, "html.parser")
)
if page_video:
video_template = f'<video controls><source src="{page_video}">Your browser does not support the video tag.</video>'
render_element.append(
BeautifulSoup(video_template, "html.parser")
)
if page_audio:
audio_template = f'<audio controls><source src="{page_audio}">Your browser does not support the audio tag.</audio>'
render_element.append(
BeautifulSoup(audio_template, "html.parser")
)
description_element_base = BeautifulSoup(
"<span class='description'></span>", "html.parser"
)
description_element = next(description_element_base.children)
description_element.append(
BeautifulSoup(
f'<p class="page-site">{page_site}</p>',
"html.parser",
)
)
description_element.append(
BeautifulSoup(f'<strong class="page-name">{page_name}</strong>', "html.parser")
)
description_element.append(
BeautifulSoup(f"<p class='page-description'>{page_description or "No description available."}</p>", "html.parser")
)
description_element.append(
BeautifulSoup(f"<p class='page-original-link'>{original_link_name}</p>", "html.parser")
)
render_element.append(description_element_base)
for attachment in attachments.values():
soup.append(attachment)
return str(soup)
class EmojiExtension(Extension): class EmojiExtension(Extension):
tags = {"emoji"} tags = {"emoji"}
@@ -276,6 +425,9 @@ class LinkifyExtension(Extension):
result = embed_youtube(result) result = embed_youtube(result)
result = enrich_image_rendering(result) result = enrich_image_rendering(result)
result = embed_url(result)
return result return result
+29 -1
View File
@@ -13,7 +13,7 @@
{% endfor %} {% endfor %}
<div class="message-list-bottom"></div> <div class="message-list-bottom"></div>
</message-list> </message-list>
<chat-input live-type="false" channel="{{ channel.uid.value }}"></chat-input> <chat-input live-type="true" channel="{{ channel.uid.value }}"></chat-input>
</section> </section>
{% include "dialog_help.html" %} {% include "dialog_help.html" %}
{% include "dialog_online.html" %} {% include "dialog_online.html" %}
@@ -157,6 +157,10 @@ app.ws.addEventListener("refresh", (data) => {
app.starField.showNotify(data.message); app.starField.showNotify(data.message);
setTimeout(() => window.location.reload(), 4000); setTimeout(() => window.location.reload(), 4000);
}); });
app.ws.addEventListener("stars_render", (data)=>{
app.starField.renderWord(data.message, { rainbow: true, resolution: 8 });
setTimeout(() => app.starField.shuffleAll(5000), 10000);
})
app.ws.addEventListener("deployed", (data) => { app.ws.addEventListener("deployed", (data) => {
app.starField.renderWord("Deployed", { rainbow: true, resolution: 8 }); app.starField.renderWord("Deployed", { rainbow: true, resolution: 8 });
setTimeout(() => app.starField.shuffleAll(5000), 10000); setTimeout(() => app.starField.shuffleAll(5000), 10000);
@@ -167,7 +171,9 @@ app.ws.addEventListener("starfield.render_word", (data) => {
// --- Channel message event --- // --- Channel message event ---
app.addEventListener("channel-message", (data) => { app.addEventListener("channel-message", (data) => {
let display = data.text && data.text.trim() ? 'block' : 'none'; let display = data.text && data.text.trim() ? 'block' : 'none';
if (data.channel_uid !== channelUid) { if (data.channel_uid !== channelUid) {
if (!isMentionForSomeoneElse(data.message)) { if (!isMentionForSomeoneElse(data.message)) {
channelSidebar.notify(data); channelSidebar.notify(data);
@@ -179,12 +185,22 @@ app.addEventListener("channel-message", (data) => {
if (isMentionToMe(data.message)) { if (isMentionToMe(data.message)) {
app.playSound("mention"); app.playSound("mention");
} else if (!isMentionForSomeoneElse(data.message)) { } else if (!isMentionForSomeoneElse(data.message)) {
if(data.is_final){
app.playSound("message"); app.playSound("message");
} }
} }
}
const lastElement = messagesContainer.querySelector(".message-list-bottom"); const lastElement = messagesContainer.querySelector(".message-list-bottom");
const doScrollDown = messagesContainer.isScrolledToBottom(); const doScrollDown = messagesContainer.isScrolledToBottom();
const oldMessage = messagesContainer.querySelector(`.message[data-uid="${data.uid}"]`);
if (oldMessage) {
oldMessage.remove();
}
const message = document.createElement("div"); const message = document.createElement("div");
message.innerHTML = data.html; message.innerHTML = data.html;
message.style.display = display; message.style.display = display;
messagesContainer.insertBefore(message.firstChild, lastElement); messagesContainer.insertBefore(message.firstChild, lastElement);
@@ -210,6 +226,18 @@ document.addEventListener('keydown', function(event) {
loadExtra(); loadExtra();
} }
} }
if(event.key == 'i' && !chatInputField.isActive()) {
event.preventDefault();
chatInputField.focus();
}
if(event.key == 'r' && !chatInputField.isActive()) {
event.preventDefault();
const replyLinks = document.querySelectorAll('.time a')
if(replyLinks.length){
const replyLink = replyLinks[replyLinks.length - 1]
replyLink.click()
}
}
if (event.shiftKey && event.key === 'G') { if (event.shiftKey && event.key === 'G') {
if (!chatInputField.isActive()) { if (!chatInputField.isActive()) {
event.preventDefault(); event.preventDefault();
+11 -2
View File
@@ -196,6 +196,9 @@ class RPCView(BaseView):
async def finalize_message(self, message_uid): async def finalize_message(self, message_uid):
self._require_login() self._require_login()
message = await self.services.channel_message.get(message_uid) message = await self.services.channel_message.get(message_uid)
if not message:
return False
if message["user_uid"] != self.user_uid: if message["user_uid"] != self.user_uid:
raise Exception("Not allowed") raise Exception("Not allowed")
@@ -203,7 +206,7 @@ class RPCView(BaseView):
if not message['is_final']: if not message['is_final']:
await self.services.chat.finalize(message['uid']) await self.services.chat.finalize(message['uid'])
return {"success": True} return True
async def update_message_text(self,message_uid, text): async def update_message_text(self,message_uid, text):
self._require_login() self._require_login()
@@ -377,7 +380,13 @@ class RPCView(BaseView):
return {"pong": args} return {"pong": args}
async def stars_render(self, channel_uid, message):
for user in await self.get_online_users(channel_uid):
try:
await self.services.socket.send_to_user(user['uid'], dict(event="stars_render", data={"channel_uid": channel_uid, "message":message}))
except Exception as ex:
print(ex)
async def get(self): async def get(self):
scheduled = [] scheduled = []
@@ -404,7 +413,7 @@ class RPCView(BaseView):
"message": "Finishing deployment"} "message": "Finishing deployment"}
} }
) )
await schedule(self.request.session.get("uid"),10,{"event": "deployed", "data": { await schedule(self.request.session.get("uid"),15,{"event": "deployed", "data": {
"uptime": self.request.app.uptime} "uptime": self.request.app.uptime}
} }
) )