Compare commits
6 Commits
Author | SHA1 | Date | |
---|---|---|---|
04527c286f | |||
e23d6571c8 | |||
0c331bbb93 | |||
a2d506cce9 | |||
![]() |
3a4cf93bcc | ||
![]() |
29b9fce07d |
src/snek
@ -31,6 +31,7 @@ from snek.sgit import GitApplication
|
|||||||
from snek.sssh import start_ssh_server
|
from snek.sssh import start_ssh_server
|
||||||
from snek.system import http
|
from snek.system import http
|
||||||
from snek.system.cache import Cache
|
from snek.system.cache import Cache
|
||||||
|
from snek.system.stats import middleware as stats_middleware, create_stats_structure, stats_handler
|
||||||
from snek.system.markdown import MarkdownExtension
|
from snek.system.markdown import MarkdownExtension
|
||||||
from snek.system.middleware import auth_middleware, cors_middleware, csp_middleware
|
from snek.system.middleware import auth_middleware, cors_middleware, csp_middleware
|
||||||
from snek.system.profiler import profiler_handler
|
from snek.system.profiler import profiler_handler
|
||||||
@ -127,6 +128,7 @@ async def trailing_slash_middleware(request, handler):
|
|||||||
class Application(BaseApplication):
|
class Application(BaseApplication):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
middlewares = [
|
middlewares = [
|
||||||
|
stats_middleware,
|
||||||
cors_middleware,
|
cors_middleware,
|
||||||
web.normalize_path_middleware(merge_slashes=True),
|
web.normalize_path_middleware(merge_slashes=True),
|
||||||
ip2location_middleware,
|
ip2location_middleware,
|
||||||
@ -168,11 +170,17 @@ class Application(BaseApplication):
|
|||||||
self.ip2location = IP2Location.IP2Location(
|
self.ip2location = IP2Location.IP2Location(
|
||||||
base_path.joinpath("IP2LOCATION-LITE-DB11.BIN")
|
base_path.joinpath("IP2LOCATION-LITE-DB11.BIN")
|
||||||
)
|
)
|
||||||
|
self.on_startup.append(self.prepare_stats)
|
||||||
self.on_startup.append(self.prepare_asyncio)
|
self.on_startup.append(self.prepare_asyncio)
|
||||||
self.on_startup.append(self.start_user_availability_service)
|
self.on_startup.append(self.start_user_availability_service)
|
||||||
self.on_startup.append(self.start_ssh_server)
|
self.on_startup.append(self.start_ssh_server)
|
||||||
self.on_startup.append(self.prepare_database)
|
self.on_startup.append(self.prepare_database)
|
||||||
|
|
||||||
|
async def prepare_stats(self, app):
|
||||||
|
app['stats'] = create_stats_structure()
|
||||||
|
print("Stats prepared", flush=True)
|
||||||
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def uptime_seconds(self):
|
def uptime_seconds(self):
|
||||||
return (datetime.now() - self.time_start).total_seconds()
|
return (datetime.now() - self.time_start).total_seconds()
|
||||||
@ -308,6 +316,7 @@ class Application(BaseApplication):
|
|||||||
self.router.add_view("/drive.json", DriveApiView)
|
self.router.add_view("/drive.json", DriveApiView)
|
||||||
self.router.add_view("/drive.html", DriveView)
|
self.router.add_view("/drive.html", DriveView)
|
||||||
self.router.add_view("/drive/{drive}.json", DriveView)
|
self.router.add_view("/drive/{drive}.json", DriveView)
|
||||||
|
self.router.add_get("/stats.html", stats_handler)
|
||||||
self.router.add_view("/stats.json", StatsView)
|
self.router.add_view("/stats.json", StatsView)
|
||||||
self.router.add_view("/user/{user}.html", UserView)
|
self.router.add_view("/user/{user}.html", UserView)
|
||||||
self.router.add_view("/repository/{username}/{repository}", RepositoryView)
|
self.router.add_view("/repository/{username}/{repository}", RepositoryView)
|
||||||
|
@ -5,7 +5,6 @@ from snek.system.template import whitelist_attributes
|
|||||||
class ChannelMessageService(BaseService):
|
class ChannelMessageService(BaseService):
|
||||||
mapper_name = "channel_message"
|
mapper_name = "channel_message"
|
||||||
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self._configured_indexes = False
|
self._configured_indexes = False
|
||||||
@ -18,7 +17,7 @@ class ChannelMessageService(BaseService):
|
|||||||
html = message["html"]
|
html = message["html"]
|
||||||
await self.save(message)
|
await self.save(message)
|
||||||
|
|
||||||
self.mapper.db['channel_message'].upsert(
|
self.mapper.db["channel_message"].upsert(
|
||||||
{
|
{
|
||||||
"uid": message["uid"],
|
"uid": message["uid"],
|
||||||
"updated_at": updated_at,
|
"updated_at": updated_at,
|
||||||
@ -41,7 +40,6 @@ class ChannelMessageService(BaseService):
|
|||||||
if not changed:
|
if not changed:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
async def create(self, channel_uid, user_uid, message, is_final=True):
|
async def create(self, channel_uid, user_uid, message, is_final=True):
|
||||||
model = await self.new()
|
model = await self.new()
|
||||||
|
|
||||||
@ -72,12 +70,18 @@ class ChannelMessageService(BaseService):
|
|||||||
|
|
||||||
if await super().save(model):
|
if await super().save(model):
|
||||||
if not self._configured_indexes:
|
if not self._configured_indexes:
|
||||||
if not self.mapper.db["channel_message"].has_index(['is_final','user_uid','channel_uid']):
|
if not self.mapper.db["channel_message"].has_index(
|
||||||
self.mapper.db["channel_message"].create_index(['is_final','user_uid','channel_uid'], unique=False)
|
["is_final", "user_uid", "channel_uid"]
|
||||||
if not self.mapper.db["channel_message"].has_index(['uid']):
|
):
|
||||||
self.mapper.db["channel_message"].create_index(['uid'], unique=True)
|
self.mapper.db["channel_message"].create_index(
|
||||||
if not self.mapper.db["channel_message"].has_index(['deleted_at']):
|
["is_final", "user_uid", "channel_uid"], unique=False
|
||||||
self.mapper.db["channel_message"].create_index(['deleted_at'], unique=False)
|
)
|
||||||
|
if not self.mapper.db["channel_message"].has_index(["uid"]):
|
||||||
|
self.mapper.db["channel_message"].create_index(["uid"], unique=True)
|
||||||
|
if not self.mapper.db["channel_message"].has_index(["deleted_at"]):
|
||||||
|
self.mapper.db["channel_message"].create_index(
|
||||||
|
["deleted_at"], unique=False
|
||||||
|
)
|
||||||
self._configured_indexes = True
|
self._configured_indexes = True
|
||||||
return model
|
return model
|
||||||
raise Exception(f"Failed to create channel message: {model.errors}.")
|
raise Exception(f"Failed to create channel message: {model.errors}.")
|
||||||
@ -86,6 +90,11 @@ class ChannelMessageService(BaseService):
|
|||||||
user = await self.services.user.get(uid=message["user_uid"])
|
user = await self.services.user.get(uid=message["user_uid"])
|
||||||
if not user:
|
if not user:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
if not message["html"].startswith("<chat-message"):
|
||||||
|
message = await self.get(uid=message["uid"])
|
||||||
|
await self.save(message)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"uid": message["uid"],
|
"uid": message["uid"],
|
||||||
"color": user["color"],
|
"color": user["color"],
|
||||||
@ -115,7 +124,6 @@ class ChannelMessageService(BaseService):
|
|||||||
model["html"] = whitelist_attributes(model["html"])
|
model["html"] = whitelist_attributes(model["html"])
|
||||||
return await super().save(model)
|
return await super().save(model)
|
||||||
|
|
||||||
|
|
||||||
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):
|
async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):
|
||||||
channel = await self.services.channel.get(uid=channel_uid)
|
channel = await self.services.channel.get(uid=channel_uid)
|
||||||
if not channel:
|
if not channel:
|
||||||
|
@ -144,7 +144,7 @@ footer {
|
|||||||
|
|
||||||
.chat-messages {
|
.chat-messages {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@ -368,7 +368,7 @@ input[type="text"], .chat-input textarea {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.message:has(+ .message.switch-user), .message:has(+ .message.long-time), .message:not(:has(+ .message)) {
|
.message.switch-user + .message, .message.long-time + .message, .message:first-child {
|
||||||
.time {
|
.time {
|
||||||
display: block;
|
display: block;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
@ -298,7 +298,9 @@ textToLeetAdvanced(text) {
|
|||||||
this.appendChild(this.uploadButton);
|
this.appendChild(this.uploadButton);
|
||||||
|
|
||||||
this.textarea.addEventListener("blur", () => {
|
this.textarea.addEventListener("blur", () => {
|
||||||
this.updateFromInput("");
|
this.updateFromInput(this.value, true).then(
|
||||||
|
this.updateFromInput("")
|
||||||
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
this.subscribe("file-uploads-done", (data)=>{
|
this.subscribe("file-uploads-done", (data)=>{
|
||||||
@ -417,7 +419,7 @@ textToLeetAdvanced(text) {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
updateFromInput(value) {
|
updateFromInput(value, isFinal = false) {
|
||||||
|
|
||||||
this.value = value;
|
this.value = value;
|
||||||
|
|
||||||
@ -425,7 +427,7 @@ textToLeetAdvanced(text) {
|
|||||||
|
|
||||||
if (this.liveType && value[0] !== "/") {
|
if (this.liveType && value[0] !== "/") {
|
||||||
const messageText = this.replaceMentionsWithAuthors(value);
|
const messageText = this.replaceMentionsWithAuthors(value);
|
||||||
this.messageUid = this.sendMessage(this.channelUid, messageText, !this.liveType);
|
this.messageUid = this.sendMessage(this.channelUid, messageText, !this.liveType || isFinal);
|
||||||
return this.messageUid;
|
return this.messageUid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,48 +5,179 @@
|
|||||||
// 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.
|
// 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.
|
// 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";
|
import {app} from "./app.js";
|
||||||
|
|
||||||
|
const LONG_TIME = 1000 * 60 * 20
|
||||||
|
|
||||||
|
class MessageElement extends HTMLElement {
|
||||||
|
static observedAttributes = ['data-uid', 'data-color', 'data-channel_uid', 'data-user_nick', 'data-created_at', 'data-user_uid'];
|
||||||
|
|
||||||
|
isVisible() {
|
||||||
|
if (!this) return false;
|
||||||
|
const rect = this.getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
rect.top >= 0 &&
|
||||||
|
rect.left >= 0 &&
|
||||||
|
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
||||||
|
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUI() {
|
||||||
|
if (this._originalChildren === undefined) {
|
||||||
|
const { color, user_nick, created_at, user_uid} = this.dataset;
|
||||||
|
this.classList.add('message');
|
||||||
|
this.style.maxWidth = '100%';
|
||||||
|
this._originalChildren = Array.from(this.children);
|
||||||
|
this.innerHTML = `
|
||||||
|
<a class="avatar" style="background-color: ${color || ''}; color: black;" href="/user/${user_uid || ''}.html">
|
||||||
|
<img class="avatar-img" width="40" height="40" src="/avatar/${user_uid || ''}.svg" alt="${user_nick || ''}" loading="lazy">
|
||||||
|
</a>
|
||||||
|
<div class="message-content">
|
||||||
|
<div class="author" style="color: ${color || ''};">${user_nick || ''}</div>
|
||||||
|
<div class="text"></div>
|
||||||
|
<div class="time no-select" data-created_at="${created_at || ''}">
|
||||||
|
<span></span>
|
||||||
|
<a href="#reply">reply</a></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.messageDiv = this.querySelector('.text');
|
||||||
|
|
||||||
|
if (this._originalChildren && this._originalChildren.length > 0) {
|
||||||
|
this._originalChildren.forEach(child => {
|
||||||
|
this.messageDiv.appendChild(child);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.timeDiv = this.querySelector('.time span');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.siblingGenerated && this.nextElementSibling) {
|
||||||
|
this.siblingGenerated = true;
|
||||||
|
if (this.nextElementSibling?.dataset?.user_uid !== this.dataset.user_uid) {
|
||||||
|
this.classList.add('switch-user');
|
||||||
|
} else {
|
||||||
|
this.classList.remove('switch-user');
|
||||||
|
const siblingTime = new Date(this.nextElementSibling.dataset.created_at);
|
||||||
|
const currentTime = new Date(this.dataset.created_at);
|
||||||
|
|
||||||
|
if (currentTime.getTime() - siblingTime.getTime() > LONG_TIME) {
|
||||||
|
this.classList.add('long-time');
|
||||||
|
} else {
|
||||||
|
this.classList.remove('long-time');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.timeDiv.innerText = app.timeDescription(this.dataset.created_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMessage(...messages) {
|
||||||
|
if (this._originalChildren) {
|
||||||
|
this.messageDiv.replaceChildren(...messages)
|
||||||
|
this._originalChildren = messages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedMoveCallback() {
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback(name, oldValue, newValue) {
|
||||||
|
this.updateUI()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class MessageList extends HTMLElement {
|
class MessageList extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
app.ws.addEventListener("update_message_text", (data) => {
|
app.ws.addEventListener("update_message_text", (data) => {
|
||||||
this.updateMessageText(data.uid, data);
|
this.upsertMessage(data);
|
||||||
});
|
});
|
||||||
app.ws.addEventListener("set_typing", (data) => {
|
app.ws.addEventListener("set_typing", (data) => {
|
||||||
this.triggerGlow(data.user_uid,data.color);
|
this.triggerGlow(data.user_uid,data.color);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.items = [];
|
this.messageMap = new Map();
|
||||||
|
this.visibleSet = new Set();
|
||||||
|
this._observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
this.visibleSet.add(entry.target);
|
||||||
|
const messageElement = entry.target;
|
||||||
|
if (messageElement instanceof MessageElement) {
|
||||||
|
messageElement.updateUI();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.visibleSet.delete(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(this.visibleSet);
|
||||||
|
}, {
|
||||||
|
root: this,
|
||||||
|
threshold: 0.1
|
||||||
|
})
|
||||||
|
|
||||||
|
for(const c of this.children) {
|
||||||
|
this._observer.observe(c);
|
||||||
|
if (c instanceof MessageElement) {
|
||||||
|
this.messageMap.set(c.dataset.uid, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.scrollToBottom(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
const messagesContainer = this
|
this.addEventListener('click', (e) => {
|
||||||
messagesContainer.addEventListener('click', (e) => {
|
|
||||||
if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return;
|
if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return;
|
||||||
|
|
||||||
const img = e.target;
|
const img = e.target;
|
||||||
|
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;justify-content:center;align-items:center;z-index:9999;'
|
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;justify-content:center;align-items:center;z-index:9999;cursor:pointer;';
|
||||||
|
|
||||||
const urlObj = new URL(img.currentSrc || img.src)
|
const urlObj = new URL(img.currentSrc || img.src, window.location.origin);
|
||||||
urlObj.searchParams.delete("width");
|
urlObj.searchParams.delete('width');
|
||||||
urlObj.searchParams.delete("height");
|
urlObj.searchParams.delete('height');
|
||||||
|
|
||||||
const fullImg = document.createElement('img');
|
const fullImg = document.createElement('img');
|
||||||
|
|
||||||
fullImg.src = urlObj.toString();
|
fullImg.src = urlObj.toString();
|
||||||
fullImg.alt = img.alt;
|
fullImg.alt = img.alt || '';
|
||||||
fullImg.style.maxWidth = '90%';
|
fullImg.style.maxWidth = '90%';
|
||||||
fullImg.style.maxHeight = '90%';
|
fullImg.style.maxHeight = '90%';
|
||||||
|
fullImg.style.boxShadow = '0 0 32px #000';
|
||||||
|
fullImg.style.borderRadius = '8px';
|
||||||
|
fullImg.style.background = '#222';
|
||||||
|
fullImg.style.objectFit = 'contain';
|
||||||
|
fullImg.loading = 'lazy';
|
||||||
|
|
||||||
overlay.appendChild(fullImg);
|
overlay.appendChild(fullImg);
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
overlay.addEventListener('click', () => document.body.removeChild(overlay));
|
overlay.addEventListener('click', () => {
|
||||||
|
if (overlay.parentNode) {
|
||||||
|
overlay.parentNode.removeChild(overlay);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Optional: ESC key closes overlay
|
||||||
|
const escListener = (evt) => {
|
||||||
|
if (evt.key === 'Escape') {
|
||||||
|
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||||
|
document.removeEventListener('keydown', escListener);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', escListener);
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
isElementVisible(element) {
|
isElementVisible(element) {
|
||||||
|
if (!element) return false;
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
return (
|
return (
|
||||||
rect.top >= 0 &&
|
rect.top >= 0 &&
|
||||||
@ -54,63 +185,72 @@ class MessageList extends HTMLElement {
|
|||||||
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
||||||
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
isScrolledToBottom() {
|
isScrolledToBottom() {
|
||||||
return this.isElementVisible(this.querySelector(".message-list-bottom"));
|
return this.isElementVisible(this.firstElementChild);
|
||||||
}
|
}
|
||||||
scrollToBottom(force) {
|
scrollToBottom(force = false, behavior= 'smooth') {
|
||||||
//this.scrollTop = this.scrollHeight;
|
if (force || this.isScrolledToBottom()) {
|
||||||
|
this.firstElementChild.scrollIntoView({ behavior, block: 'start' });
|
||||||
this.querySelector(".message-list-bottom").scrollIntoView();
|
|
||||||
this.querySelector(".message-list-bottom").scrollIntoView();
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
this.firstElementChild.scrollIntoView({ behavior, block: 'start' });
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// this.scrollTop = this.scrollHeight;
|
triggerGlow(uid, color) {
|
||||||
this.querySelector(".message-list-bottom").scrollIntoView();
|
if (!uid || !color) return;
|
||||||
},200)
|
app.starField.glowColor(color);
|
||||||
}
|
|
||||||
updateMessageText(uid, message) {
|
|
||||||
const messageDiv = this.querySelector('div[data-uid="' + uid + '"]');
|
|
||||||
|
|
||||||
if (!messageDiv) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const scrollToBottom = this.isScrolledToBottom();
|
|
||||||
const receivedHtml = document.createElement("div");
|
|
||||||
receivedHtml.innerHTML = message.html;
|
|
||||||
const html = receivedHtml.querySelector(".text").innerHTML;
|
|
||||||
const textElement = messageDiv.querySelector(".text");
|
|
||||||
textElement.innerHTML = html;
|
|
||||||
textElement.style.display = message.text == "" ? "none" : "block";
|
|
||||||
if(scrollToBottom)
|
|
||||||
this.scrollToBottom(true)
|
|
||||||
}
|
|
||||||
triggerGlow(uid,color) {
|
|
||||||
app.starField.glowColor(color)
|
|
||||||
let lastElement = null;
|
let lastElement = null;
|
||||||
this.querySelectorAll(".avatar").forEach((el) => {
|
this.querySelectorAll('.avatar').forEach((el) => {
|
||||||
const div = el.closest("a");
|
const anchor = el.closest('a');
|
||||||
if (el.href.indexOf(uid) != -1) {
|
if (anchor && typeof anchor.href === 'string' && anchor.href.includes(uid)) {
|
||||||
lastElement = el;
|
lastElement = el;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (lastElement) {
|
if (lastElement) {
|
||||||
lastElement.classList.add("glow");
|
lastElement.classList.add('glow');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
lastElement.classList.remove("glow");
|
lastElement.classList.remove('glow');
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
set data(items) {
|
|
||||||
this.items = items;
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
render() {
|
|
||||||
this.innerHTML = "";
|
|
||||||
|
|
||||||
//this.insertAdjacentHTML("beforeend", html);
|
updateTimes() {
|
||||||
|
this.visibleSet.forEach((messageElement) => {
|
||||||
|
if (messageElement instanceof MessageElement) {
|
||||||
|
messageElement.updateUI();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertMessage(data) {
|
||||||
|
let message = this.messageMap.get(data.uid);
|
||||||
|
const newMessage = !!message;
|
||||||
|
if (message) {
|
||||||
|
message.parentElement.removeChild(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.message) return
|
||||||
|
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
|
||||||
|
wrapper.innerHTML = data.html;
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
message.updateMessage(...wrapper.firstElementChild._originalChildren);
|
||||||
|
} else {
|
||||||
|
message = wrapper.firstElementChild;
|
||||||
|
this.messageMap.set(data.uid, message);
|
||||||
|
this._observer.observe(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrolledToBottom = this.isScrolledToBottom();
|
||||||
|
this.prepend(message);
|
||||||
|
if (scrolledToBottom) this.scrollToBottom(true, !newMessage ? 'smooth' : 'auto');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
customElements.define("chat-message", MessageElement);
|
||||||
customElements.define("message-list", MessageList);
|
customElements.define("message-list", MessageList);
|
||||||
|
129
src/snek/system/stats.py
Normal file
129
src/snek/system/stats.py
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import asyncio
|
||||||
|
from aiohttp import web, WSMsgType
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from collections import defaultdict
|
||||||
|
import html
|
||||||
|
|
||||||
|
def create_stats_structure():
|
||||||
|
"""Creates the nested dictionary structure for storing statistics."""
|
||||||
|
def nested_dd():
|
||||||
|
return defaultdict(lambda: defaultdict(int))
|
||||||
|
return defaultdict(nested_dd)
|
||||||
|
|
||||||
|
def get_time_keys(dt: datetime):
|
||||||
|
"""Generates dictionary keys for different time granularities."""
|
||||||
|
return {
|
||||||
|
"hour": dt.strftime('%Y-%m-%d-%H'),
|
||||||
|
"day": dt.strftime('%Y-%m-%d'),
|
||||||
|
"week": dt.strftime('%Y-%W'), # Week number, Monday is first day
|
||||||
|
"month": dt.strftime('%Y-%m'),
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_stats_counters(stats_dict: defaultdict, now: datetime):
|
||||||
|
"""Increments the appropriate time-based counters in a stats dictionary."""
|
||||||
|
keys = get_time_keys(now)
|
||||||
|
stats_dict['by_hour'][keys['hour']] += 1
|
||||||
|
stats_dict['by_day'][keys['day']] += 1
|
||||||
|
stats_dict['by_week'][keys['week']] += 1
|
||||||
|
stats_dict['by_month'][keys['month']] += 1
|
||||||
|
|
||||||
|
def generate_time_series_svg(title: str, data: list[tuple[str, int]], y_label: str) -> str:
|
||||||
|
"""Generates a responsive SVG bar chart for time-series data."""
|
||||||
|
if not data:
|
||||||
|
return f"<h3>{html.escape(title)}</h3><p>No data yet.</p>"
|
||||||
|
max_val = max(item[1] for item in data) if data else 1
|
||||||
|
svg_height, svg_width = 250, 600
|
||||||
|
bar_padding = 5
|
||||||
|
bar_width = (svg_width - 50) / len(data) - bar_padding
|
||||||
|
|
||||||
|
bars = ""
|
||||||
|
labels = ""
|
||||||
|
for i, (key, val) in enumerate(data):
|
||||||
|
bar_height = (val / max_val) * (svg_height - 50) if max_val > 0 else 0
|
||||||
|
x = i * (bar_width + bar_padding) + 40
|
||||||
|
y = svg_height - bar_height - 30
|
||||||
|
|
||||||
|
bars += f'<rect x="{x}" y="{y}" width="{bar_width}" height="{bar_height}" fill="#007BFF"><title>{html.escape(key)}: {val}</title></rect>'
|
||||||
|
labels += f'<text x="{x + bar_width / 2}" y="{svg_height - 15}" font-size="11" text-anchor="middle">{html.escape(key)}</text>'
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<h3>{html.escape(title)}</h3>
|
||||||
|
<div style="border:1px solid #ccc; padding: 10px; border-radius: 5px;">
|
||||||
|
<svg viewBox="0 0 {svg_width} {svg_height}" style="width:100%; height:auto;">
|
||||||
|
<g>{bars}</g>
|
||||||
|
<g>{labels}</g>
|
||||||
|
<line x1="35" y1="10" x2="35" y2="{svg_height - 30}" stroke="#aaa" stroke-width="1" />
|
||||||
|
<line x1="35" y1="{svg_height - 30}" x2="{svg_width - 10}" y2="{svg_height - 30}" stroke="#aaa" stroke-width="1" />
|
||||||
|
<text x="5" y="{svg_height - 30}" font-size="12">0</text>
|
||||||
|
<text x="5" y="20" font-size="12">{max_val}</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
@web.middleware
|
||||||
|
async def middleware(request, handler):
|
||||||
|
"""Middleware to count all incoming HTTP requests."""
|
||||||
|
# Avoid counting requests to the stats page itself
|
||||||
|
if request.path.startswith('/stats.html'):
|
||||||
|
return await handler(request)
|
||||||
|
|
||||||
|
update_stats_counters(request.app['stats']['http_requests'], datetime.now(timezone.utc))
|
||||||
|
return await handler(request)
|
||||||
|
|
||||||
|
def update_websocket_stats(app):
|
||||||
|
update_stats_counters(app['stats']['websocket_requests'], datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
async def pipe_and_count_websocket(ws_from, ws_to, stats_dict):
|
||||||
|
"""This function proxies WebSocket messages AND counts them."""
|
||||||
|
async for msg in ws_from:
|
||||||
|
# This is the key part for monitoring WebSockets
|
||||||
|
update_stats_counters(stats_dict, datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
if msg.type == WSMsgType.TEXT:
|
||||||
|
await ws_to.send_str(msg.data)
|
||||||
|
elif msg.type == WSMsgType.BINARY:
|
||||||
|
await ws_to.send_bytes(msg.data)
|
||||||
|
elif msg.type in (WSMsgType.CLOSE, WSMsgType.ERROR):
|
||||||
|
await ws_to.close(code=ws_from.close_code)
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
async def stats_handler(request: web.Request):
|
||||||
|
"""Handler to display the statistics dashboard."""
|
||||||
|
stats = request.app['stats']
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Helper to prepare data for charts
|
||||||
|
def get_data(source, period, count):
|
||||||
|
data = []
|
||||||
|
for i in range(count - 1, -1, -1):
|
||||||
|
if period == 'hour':
|
||||||
|
dt = now - timedelta(hours=i)
|
||||||
|
key, label = dt.strftime('%Y-%m-%d-%H'), dt.strftime('%H:00')
|
||||||
|
data.append((label, source['by_hour'].get(key, 0)))
|
||||||
|
elif period == 'day':
|
||||||
|
dt = now - timedelta(days=i)
|
||||||
|
key, label = dt.strftime('%Y-%m-%d'), dt.strftime('%a')
|
||||||
|
data.append((label, source['by_day'].get(key, 0)))
|
||||||
|
return data
|
||||||
|
|
||||||
|
http_hourly = get_data(stats['http_requests'], 'hour', 24)
|
||||||
|
ws_hourly = get_data(stats['ws_messages'], 'hour', 24)
|
||||||
|
http_daily = get_data(stats['http_requests'], 'day', 7)
|
||||||
|
ws_daily = get_data(stats['ws_messages'], 'day', 7)
|
||||||
|
|
||||||
|
body = f"""
|
||||||
|
<html><head><title>App Stats</title><meta http-equiv="refresh" content="30"></head>
|
||||||
|
<body>
|
||||||
|
<h2>Application Dashboard</h2>
|
||||||
|
<h3>Last 24 Hours</h3>
|
||||||
|
{generate_time_series_svg("HTTP Requests", http_hourly, "Reqs/Hour")}
|
||||||
|
{generate_time_series_svg("WebSocket Messages", ws_hourly, "Msgs/Hour")}
|
||||||
|
<h3>Last 7 Days</h3>
|
||||||
|
{generate_time_series_svg("HTTP Requests", http_daily, "Reqs/Day")}
|
||||||
|
{generate_time_series_svg("WebSocket Messages", ws_daily, "Msgs/Day")}
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
return web.Response(text=body, content_type='text/html')
|
||||||
|
|
@ -115,7 +115,7 @@ app.starField.renderWord("H4x0r 1337")
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
document.addEventListener("keydown", async() => {
|
document.addEventListener("keydown", async(event) => {
|
||||||
if(prevKey == "Escape"){
|
if(prevKey == "Escape"){
|
||||||
document.querySelector("chat-input").querySelector("textarea").value = "";
|
document.querySelector("chat-input").querySelector("textarea").value = "";
|
||||||
}
|
}
|
||||||
|
@ -1 +1 @@
|
|||||||
<div style="max-width:100%;" data-uid="{{uid}}" data-color="{{color}}" data-channel_uid="{{channel_uid}}" data-user_nick="{{user_nick}}" data-created_at="{{created_at}}" data-user_uid="{{user_uid}}" class="message"><a class="avatar" style="background-color: {{color}}; color: black;" href="/user/{{user_uid}}.html"><img class="avatar-img" width="40px" height="40px" src="/avatar/{{user_uid}}.svg" /></a><div class="message-content"><div class="author" style="color: {{color}};">{{user_nick}}</div><div class="text">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class="time no-select" data-created_at="{{created_at}}"></div></div></div>
|
<chat-message data-uid="{{uid}}" data-color="{{color}}" data-channel_uid="{{channel_uid}}" data-user_nick="{{user_nick}}" data-created_at="{{created_at}}" data-user_uid="{{user_uid}}">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</chat-message>
|
@ -25,12 +25,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for message in messages %}
|
{% for message in messages|reverse %}
|
||||||
{% autoescape false %}
|
{% autoescape false %}
|
||||||
{{ message.html }}
|
{{ message.html }}
|
||||||
{% endautoescape %}
|
{% endautoescape %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div class="message-list-bottom"></div>
|
|
||||||
</message-list>
|
</message-list>
|
||||||
<chat-input live-type="true" channel="{{ channel.uid.value }}"></chat-input>
|
<chat-input live-type="true" channel="{{ channel.uid.value }}"></chat-input>
|
||||||
</section>
|
</section>
|
||||||
@ -73,7 +72,7 @@ function throttle(fn, wait) {
|
|||||||
// --- Scroll: load extra messages, throttled ---
|
// --- Scroll: load extra messages, throttled ---
|
||||||
let isLoadingExtra = false;
|
let isLoadingExtra = false;
|
||||||
async function loadExtra() {
|
async function loadExtra() {
|
||||||
const firstMessage = messagesContainer.querySelector(".message:first-child");
|
const firstMessage = messagesContainer.children[messagesContainer.children.length - 1];
|
||||||
if (isLoadingExtra || !isScrolledPastHalf() || !firstMessage) return;
|
if (isLoadingExtra || !isScrolledPastHalf() || !firstMessage) return;
|
||||||
isLoadingExtra = true;
|
isLoadingExtra = true;
|
||||||
const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);
|
const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);
|
||||||
@ -84,7 +83,7 @@ async function loadExtra() {
|
|||||||
temp.innerHTML = msg.html;
|
temp.innerHTML = msg.html;
|
||||||
frag.appendChild(temp.firstChild);
|
frag.appendChild(temp.firstChild);
|
||||||
});
|
});
|
||||||
firstMessage.parentNode.insertBefore(frag, firstMessage);
|
messagesContainer.appendChild(frag);
|
||||||
updateLayout(false);
|
updateLayout(false);
|
||||||
}
|
}
|
||||||
isLoadingExtra = false;
|
isLoadingExtra = false;
|
||||||
@ -93,32 +92,7 @@ messagesContainer.addEventListener("scroll", throttle(loadExtra, 200));
|
|||||||
|
|
||||||
// --- Only update visible times ---
|
// --- Only update visible times ---
|
||||||
function updateTimes() {
|
function updateTimes() {
|
||||||
const containers = messagesContainer.querySelectorAll(".time");
|
messagesContainer.updateTimes();
|
||||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
|
||||||
containers.forEach(container => {
|
|
||||||
const rect = container.getBoundingClientRect();
|
|
||||||
if (rect.top >= 0 && rect.bottom <= viewportHeight) {
|
|
||||||
const messageDiv = container.closest('.message');
|
|
||||||
let text = messageDiv.querySelector(".text").innerText;
|
|
||||||
const time = document.createElement("span");
|
|
||||||
time.innerText = app.timeDescription(container.dataset.created_at);
|
|
||||||
messageDiv.querySelector(".text").querySelectorAll("img").forEach(img => {
|
|
||||||
text += " " + img.src
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
container.replaceChildren(time);
|
|
||||||
const reply = document.createElement("a");
|
|
||||||
reply.innerText = " reply";
|
|
||||||
reply.href = "#reply";
|
|
||||||
container.appendChild(reply);
|
|
||||||
reply.addEventListener('click', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
replyMessage(text);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
setInterval(() => requestIdleCallback(updateTimes), 30000);
|
setInterval(() => requestIdleCallback(updateTimes), 30000);
|
||||||
|
|
||||||
@ -164,7 +138,7 @@ chatInputField.textarea.focus();
|
|||||||
|
|
||||||
// --- Reply helper ---
|
// --- Reply helper ---
|
||||||
function replyMessage(message) {
|
function replyMessage(message) {
|
||||||
chatInputField.value = "```markdown\n> " + (message || '') + "\n```\n";
|
chatInputField.value = "```markdown\n> " + (message || '').split("\n").join("\n> ") + "\n```\n";
|
||||||
chatInputField.focus();
|
chatInputField.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -223,22 +197,8 @@ app.addEventListener("channel-message", (data) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const lastElement = messagesContainer.querySelector(".message-list-bottom");
|
|
||||||
const doScrollDown = messagesContainer.isScrolledToBottom();
|
|
||||||
|
|
||||||
const oldMessage = messagesContainer.querySelector(`.message[data-uid="${data.uid}"]`);
|
messagesContainer.upsertMessage(data)
|
||||||
if (oldMessage) {
|
|
||||||
oldMessage.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = document.createElement("div");
|
|
||||||
|
|
||||||
|
|
||||||
message.innerHTML = data.html;
|
|
||||||
message.style.display = display;
|
|
||||||
messagesContainer.insertBefore(message.firstChild, lastElement);
|
|
||||||
updateLayout(doScrollDown);
|
|
||||||
setTimeout(() => updateLayout(doScrollDown), 1000);
|
|
||||||
app.rpc.markAsRead(channelUid);
|
app.rpc.markAsRead(channelUid);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -284,27 +244,6 @@ document.addEventListener('keydown', function(event) {
|
|||||||
// --- Layout update ---
|
// --- Layout update ---
|
||||||
function updateLayout(doScrollDown) {
|
function updateLayout(doScrollDown) {
|
||||||
updateTimes();
|
updateTimes();
|
||||||
let previousUser = null, previousDate = null;
|
|
||||||
messagesContainer.querySelectorAll(".message").forEach((message) => {
|
|
||||||
if (previousUser !== message.dataset.user_uid) {
|
|
||||||
message.classList.add("switch-user");
|
|
||||||
previousUser = message.dataset.user_uid;
|
|
||||||
previousDate = new Date(message.dataset.created_at);
|
|
||||||
} else {
|
|
||||||
message.classList.remove("switch-user");
|
|
||||||
if (!previousDate) {
|
|
||||||
previousDate = new Date(message.dataset.created_at);
|
|
||||||
} else {
|
|
||||||
const currentDate = new Date(message.dataset.created_at);
|
|
||||||
if (currentDate.getTime() - previousDate.getTime() > 1000 * 60 * 20) {
|
|
||||||
message.classList.add("long-time");
|
|
||||||
} else {
|
|
||||||
message.classList.remove("long-time");
|
|
||||||
}
|
|
||||||
previousDate = currentDate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (doScrollDown) messagesContainer.scrollToBottom?.();
|
if (doScrollDown) messagesContainer.scrollToBottom?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
# MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions.
|
# MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions.
|
||||||
|
|
||||||
|
from snek.system.stats import update_websocket_stats
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@ -507,7 +507,9 @@ class RPCView(BaseView):
|
|||||||
raise Exception("Method not found")
|
raise Exception("Method not found")
|
||||||
success = True
|
success = True
|
||||||
try:
|
try:
|
||||||
|
update_websocket_stats(self.app)
|
||||||
result = await method(*args)
|
result = await method(*args)
|
||||||
|
update_websocket_stats(self.app)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
result = {"exception": str(ex), "traceback": traceback.format_exc()}
|
result = {"exception": str(ex), "traceback": traceback.format_exc()}
|
||||||
success = False
|
success = False
|
||||||
|
@ -82,7 +82,6 @@ class WebView(BaseView):
|
|||||||
await self.app.services.notification.mark_as_read(
|
await self.app.services.notification.mark_as_read(
|
||||||
self.session.get("uid"), message["uid"]
|
self.session.get("uid"), message["uid"]
|
||||||
)
|
)
|
||||||
print(messages)
|
|
||||||
name = await channel_member.get_name()
|
name = await channel_member.get_name()
|
||||||
return await self.render_template(
|
return await self.render_template(
|
||||||
"web.html",
|
"web.html",
|
||||||
|
Loading…
Reference in New Issue
Block a user