feat: add TTLCache for get_cache_version and TEMPLATE_AUTO_RELOAD config with Makefile worker count variables

This commit is contained in:
2026-06-14 14:46:36 +00:00
parent c151325916
commit d75d5dc6a8
149 changed files with 9811 additions and 262 deletions
+85
View File
@@ -0,0 +1,85 @@
/* retoor <retoor@molodetz.nl> */
.bots-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
margin-top: 12px;
}
.bot-card {
margin: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card-hover);
overflow: hidden;
display: flex;
flex-direction: column;
transition: border-color 0.2s ease, opacity 0.2s ease;
}
.bot-card.bot-active {
border-color: var(--success);
}
.bot-card.bot-idle {
opacity: 0.6;
}
.bot-shot {
position: relative;
aspect-ratio: 16 / 10;
background: var(--bg-input);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.bot-shot img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top center;
display: block;
}
.bot-shot-empty {
color: var(--text-muted);
font-size: 0.85rem;
font-family: var(--font-mono);
}
.bot-meta {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
}
.bot-name {
font-weight: 600;
color: var(--text-primary);
word-break: break-all;
}
.bot-persona {
font-size: 0.78rem;
color: var(--text-secondary);
}
.bot-action {
font-size: 0.78rem;
color: var(--text-muted);
font-family: var(--font-mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 600px) {
.bots-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
}
+137
View File
@@ -132,3 +132,140 @@
.cm-body { grid-template-columns: 1fr; }
.ci-grid { grid-template-columns: 1fr; }
}
.admin-btn-primary {
background: var(--accent, var(--info));
color: var(--white);
border-color: transparent;
}
.admin-btn-danger {
background: var(--danger);
color: var(--white);
border-color: transparent;
}
.cm-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.cm-form.cm-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
max-width: 720px;
}
.cm-form label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
color: var(--text-secondary);
position: relative;
}
.cm-form input[type="text"],
.cm-form input[type="number"],
.cm-form select,
.cm-form textarea {
width: 100%;
padding: 0.5rem 0.65rem;
background: var(--bg-input, var(--bg-card));
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
font: inherit;
}
.cm-form textarea {
font-family: var(--font-mono, monospace);
resize: vertical;
}
.cm-form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.cm-check {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.cm-suggest {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
max-height: 200px;
overflow-y: auto;
}
.cm-suggest:empty {
display: none;
}
.cm-suggest li {
padding: 0.4rem 0.65rem;
cursor: pointer;
color: var(--text-primary);
}
.cm-suggest li:hover,
.cm-suggest li.cm-suggest-active {
background: var(--bg-hover, var(--border));
}
.cm-suggest li.cm-suggest-active {
outline: 2px solid var(--accent, var(--border));
outline-offset: -2px;
}
.cm-actions {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
@media (max-width: 768px) {
.cm-form-row { grid-template-columns: 1fr; }
}
.ci-events {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 320px;
overflow-y: auto;
}
.ci-event {
display: flex;
gap: 0.75rem;
align-items: center;
padding: 0.35rem 0.5rem;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
}
.ci-event-name {
font-weight: 600;
color: var(--text-primary);
}
.ci-event-time {
margin-left: auto;
color: var(--text-muted);
font-family: var(--font-mono, monospace);
}
+136
View File
@@ -141,6 +141,109 @@
font-weight: 600;
}
.messages-header-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.messages-presence {
font-size: 0.6875rem;
color: var(--text-muted);
}
.messages-presence.online {
color: var(--success, var(--accent));
}
.messages-presence.online::before {
content: "";
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--success, var(--accent));
margin-right: 0.3125rem;
vertical-align: middle;
}
.conversation-unread-dot {
flex-shrink: 0;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--accent);
align-self: center;
}
.conversation-unread-dot[hidden] {
display: none;
}
.message-receipt {
font-size: 0.6875rem;
color: rgba(255, 255, 255, 0.85);
margin-left: 0.375rem;
letter-spacing: -2px;
}
.message-receipt[hidden] {
display: none;
}
.message-bubble.pending {
opacity: 0.6;
}
.attachment-pending {
margin-top: 0.35rem;
font-size: 0.8rem;
font-style: italic;
color: var(--text-secondary);
}
.typing-indicator {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
background: var(--bg-card-hover);
border-radius: var(--radius-lg);
border-bottom-left-radius: 4px;
}
.typing-indicator[hidden] {
display: none;
}
.typing-indicator span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
animation: typing-bounce 1.2s infinite ease-in-out;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing-bounce {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-4px);
opacity: 1;
}
}
.messages-thread {
flex: 1;
overflow-y: auto;
@@ -233,6 +336,39 @@
background: var(--accent-hover);
}
.messages-send-btn .send-spinner {
display: none;
width: 16px;
height: 16px;
border: 2px solid var(--white);
border-right-color: transparent;
border-radius: 50%;
animation: messages-send-spin 0.6s linear infinite;
}
.messages-send-btn.is-sending {
cursor: default;
opacity: 0.75;
}
.messages-send-btn.is-sending .send-icon {
display: none;
}
.messages-send-btn.is-sending .send-spinner {
display: block;
}
.messages-send-btn:disabled {
cursor: default;
}
@keyframes messages-send-spin {
to {
transform: rotate(360deg);
}
}
.messages-empty {
display: flex;
flex-direction: column;
+106
View File
@@ -0,0 +1,106 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
export class BotMonitor {
constructor(root) {
this.root = root;
this.endpoint = root.dataset.endpoint;
this.count = document.getElementById("bots-count");
this.pollMs = 2000;
}
init() {
this.poller = new Poller(() => this.refresh(), this.pollMs, {
immediate: false,
pauseHidden: true,
});
}
escape(value) {
const span = document.createElement("span");
span.textContent = value == null ? "" : String(value);
return span.innerHTML;
}
async refresh() {
const payload = await Http.getJson(this.endpoint);
const frames = payload.frames || [];
if (this.count) {
this.count.textContent = `${frames.length} bots`;
}
if (!frames.length) {
this.root.innerHTML = `<p class="admin-empty">No bots running. Launch the fleet on <a href="/admin/services">Services</a>.</p>`;
return;
}
this.reconcile(frames);
}
reconcile(frames) {
const seen = new Set();
let previous = null;
for (const frame of frames) {
const slot = String(frame.slot);
seen.add(slot);
let card = this.root.querySelector(`.bot-card[data-slot="${this.escape(slot)}"]`);
if (card) {
this.update(card, frame);
} else {
card = document.createElement("div");
card.innerHTML = this.card(frame);
card = card.firstElementChild;
}
const anchor = previous ? previous.nextElementSibling : this.root.firstElementChild;
if (anchor !== card) {
this.root.insertBefore(card, anchor);
}
previous = card;
}
for (const card of [...this.root.querySelectorAll(".bot-card")]) {
if (!seen.has(card.dataset.slot)) {
card.remove();
}
}
}
update(card, frame) {
const active = frame.active ? "bot-active" : "bot-idle";
card.className = `bot-card ${active}`;
const shot = card.querySelector(".bot-shot");
const image = shot.querySelector("img");
if (frame.has_image) {
if (image) {
if (image.getAttribute("src") !== frame.frame_url) {
image.src = frame.frame_url;
}
} else {
shot.innerHTML = `<img src="${this.escape(frame.frame_url)}" alt="${this.escape(frame.label || `bot${frame.slot}`)} screenshot" loading="lazy">`;
}
} else if (!shot.querySelector(".bot-shot-empty")) {
shot.innerHTML = `<div class="bot-shot-empty">no frame yet</div>`;
}
card.querySelector(".bot-name").textContent = frame.label || `bot${frame.slot}`;
card.querySelector(".bot-persona").textContent = frame.persona || "-";
card.querySelector(".bot-action").textContent = frame.action || frame.status || "idle";
}
card(frame) {
const slot = this.escape(frame.slot);
const label = this.escape(frame.label || `bot${frame.slot}`);
const persona = this.escape(frame.persona || "-");
const action = this.escape(frame.action || frame.status || "idle");
const active = frame.active ? "bot-active" : "bot-idle";
const shot = frame.has_image
? `<img src="${this.escape(frame.frame_url)}" alt="${label} screenshot" loading="lazy">`
: `<div class="bot-shot-empty">no frame yet</div>`;
return `<figure class="bot-card ${active}" data-slot="${slot}">
<div class="bot-shot">${shot}</div>
<figcaption class="bot-meta">
<span class="bot-name">${label}</span>
<span class="bot-persona">${persona}</span>
<span class="bot-action">${action}</span>
</figcaption>
</figure>`;
}
}
+43
View File
@@ -0,0 +1,43 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { wireSearch, wireBootToggle } from "./ContainerFormControls.js";
export class ContainerEdit {
constructor(form) {
this.form = form;
this.uid = form ? form.dataset.uid : "";
}
init() {
if (!this.form) return;
wireSearch(this.form, "user", "/admin/containers/users/search", "run_as_uid", (r) => r.uid, (r) => r.username);
wireBootToggle(this.form);
this.form.addEventListener("submit", (e) => { e.preventDefault(); this.save(); });
}
toast(message, type) {
if (window.app && window.app.toast) window.app.toast.show(message, { type: type || "info" });
}
async save() {
const f = this.form;
const params = {
run_as_uid: f.elements.run_as_uid.value.trim(),
boot_language: f.elements.boot_language.value,
boot_script: f.elements.boot_script.value,
boot_command: f.elements.boot_command.value.trim(),
restart_policy: f.elements.restart_policy.value,
cpu_limit: f.elements.cpu_limit.value.trim(),
mem_limit: f.elements.mem_limit.value.trim(),
start_on_boot: f.elements.start_on_boot.checked ? "true" : "false",
};
try {
await Http.send(`/admin/containers/${this.uid}/edit`, params);
this.toast("Saved", "success");
window.location.assign(`/admin/containers/${this.uid}`);
} catch (err) {
return;
}
}
}
@@ -0,0 +1,150 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
export function escapeHtml(value) {
const span = document.createElement("span");
span.textContent = value == null ? "" : String(value);
return span.innerHTML;
}
export function wireBootToggle(form) {
const select = form.elements.boot_language;
const update = () => {
const script = form.querySelector('[data-boot="script"]');
if (script) script.style.display = select.value === "none" ? "none" : "";
};
select.addEventListener("change", update);
update();
}
const SEARCH_DEBOUNCE_MS = 200;
export function wireSearch(form, kind, url, hiddenName, valueOf, labelOf) {
const input = form.querySelector(`[data-search="${kind}"]`);
const list = form.querySelector(`[data-suggest="${kind}"]`);
const hidden = form.elements[hiddenName];
if (!input || !list || !hidden) return;
if (!list.id) list.id = `cm-suggest-${kind}`;
list.setAttribute("role", "listbox");
input.setAttribute("role", "combobox");
input.setAttribute("aria-autocomplete", "list");
input.setAttribute("aria-controls", list.id);
input.setAttribute("aria-expanded", "false");
let timer = null;
let active = -1;
const options = () => Array.from(list.querySelectorAll("li[data-value]"));
const close = () => {
list.innerHTML = "";
active = -1;
input.setAttribute("aria-expanded", "false");
input.removeAttribute("aria-activedescendant");
};
const setActive = (index) => {
const opts = options();
active = index;
opts.forEach((li, i) => {
const on = i === index;
li.classList.toggle("cm-suggest-active", on);
li.setAttribute("aria-selected", on ? "true" : "false");
if (on) {
input.setAttribute("aria-activedescendant", li.id);
li.scrollIntoView({ block: "nearest" });
}
});
if (index < 0) input.removeAttribute("aria-activedescendant");
};
const choose = (item) => {
if (!item) return;
hidden.value = item.dataset.value;
input.value = item.dataset.label;
close();
};
const render = (results) => {
list.innerHTML = results.map((r, i) =>
`<li id="${list.id}-opt-${i}" role="option" aria-selected="false" `
+ `data-value="${escapeHtml(valueOf(r))}" data-label="${escapeHtml(labelOf(r))}">`
+ `${escapeHtml(labelOf(r))}</li>`
).join("");
active = -1;
input.setAttribute("aria-expanded", results.length ? "true" : "false");
input.removeAttribute("aria-activedescendant");
};
input.addEventListener("input", () => {
hidden.value = "";
clearTimeout(timer);
const q = input.value.trim();
if (!q) { close(); return; }
timer = setTimeout(async () => {
try {
const data = await Http.getJson(`${url}?q=${encodeURIComponent(q)}`);
render(data.results || []);
} catch (err) {
close();
}
}, SEARCH_DEBOUNCE_MS);
});
input.addEventListener("keydown", (e) => {
const opts = options();
const count = opts.length;
if (!count) return;
const last = count - 1;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActive(active < last ? active + 1 : 0);
break;
case "ArrowUp":
e.preventDefault();
setActive(active > 0 ? active - 1 : last);
break;
case "Home":
e.preventDefault();
setActive(0);
break;
case "End":
e.preventDefault();
setActive(last);
break;
case "Enter":
e.preventDefault();
choose(active >= 0 ? opts[active] : opts[0]);
break;
case "Escape":
e.preventDefault();
close();
break;
case "Tab":
if (e.shiftKey) {
if (active > 0) { e.preventDefault(); setActive(active - 1); }
else close();
} else if (active < last) {
e.preventDefault();
setActive(active + 1);
} else {
choose(opts[active]);
}
break;
default:
break;
}
});
list.addEventListener("mousedown", (e) => e.preventDefault());
list.addEventListener("click", (e) => {
const item = e.target.closest("li[data-value]");
choose(item);
});
input.addEventListener("blur", () => {
window.setTimeout(close, 150);
});
}
+106 -8
View File
@@ -2,6 +2,7 @@
import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
import { escapeHtml, wireSearch, wireBootToggle } from "./ContainerFormControls.js";
export class ContainerList {
constructor(root) {
@@ -12,20 +13,103 @@ export class ContainerList {
}
init() {
this.bindActions();
this.bindCreate();
this.poller = new Poller(() => this.refresh(), this.pollMs, { immediate: false });
}
toast(message, type) {
if (window.app && window.app.toast) window.app.toast.show(message, { type: type || "info" });
}
escape(value) {
const span = document.createElement("span");
span.textContent = value == null ? "" : String(value);
return span.innerHTML;
return escapeHtml(value);
}
bindActions() {
if (!this.body) return;
this.body.addEventListener("click", (e) => {
const btn = e.target.closest("[data-cm-action]");
if (!btn) return;
e.preventDefault();
const row = btn.closest(".cm-row");
if (!row) return;
this.runAction(btn.dataset.cmAction, row.dataset.uid, row.dataset.slug, row.dataset.name);
});
}
async runAction(action, uid, slug, name) {
if (action === "terminal") {
if (window.app && window.app.containerTerminals && slug) {
window.app.containerTerminals.open(slug, uid, name);
} else {
this.toast("Terminal needs a project slug", "error");
}
return;
}
if (action === "delete") {
const ok = window.app && window.app.dialog
? await window.app.dialog.confirm(`Delete container instance "${name}"? This cannot be undone.`)
: window.confirm(`Delete container instance "${name}"?`);
if (!ok) return;
}
try {
await Http.send(`/admin/containers/${uid}/${action}`, {});
this.toast(`Instance ${action} requested`, "success");
await this.refresh();
} catch (err) {
return;
}
}
bindCreate() {
this.form = document.getElementById("cm-admin-create-form");
if (!this.form) return;
wireSearch(this.form, "project", "/admin/containers/projects/search", "project_slug", (r) => r.slug, (r) => r.title);
wireSearch(this.form, "user", "/admin/containers/users/search", "run_as_uid", (r) => r.uid, (r) => r.username);
wireBootToggle(this.form);
this.form.addEventListener("submit", (e) => { e.preventDefault(); this.create(); });
}
async create() {
const f = this.form;
const slug = f.elements.project_slug.value.trim();
if (!slug) { this.toast("Pick a project first", "error"); return; }
const params = {
project_slug: slug,
name: f.elements.name.value.trim(),
run_as_uid: f.elements.run_as_uid.value.trim(),
boot_language: f.elements.boot_language.value,
boot_script: f.elements.boot_script.value,
boot_command: f.elements.boot_command.value.trim(),
restart_policy: f.elements.restart_policy.value,
ports: f.elements.ports.value.trim(),
env: f.elements.env.value,
cpu_limit: f.elements.cpu_limit.value.trim(),
mem_limit: f.elements.mem_limit.value.trim(),
ingress_slug: f.elements.ingress_slug.value.trim(),
ingress_port: f.elements.ingress_port.value.trim(),
start_on_boot: f.elements.start_on_boot.checked ? "true" : "false",
autostart: f.elements.autostart.checked ? "true" : "false",
};
try {
await Http.send("/admin/containers/create", params);
const modal = document.getElementById("cm-admin-create-modal");
if (modal) modal.classList.remove("visible");
f.reset();
wireBootToggle(this.form);
this.toast("Instance created", "success");
await this.refresh();
} catch (err) {
return;
}
}
async refresh() {
const instances = (await Http.getJson(this.endpoint)).instances || [];
if (!this.body) return;
if (!instances.length) {
this.body.innerHTML = `<tr><td colspan="6" class="admin-empty">No container instances yet. Open a project's container manager to launch one.</td></tr>`;
this.body.innerHTML = `<tr><td colspan="8" class="admin-empty">No container instances yet. Create one above or open a project's container manager.</td></tr>`;
return;
}
this.body.innerHTML = instances.map((inst) => this.row(inst)).join("");
@@ -33,19 +117,33 @@ export class ContainerList {
row(inst) {
const uid = this.escape(inst.uid);
const slug = this.escape(inst.project_slug || "");
const name = this.escape(inst.name);
const project = inst.project_slug
? `<a class="cm-row-project" href="/projects/${this.escape(inst.project_slug)}/containers">${this.escape(inst.project_title || inst.project_slug)}</a>`
? `<a class="cm-row-project" href="/projects/${slug}/containers">${this.escape(inst.project_title || inst.project_slug)}</a>`
: `<span class="cm-muted">-</span>`;
const ingress = inst.ingress_slug
? `<a href="/p/${this.escape(inst.ingress_slug)}" target="_blank" rel="noopener">/p/${this.escape(inst.ingress_slug)}</a>`
: `<span class="cm-muted">-</span>`;
return `<tr class="cm-row" data-uid="${uid}">
<td><a class="cm-row-name" href="/admin/containers/${uid}">${this.escape(inst.name)}</a></td>
const boot = inst.start_on_boot
? `<span class="cm-badge cm-running">on</span>`
: `<span class="cm-muted">off</span>`;
return `<tr class="cm-row" data-uid="${uid}" data-slug="${slug}" data-name="${name}">
<td><a class="cm-row-name" href="/admin/containers/${uid}">${name}</a></td>
<td>${project}</td>
<td><span class="cm-badge cm-${this.escape(inst.status)}">${this.escape(inst.status)}</span></td>
<td>${this.escape(inst.boot_language || "none")}</td>
<td>${ingress}</td>
<td>${this.escape(inst.restart_policy || "never")}</td>
<td><a class="admin-btn admin-btn-sm" href="/admin/containers/${uid}">Manage &rarr;</a></td>
<td>${boot}</td>
<td class="cm-actions">
<button class="admin-btn admin-btn-sm" data-cm-action="start">Start</button>
<button class="admin-btn admin-btn-sm" data-cm-action="stop">Stop</button>
<button class="admin-btn admin-btn-sm" data-cm-action="restart">Restart</button>
<button class="admin-btn admin-btn-sm" data-cm-action="terminal">Terminal</button>
<a class="admin-btn admin-btn-sm" href="/admin/containers/${uid}/edit">Edit</a>
<button class="admin-btn admin-btn-sm admin-btn-danger" data-cm-action="delete">Delete</button>
</td>
</tr>`;
}
}
@@ -27,6 +27,7 @@ class CustomizationToggle {
Toast.flash(button, "Error", 1500);
} finally {
button.disabled = false;
button.classList.remove("is-loading");
}
});
}
+3
View File
@@ -53,6 +53,9 @@ export class FormManager {
initFormDisable() {
document.querySelectorAll("form").forEach((form) => {
if (form.hasAttribute("data-live-form")) {
return;
}
form.addEventListener("submit", () => {
const btn = form.querySelector("button[type='submit']");
if (btn) {
+43 -8
View File
@@ -6,6 +6,26 @@ export class Http {
window.location.href = `/auth/login?next=${next}`;
}
static notifyError(message) {
const text = (message && String(message).trim()) || "Something went wrong. Please try again.";
const app = window.app;
if (app && app.dialog && typeof app.dialog.alert === "function") {
app.dialog.alert({ title: "Error", message: text });
return;
}
if (app && app.toast && typeof app.toast.show === "function") {
app.toast.show(text, { type: "error", ms: 6000 });
return;
}
console.error(text);
}
static async _messageFrom(response) {
const data = await response.json().catch(() => ({}));
const message = (data && data.error && data.error.message) || `request failed with status ${response.status}`;
return { data, message };
}
static async getJson(url) {
const response = await fetch(url, { headers: { "Accept": "application/json" } });
if (!response.ok) {
@@ -14,10 +34,11 @@ export class Http {
return response.json();
}
static async sendForm(url, params = {}) {
static async sendForm(url, params = {}, options = {}) {
const response = await fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"X-Requested-With": "fetch",
"Content-Type": "application/x-www-form-urlencoded",
},
@@ -28,12 +49,14 @@ export class Http {
return new Promise(() => {});
}
if (!response.ok) {
throw new Error(`request failed with status ${response.status}`);
const { message } = await Http._messageFrom(response);
if (!options.silent) Http.notifyError(message);
throw new Error(message);
}
return response.json();
return response.json().catch(() => ({}));
}
static async send(url, params = {}) {
static async send(url, params = {}, options = {}) {
const response = await fetch(url, {
method: "POST",
headers: {
@@ -42,21 +65,33 @@ export class Http {
},
body: new URLSearchParams(params),
});
if (response.redirected && response.url.includes("/auth/login")) {
Http.toLogin();
return new Promise(() => {});
}
const data = await response.json().catch(() => ({}));
if (!response.ok || data.ok === false) {
throw new Error((data.error && data.error.message) || `request failed: ${response.status}`);
const message = (data.error && data.error.message) || `request failed: ${response.status}`;
if (!options.silent) Http.notifyError(message);
throw new Error(message);
}
return data;
}
static async postJson(url, body) {
static async postJson(url, body, options = {}) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Accept": "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (response.redirected && response.url.includes("/auth/login")) {
Http.toLogin();
return new Promise(() => {});
}
if (!response.ok) {
throw new Error(`request failed with status ${response.status}`);
const { message } = await Http._messageFrom(response);
if (!options.silent) Http.notifyError(message);
throw new Error(message);
}
return response.json();
}
+354 -1
View File
@@ -1,5 +1,11 @@
// retoor <retoor@molodetz.nl>
import { MessagesSocket } from "./MessagesSocket.js";
import { contentRenderer } from "./ContentRenderer.js";
const TYPING_THROTTLE_MS = 1500;
const TYPING_HIDE_MS = 4000;
export class MessagesLayout {
constructor() {
this.layout = document.querySelector(".messages-layout");
@@ -7,12 +13,359 @@ export class MessagesLayout {
return;
}
this.thread = document.querySelector(".messages-thread");
this.input = document.querySelector('.messages-input-area input[name="content"]');
this.form = document.querySelector(".messages-input-area");
this.input = this.form ? this.form.querySelector('input[name="content"]') : null;
this.upload = this.form ? this.form.querySelector("dp-upload") : null;
this.sendBtn = this.form ? this.form.querySelector(".messages-send-btn") : null;
this._uploading = false;
this._pendingSends = new Set();
this.presenceEl = document.getElementById("messages-presence");
this.typingEl = document.getElementById("typing-indicator");
this.selfUid = this.layout.dataset.selfUid || "";
this.otherUid = this.layout.dataset.otherUid || "";
this._lastTypingSent = 0;
this._typingHideTimer = null;
this._socketReady = false;
this.scrollThreadToEnd();
if (this.input) {
this.input.focus({ preventScroll: true });
}
this.initPresence();
this.connect();
this.bindForm();
this.bindTyping();
this.markRead();
}
connect() {
this.socket = new MessagesSocket({
onReady: () => this.onReady(),
onMessage: (frame) => this.onFrame(frame),
onClose: () => { this._socketReady = false; },
});
this.socket.connect();
}
onReady() {
this._socketReady = true;
if (this.otherUid) {
this.socket.send({ type: "presence", with_uid: this.otherUid });
this.markRead();
}
}
onFrame(frame) {
switch (frame.type) {
case "message":
this.handleIncoming(frame);
break;
case "typing":
if (frame.from_uid === this.otherUid) this.showTyping();
break;
case "read":
if (frame.by_uid === this.otherUid) this.markReceipts();
break;
case "presence":
if (frame.user_uid === this.otherUid) {
this.renderPresence(frame.online, frame.last_seen);
}
break;
default:
break;
}
}
bindForm() {
if (!this.form || !this.input) return;
this.form.addEventListener("submit", (event) => {
if (!this._socketReady || !this.socket.isOpen()) {
return;
}
event.preventDefault();
this.sendViaSocket();
});
if (this.upload) {
this.upload.addEventListener("dp-upload:busy", (event) => {
this._uploading = !!(event.detail && event.detail.busy);
this.refreshSendButton();
});
}
}
refreshSendButton() {
if (!this.sendBtn) return;
const busy = this._uploading || this._pendingSends.size > 0;
this.sendBtn.disabled = busy;
this.sendBtn.classList.toggle("is-sending", busy);
}
sendViaSocket() {
const content = (this.input.value || "").trim();
const attachmentUids = this.collectAttachments();
if (!content && attachmentUids.length === 0) return;
const clientId = "c" + Date.now() + Math.random().toString(36).slice(2, 8);
this.appendOptimistic(content, clientId, attachmentUids.length);
const ok = this.socket.send({
type: "send",
receiver_uid: this.otherUid,
content,
attachment_uids: attachmentUids,
client_id: clientId,
});
if (!ok) {
this.form.submit();
return;
}
this._pendingSends.add(clientId);
this.refreshSendButton();
window.setTimeout(() => this.clearPendingSend(clientId), 8000);
this.input.value = "";
if (this.upload && typeof this.upload.clear === "function") {
this.upload.clear();
}
this.input.focus({ preventScroll: true });
}
clearPendingSend(clientId) {
if (this._pendingSends.delete(clientId)) {
this.refreshSendButton();
}
}
collectAttachments() {
const hidden = this.form.querySelector('input[name="attachment_uids"]');
if (!hidden || !hidden.value) return [];
return hidden.value.split(",").map((v) => v.trim()).filter(Boolean);
}
bindTyping() {
if (!this.input) return;
this.input.addEventListener("input", () => {
if (!this._socketReady || !this.otherUid) return;
const now = Date.now();
if (now - this._lastTypingSent < TYPING_THROTTLE_MS) return;
this._lastTypingSent = now;
this.socket.send({ type: "typing", receiver_uid: this.otherUid });
});
}
appendOptimistic(content, clientId, attachmentCount) {
const bubble = this.buildBubble({
content,
mine: true,
clientId,
time: "now",
attachmentCount,
});
bubble.classList.add("pending");
this.insertBubble(bubble);
}
handleIncoming(frame) {
if (frame.sender_uid === this.selfUid && frame.client_id) {
this.clearPendingSend(frame.client_id);
const pending = this.thread.querySelector(`.message-bubble[data-client-id="${frame.client_id}"]`);
if (pending) {
pending.classList.remove("pending");
pending.dataset.msgUid = frame.uid;
const time = pending.querySelector(".message-time");
if (time) time.textContent = frame.time_ago;
const placeholder = pending.querySelector(".attachment-pending");
if (placeholder) placeholder.remove();
const gallery = this.renderAttachments(frame.attachments);
if (gallery) pending.insertBefore(gallery, time || null);
this.bumpConversation(frame, true);
return;
}
}
const partnerUid = frame.sender_uid === this.selfUid ? frame.receiver_uid : frame.sender_uid;
const inThisThread = this.otherUid && partnerUid === this.otherUid;
if (inThisThread) {
const mine = frame.sender_uid === this.selfUid;
const bubble = this.buildBubble({
content: frame.content,
mine,
time: frame.time_ago,
uid: frame.uid,
attachments: frame.attachments,
});
this.insertBubble(bubble);
if (!mine && this._socketReady) {
this.socket.send({ type: "read", with_uid: this.otherUid });
}
}
this.bumpConversation(frame, frame.sender_uid === this.selfUid);
}
buildBubble({ content, mine, time, uid, clientId, attachments, attachmentCount }) {
const bubble = document.createElement("div");
bubble.className = "message-bubble " + (mine ? "mine" : "theirs");
if (uid) bubble.dataset.msgUid = uid;
if (clientId) bubble.dataset.clientId = clientId;
const body = document.createElement("div");
body.className = "rendered-content";
body.setAttribute("data-render", "");
body.textContent = content || "";
bubble.appendChild(body);
const gallery = this.renderAttachments(attachments);
if (gallery) {
bubble.appendChild(gallery);
} else if (attachmentCount > 0) {
const placeholder = document.createElement("div");
placeholder.className = "attachment-pending";
placeholder.textContent = attachmentCount === 1
? "Uploading attachment..."
: `Uploading ${attachmentCount} attachments...`;
bubble.appendChild(placeholder);
}
const timeEl = document.createElement("span");
timeEl.className = "message-time";
timeEl.textContent = time || "";
bubble.appendChild(timeEl);
if (mine) {
const receipt = document.createElement("span");
receipt.className = "message-receipt";
receipt.hidden = true;
receipt.innerHTML = "&#x2713;&#x2713;";
bubble.appendChild(receipt);
}
this.renderBody(body);
return bubble;
}
renderBody(el) {
contentRenderer.applyTo(el);
el.querySelectorAll("img:not(.avatar-img)").forEach((img) => {
img.dataset.lightbox = "";
});
contentRenderer.highlightAll();
}
renderAttachments(attachments) {
if (!attachments || !attachments.length) return null;
const gallery = document.createElement("div");
gallery.className = "attachment-gallery";
for (const att of attachments) {
const item = document.createElement("div");
item.className = "attachment-gallery-item";
if (att.is_image) {
const img = document.createElement("img");
img.src = att.thumbnail_url || att.url;
img.alt = att.original_filename || "";
img.loading = "lazy";
img.className = "gallery-thumb";
img.dataset.lightbox = "";
img.dataset.full = att.url;
if (att.mime_type) img.dataset.mime = att.mime_type;
item.appendChild(img);
} else if (att.is_video) {
const video = document.createElement("video");
video.src = att.url;
video.controls = true;
video.preload = "metadata";
video.className = "gallery-video";
item.appendChild(video);
} else {
const link = document.createElement("a");
link.href = att.url;
link.target = "_blank";
link.rel = "noopener";
link.className = "non-image";
link.download = att.original_filename || "file";
link.textContent = att.original_filename || "file";
item.appendChild(link);
}
gallery.appendChild(item);
}
return gallery;
}
insertBubble(bubble) {
if (!this.thread) return;
if (this.typingEl && this.typingEl.parentElement === this.thread) {
this.thread.insertBefore(bubble, this.typingEl);
} else {
this.thread.appendChild(bubble);
}
this.scrollThreadToEnd();
}
markReceipts() {
this.thread.querySelectorAll(".message-bubble.mine .message-receipt").forEach((el) => {
el.hidden = false;
});
}
markRead() {
if (this._socketReady && this.otherUid) {
this.socket.send({ type: "read", with_uid: this.otherUid });
}
}
showTyping() {
if (!this.typingEl) return;
this.typingEl.hidden = false;
this.scrollThreadToEnd();
clearTimeout(this._typingHideTimer);
this._typingHideTimer = setTimeout(() => {
this.typingEl.hidden = true;
}, TYPING_HIDE_MS);
}
initPresence() {
if (!this.presenceEl) return;
const online = this.layout.dataset.otherOnline === "1";
const lastSeen = this.layout.dataset.otherLastSeen || null;
this.renderPresence(online, lastSeen);
}
renderPresence(online, lastSeen) {
if (!this.presenceEl) return;
this.presenceEl.classList.toggle("online", !!online);
if (online) {
this.presenceEl.textContent = "online";
} else if (lastSeen) {
this.presenceEl.textContent = "last seen " + this.formatLastSeen(lastSeen);
} else {
this.presenceEl.textContent = "offline";
}
}
formatLastSeen(iso) {
const then = new Date(iso);
if (Number.isNaN(then.getTime())) return "recently";
const seconds = Math.max(0, Math.floor((Date.now() - then.getTime()) / 1000));
if (seconds < 60) return "just now";
if (seconds < 3600) return Math.floor(seconds / 60) + "m ago";
if (seconds < 86400) return Math.floor(seconds / 3600) + "h ago";
return then.toLocaleDateString("en-GB");
}
bumpConversation(frame, mine) {
const partnerUid = mine ? frame.receiver_uid : frame.sender_uid;
const item = document.querySelector(`.conversation-item[data-conv-uid="${partnerUid}"]`);
if (!item) return;
const preview = item.querySelector(".conversation-preview");
if (preview) preview.textContent = frame.content.slice(0, 60);
const dot = item.querySelector(".conversation-unread-dot");
if (dot) {
dot.hidden = mine || partnerUid === this.otherUid;
}
const list = item.parentElement;
if (list && list.firstElementChild !== item) {
list.insertBefore(item, list.firstElementChild);
}
}
scrollThreadToEnd() {
+70
View File
@@ -0,0 +1,70 @@
// retoor <retoor@molodetz.nl>
const RECONNECT_DELAY_MS = 1500;
const WRONG_WORKER_RETRY_MS = 200;
const WRONG_WORKER_CODE = 4013;
export class MessagesSocket {
constructor(handlers) {
this.handlers = handlers || {};
this.ws = null;
this.url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/messages/ws";
this._shouldRun = false;
this._settled = false;
}
connect() {
this._shouldRun = true;
this._open();
}
isOpen() {
return this.ws && this.ws.readyState === WebSocket.OPEN;
}
_open() {
this._settled = false;
this.ws = new WebSocket(this.url);
this.ws.addEventListener("open", () => this._emit("onOpen"));
this.ws.addEventListener("close", (event) => {
const transient = !this._settled && event.code === WRONG_WORKER_CODE;
if (!transient) this._emit("onClose");
if (this._shouldRun) {
const delay = transient ? WRONG_WORKER_RETRY_MS : RECONNECT_DELAY_MS;
window.setTimeout(() => this._open(), delay);
}
});
this.ws.addEventListener("error", () => this._emit("onError"));
this.ws.addEventListener("message", (event) => {
let payload;
try {
payload = JSON.parse(event.data);
} catch {
return;
}
if (!this._settled) {
this._settled = true;
this._emit("onReady", payload);
}
this._emit("onMessage", payload);
});
}
send(message) {
if (this.isOpen()) {
this.ws.send(JSON.stringify(message));
return true;
}
return false;
}
close() {
this._shouldRun = false;
if (this.ws) this.ws.close();
}
_emit(name, payload) {
const handler = this.handlers[name];
if (typeof handler === "function") handler(payload);
}
}
+2 -2
View File
@@ -6,7 +6,7 @@ import { Toast } from "./Toast.js";
export class OptimisticAction {
async submit(url, params, errorTarget, render) {
try {
const result = await Http.sendForm(url, params);
const result = await Http.sendForm(url, params, { silent: true });
if (render) render(result);
return result;
} catch (error) {
@@ -19,7 +19,7 @@ export class OptimisticAction {
async submitOptimistic(url, params, errorTarget, apply, revert, reconcile) {
apply();
try {
const result = await Http.sendForm(url, params);
const result = await Http.sendForm(url, params, { silent: true });
if (reconcile) reconcile(result);
return result;
} catch (error) {
@@ -16,8 +16,10 @@ export class AppUpload extends Component {
this.maxFiles = this.intAttr("max-files", 10);
this.allowedTypes = this.attr("allowed-types", "")
.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
this.showChips = !this.boolAttr("hide-chips");
this.extraFields = {};
this.items = [];
this.busyCount = 0;
this.build();
}
@@ -145,6 +147,7 @@ export class AppUpload extends Component {
async upload(file) {
this.button.classList.add("uploading");
this.setBusy(true);
const body = new FormData();
body.append(this.fieldName, file);
for (const [key, value] of Object.entries(this.extraFields)) {
@@ -172,10 +175,21 @@ export class AppUpload extends Component {
this.emit("error", { file, message: error.message });
} finally {
this.button.classList.remove("uploading");
this.setBusy(false);
this.updateCount();
}
}
setBusy(busy) {
this.busyCount = Math.max(0, this.busyCount + (busy ? 1 : -1));
const active = this.busyCount > 0;
if (active === this._lastBusy) {
return;
}
this._lastBusy = active;
this.emit("busy", { busy: active });
}
setFieldFiles(files) {
if (this.input.multiple) {
this.items.push(...files.map((file) => ({ file, name: file.name })));
@@ -209,6 +223,9 @@ export class AppUpload extends Component {
renderChips() {
this.chips.textContent = "";
if (!this.showChips) {
return;
}
this.items.forEach((item, index) => {
const chip = document.createElement("span");
chip.className = "dp-upload-chip";