feat: add user_id index to profiles table for faster lookups

The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
This commit is contained in:
2026-06-09 16:37:49 +00:00
parent 1f3fe1b5d9
commit 0b7bfda1ab
27 changed files with 282 additions and 350 deletions
+2 -2
View File
@@ -1,6 +1,7 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
import { formatDate } from "./DateFormat.js";
class AiUsageMonitor {
@@ -26,8 +27,7 @@ class AiUsageMonitor {
this.root.classList.toggle("show-divided", this.dividedToggle.checked);
});
}
this.poll();
setInterval(() => this.poll(), this.pollMs);
this.poller = new Poller(() => this.poll(), this.pollMs);
}
async poll() {
+5 -7
View File
@@ -1,7 +1,8 @@
import { Http } from "./Http.js";
import { OptimisticAction } from "./OptimisticAction.js";
export class BookmarkManager {
export class BookmarkManager extends OptimisticAction {
constructor() {
super();
document.addEventListener("click", (event) => this.onClick(event));
}
@@ -13,15 +14,12 @@ export class BookmarkManager {
event.preventDefault();
const type = button.dataset.bookmarkType;
const uid = button.dataset.bookmarkUid;
try {
const result = await Http.sendForm(`/bookmarks/${type}/${uid}`, {});
await this.submit(`/bookmarks/${type}/${uid}`, {}, null, (result) => {
button.classList.toggle("bookmarked", result.saved);
const label = button.querySelector(".bookmark-label");
if (label) {
label.textContent = result.saved ? "Saved" : "Save";
}
} catch (error) {
console.error("bookmark failed", error);
}
});
}
}
+15 -35
View File
@@ -1,5 +1,8 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
export class ContainerInstance {
constructor(root) {
this.root = root;
@@ -38,23 +41,6 @@ export class ContainerInstance {
q(sel) { return this.root.querySelector(sel); }
async getJson(url) {
const res = await fetch(url, { headers: { "Accept": "application/json" } });
if (!res.ok) throw new Error(`request failed: ${res.status}`);
return res.json();
}
async post(url, body) {
const res = await fetch(url, {
method: "POST",
headers: { "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(body || {}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data.error && data.error.message) || `request failed: ${res.status}`);
return data;
}
toast(message, type) {
if (window.app && window.app.toast) window.app.toast.show(message, { type: type || "info" });
}
@@ -98,7 +84,7 @@ export class ContainerInstance {
const url = action === "delete" ? `${this.base}/instances/${this.uid}/delete`
: action === "sync" ? `${this.base}/instances/${this.uid}/sync`
: `${this.base}/instances/${this.uid}/${action}`;
const res = await this.post(url, {});
const res = await Http.send(url, {});
if (action === "delete") { window.location.href = "/admin/containers"; return; }
if (action === "sync") this.toast(`Synced ${res.data && res.data.imported} files`, "success");
else this.toast(`${action} requested`, "success");
@@ -108,14 +94,10 @@ export class ContainerInstance {
// ---------------- polling ----------------
startDetailPoll() {
const tick = async () => {
try {
const detail = await this.getJson(`${this.base}/instances/${this.uid}`);
this.applyDetail(detail);
} catch { /* ignore */ }
};
tick();
this.detailPoll = setInterval(tick, 4000);
this.detailPoll = new Poller(async () => {
const detail = await Http.getJson(`${this.base}/instances/${this.uid}`);
this.applyDetail(detail);
}, 4000);
}
applyDetail(detail) {
@@ -146,22 +128,20 @@ export class ContainerInstance {
startLogPoll() {
const pre = this.q("#ci-log");
const tick = async () => {
this.logPoll = new Poller(async () => {
try {
const data = await this.getJson(`${this.base}/instances/${this.uid}/logs?tail=400`);
const data = await Http.getJson(`${this.base}/instances/${this.uid}/logs?tail=400`);
const logs = data.logs || "";
pre.classList.toggle("ci-empty", !logs);
pre.textContent = logs || (this.status === "running"
? "No output yet. This panel shows the container's main process (stdout and stderr); commands run in the terminal above are not included here."
: "No logs. Start the instance to capture its main-process output.");
if (logs) pre.scrollTop = pre.scrollHeight;
} catch {
} catch (error) {
pre.classList.add("ci-empty");
pre.textContent = "Logs are currently unavailable.";
}
};
tick();
this.logPoll = setInterval(tick, 3000);
}, 3000);
}
// ---------------- exec ----------------
@@ -173,7 +153,7 @@ export class ContainerInstance {
this.q("#ci-terminal").hidden = false;
const pre = this.q("#ci-term");
try {
const res = await this.post(`${this.base}/instances/${this.uid}/exec`, { command: cmd });
const res = await Http.send(`${this.base}/instances/${this.uid}/exec`, { command: cmd });
pre.textContent += `$ ${cmd}\n${this.cleanTerm(res.output || "")}\n`;
pre.scrollTop = pre.scrollHeight;
input.value = "";
@@ -218,7 +198,7 @@ export class ContainerInstance {
if (kind === "interval") body.every_seconds = form.elements.every_seconds.value;
if (kind === "once") body.delay_seconds = form.elements.delay_seconds.value;
try {
await this.post(`${this.base}/instances/${this.uid}/schedules`, body);
await Http.send(`${this.base}/instances/${this.uid}/schedules`, body);
const modal = document.getElementById("ci-schedule-modal");
if (modal) modal.classList.remove("visible");
form.reset();
@@ -236,7 +216,7 @@ export class ContainerInstance {
});
if (!ok) return;
try {
await this.post(`${this.base}/instances/${this.uid}/schedules/${sid}/delete`, {});
await Http.send(`${this.base}/instances/${this.uid}/schedules/${sid}/delete`, {});
this.toast("Schedule removed", "success");
} catch (err) { this.toast(err.message, "error"); }
}
+5 -7
View File
@@ -1,5 +1,8 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
export class ContainerList {
constructor(root) {
this.root = root;
@@ -9,7 +12,7 @@ export class ContainerList {
}
init() {
setInterval(() => this.refresh(), this.pollMs);
this.poller = new Poller(() => this.refresh(), this.pollMs, { immediate: false });
}
escape(value) {
@@ -19,12 +22,7 @@ export class ContainerList {
}
async refresh() {
let instances;
try {
const res = await fetch(this.endpoint, { headers: { "Accept": "application/json" } });
if (!res.ok) return;
instances = (await res.json()).instances || [];
} catch { return; }
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>`;
+8 -24
View File
@@ -1,5 +1,8 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
export class ContainerManager {
constructor(root) {
this.root = root;
@@ -14,7 +17,7 @@ export class ContainerManager {
this.instances = data.instances || [];
this.bind();
this.renderInstances();
setInterval(() => this.refresh(), this.pollMs);
this.poller = new Poller(() => this.refresh(), this.pollMs, { immediate: false });
}
bind() {
@@ -29,33 +32,14 @@ export class ContainerManager {
if (modal) modal.classList.remove("visible");
}
async getJson(url) {
const res = await fetch(url, { headers: { "Accept": "application/json" } });
if (!res.ok) throw new Error(`request failed: ${res.status}`);
return res.json();
}
async post(url, body) {
const res = await fetch(url, {
method: "POST",
headers: { "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(body || {}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data.error && data.error.message) || `request failed: ${res.status}`);
return data;
}
toast(message, type) {
if (window.app && window.app.toast) window.app.toast.show(message, { type: type || "info" });
}
async refresh() {
try {
const data = await this.getJson(`${this.base}/data`);
this.instances = data.instances || [];
this.renderInstances();
} catch { /* ignore poll errors */ }
const data = await Http.getJson(`${this.base}/data`);
this.instances = data.instances || [];
this.renderInstances();
}
renderInstances() {
@@ -77,7 +61,7 @@ export class ContainerManager {
const name = form.elements.name.value.trim();
if (!name) return;
try {
await this.post(`${this.base}/instances`, {
await Http.send(`${this.base}/instances`, {
name,
boot_command: form.elements.boot_command.value.trim(),
restart_policy: form.elements.restart_policy.value,
+2 -7
View File
@@ -1,22 +1,17 @@
import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
export class CounterManager {
constructor() {
if (!document.querySelector("[data-counter]")) {
return;
}
this.interval = 30000;
this.poll();
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
this.poll();
}
});
setInterval(() => {
if (document.visibilityState === "visible") {
this.poll();
}
}, this.interval);
this.poller = new Poller(() => this.poll(), 30000, { pauseHidden: true });
}
async poll() {
+16
View File
@@ -31,6 +31,22 @@ export class Http {
return response.json();
}
static async send(url, params = {}) {
const response = await fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams(params),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.ok === false) {
throw new Error((data.error && data.error.message) || `request failed: ${response.status}`);
}
return data;
}
static async postJson(url, body) {
const response = await fetch(url, {
method: "POST",
+37
View File
@@ -0,0 +1,37 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
export class JobPoller {
static run(statusUrl, options = {}) {
const intervalMs = options.intervalMs || 1500;
const maxAttempts = options.maxAttempts || 200;
return new Promise((resolve) => {
let attempts = 0;
const timer = window.setInterval(async () => {
attempts += 1;
if (attempts > maxAttempts) {
window.clearInterval(timer);
if (options.onTimeout) options.onTimeout();
resolve(null);
return;
}
let status;
try {
status = await Http.getJson(statusUrl);
} catch (error) {
return;
}
if (status.status === "done") {
window.clearInterval(timer);
if (options.onDone) options.onDone(status);
resolve(status);
} else if (status.status === "failed") {
window.clearInterval(timer);
if (options.onFailed) options.onFailed(status);
resolve(status);
}
}, intervalMs);
});
}
}
+18
View File
@@ -0,0 +1,18 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { Toast } from "./Toast.js";
export class OptimisticAction {
async submit(url, params, errorTarget, render) {
try {
const result = await Http.sendForm(url, params);
if (render) render(result);
return result;
} catch (error) {
console.error("optimistic action failed", error);
if (errorTarget) Toast.flash(errorTarget, "Error", 1500);
return null;
}
}
}
+4 -8
View File
@@ -1,7 +1,8 @@
import { Http } from "./Http.js";
import { OptimisticAction } from "./OptimisticAction.js";
export class PollManager {
export class PollManager extends OptimisticAction {
constructor() {
super();
document.addEventListener("click", (event) => this.onClick(event));
document.addEventListener("submit", (event) => this.onSubmit(event), true);
}
@@ -39,12 +40,7 @@ export class PollManager {
}
const pollUid = poll.dataset.pollUid;
const optionUid = option.dataset.optionUid;
try {
const result = await Http.sendForm(`/polls/${pollUid}/vote`, { option_uid: optionUid });
this.render(poll, result);
} catch (error) {
console.error("poll vote failed", error);
}
await this.submit(`/polls/${pollUid}/vote`, { option_uid: optionUid }, null, (result) => this.render(poll, result));
}
render(poll, result) {
+33
View File
@@ -0,0 +1,33 @@
// retoor <retoor@molodetz.nl>
export class Poller {
constructor(fn, intervalMs, options = {}) {
this._fn = fn;
this._interval = intervalMs;
this._pauseHidden = !!options.pauseHidden;
this._timer = null;
if (options.immediate !== false) this.tick();
this.start();
}
start() {
if (this._timer) return;
this._timer = window.setInterval(() => this.tick(), this._interval);
}
stop() {
if (this._timer) {
window.clearInterval(this._timer);
this._timer = null;
}
}
async tick() {
if (this._pauseHidden && document.hidden) return;
try {
await this._fn();
} catch (error) {
return;
}
}
}
+3 -11
View File
@@ -1,3 +1,4 @@
import { Http } from "./Http.js";
import { Toast } from "./Toast.js";
import { modeForFilename } from "./codemirrorModes.js";
@@ -429,17 +430,8 @@ export class ProjectFiles {
this.currentLabel.hidden = true;
}
async post(action, body) {
const res = await fetch(`${this.base}/${action}`, {
method: "POST",
headers: { "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(body).toString(),
});
const data = await res.json();
if (!res.ok || data.ok === false) {
throw new Error(data.error ? data.error.message : "Operation failed");
}
return data;
post(action, body) {
return Http.send(`${this.base}/${action}`, body);
}
async refresh(openPath) {
+10 -29
View File
@@ -1,11 +1,10 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { JobPoller } from "./JobPoller.js";
export class ProjectForker {
constructor() {
this.pollMs = 1500;
this.maxPolls = 200;
this.active = new Set();
document.addEventListener("click", (event) => {
const trigger = event.target.closest("[data-fork-project]");
@@ -42,33 +41,15 @@ export class ProjectForker {
}
poll(statusUrl) {
return new Promise((resolve) => {
let attempts = 0;
const timer = setInterval(async () => {
attempts += 1;
if (attempts > this.maxPolls) {
clearInterval(timer);
window.app.toast.show("Fork timed out", { type: "error" });
resolve();
return;
}
let status;
try {
status = await Http.getJson(statusUrl);
} catch {
return;
}
if (status.status === "done") {
clearInterval(timer);
window.app.toast.show("Fork ready", { type: "success" });
window.location.href = status.project_url;
resolve();
} else if (status.status === "failed") {
clearInterval(timer);
window.app.toast.show("Fork failed: " + (status.error || "unknown error"), { type: "error" });
resolve();
}
}, this.pollMs);
return JobPoller.run(statusUrl, {
onDone: (status) => {
window.app.toast.show("Fork ready", { type: "success" });
window.location.href = status.project_url;
},
onFailed: (status) => {
window.app.toast.show("Fork failed: " + (status.error || "unknown error"), { type: "error" });
},
onTimeout: () => window.app.toast.show("Fork timed out", { type: "error" }),
});
}
}
+4 -8
View File
@@ -1,7 +1,8 @@
import { Http } from "./Http.js";
import { OptimisticAction } from "./OptimisticAction.js";
export class ReactionBar {
export class ReactionBar extends OptimisticAction {
constructor() {
super();
document.addEventListener("click", (event) => this.onClick(event));
}
@@ -42,12 +43,7 @@ export class ReactionBar {
const type = bar.dataset.reactionType;
const uid = bar.dataset.reactionUid;
const emoji = trigger.dataset.reactionEmoji;
try {
const result = await Http.sendForm(`/reactions/${type}/${uid}`, { emoji });
this.render(bar, result);
} catch (error) {
console.error("reaction failed", error);
}
await this.submit(`/reactions/${type}/${uid}`, { emoji }, null, (result) => this.render(bar, result));
this.closeAllPalettes();
}
+3 -11
View File
@@ -1,4 +1,5 @@
import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
import { Toast } from "./Toast.js";
import { formatDate } from "./DateFormat.js";
@@ -15,8 +16,7 @@ class ServiceMonitor {
if (!container) return;
this.bindActions(container);
this.bindConfigForms(container);
this.poll();
setInterval(() => this.poll(), this.pollInterval);
this.poller = new Poller(() => this.poll(), this.pollInterval);
}
bindActions(container) {
@@ -63,15 +63,7 @@ class ServiceMonitor {
params.append(input.name, input.value);
});
try {
const response = await fetch(`/admin/services/${name}/config`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-Requested-With": "fetch",
},
body: params,
});
const data = await response.json();
const data = await Http.sendForm(`/admin/services/${name}/config`, params);
if (data.ok) {
if (statusEl) Toast.flash(statusEl, "Saved", 2000, "");
await this.poll();
+5 -11
View File
@@ -1,8 +1,8 @@
import { Toast } from "./Toast.js";
import { Http } from "./Http.js";
import { OptimisticAction } from "./OptimisticAction.js";
export class VoteManager {
export class VoteManager extends OptimisticAction {
constructor() {
super();
this.initVoteButtons();
}
@@ -17,16 +17,10 @@ export class VoteManager {
});
}
async cast(form, button) {
cast(form, button) {
const action = form.getAttribute("action");
const value = form.querySelector('input[name="value"]').value;
try {
const result = await Http.sendForm(action, { value });
this.render(action, result);
} catch (error) {
console.error("vote failed", error);
Toast.flash(button, "Error", 1500);
}
return this.submit(action, { value }, button, (result) => this.render(action, result));
}
render(action, result) {
+10 -29
View File
@@ -1,11 +1,10 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { JobPoller } from "./JobPoller.js";
export class ZipDownloader {
constructor() {
this.pollMs = 1500;
this.maxPolls = 200;
this.active = new Set();
document.addEventListener("click", (event) => {
const trigger = event.target.closest("[data-zip-download]");
@@ -30,33 +29,15 @@ export class ZipDownloader {
}
poll(statusUrl) {
return new Promise((resolve) => {
let attempts = 0;
const timer = setInterval(async () => {
attempts += 1;
if (attempts > this.maxPolls) {
clearInterval(timer);
window.app.toast.show("Download timed out", { type: "error" });
resolve();
return;
}
let status;
try {
status = await Http.getJson(statusUrl);
} catch {
return;
}
if (status.status === "done") {
clearInterval(timer);
this.trigger(status.download_url);
window.app.toast.show("Download ready", { type: "success" });
resolve();
} else if (status.status === "failed") {
clearInterval(timer);
window.app.toast.show("Zip failed: " + (status.error || "unknown error"), { type: "error" });
resolve();
}
}, this.pollMs);
return JobPoller.run(statusUrl, {
onDone: (status) => {
this.trigger(status.download_url);
window.app.toast.show("Download ready", { type: "success" });
},
onFailed: (status) => {
window.app.toast.show("Zip failed: " + (status.error || "unknown error"), { type: "error" });
},
onTimeout: () => window.app.toast.show("Download timed out", { type: "error" }),
});
}
@@ -1,6 +1,7 @@
// retoor <retoor@molodetz.nl>
import FloatingWindow from "./FloatingWindow.js";
import { Http } from "../Http.js";
import { userScope } from "../userScope.js";
const XTERM_JS = "/static/vendor/xterm/xterm.js";
@@ -252,23 +253,15 @@ export default class ContainerTerminalElement extends FloatingWindow {
];
}
_post(action) {
async _lifecycle(action, okMessage) {
const slug = this.getAttribute("project-slug");
const instance = this.getAttribute("instance");
return fetch(
`/projects/${encodeURIComponent(slug)}/containers/instances/${encodeURIComponent(instance)}/${action}`,
{ method: "POST", headers: { "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded" } },
);
}
async _lifecycle(action, okMessage) {
const url = `/projects/${encodeURIComponent(slug)}/containers/instances/${encodeURIComponent(instance)}/${action}`;
try {
const res = await this._post(action);
const data = await res.json().catch(() => ({}));
if (res.ok) this._toast(okMessage, "success");
else this._toast((data.error && data.error.message) || `${action} failed`, "error");
await Http.send(url, {});
this._toast(okMessage, "success");
} catch (error) {
this._toast(`${action} failed`, "error");
this._toast(error.message || `${action} failed`, "error");
}
}
@@ -25,6 +25,10 @@ export default class FloatingWindow extends HTMLElement {
return false;
}
get _dragClass() {
return "fw-dragging";
}
connectedCallback() {
this._ensureBaseCss();
this.classList.add("fw-host");
@@ -246,7 +250,7 @@ export default class FloatingWindow extends HTMLElement {
const point = event.touches ? event.touches[0] : event;
const rect = this.win.getBoundingClientRect();
this._drag = { dx: point.clientX - rect.left, dy: point.clientY - rect.top };
this.classList.add("fw-dragging");
this.classList.add(this._dragClass);
this._moveHandler = (move) => this._onDrag(move);
this._upHandler = () => this._endDrag();
window.addEventListener("mousemove", this._moveHandler);
@@ -268,7 +272,7 @@ export default class FloatingWindow extends HTMLElement {
_endDrag() {
this._drag = null;
this.classList.remove("fw-dragging");
this.classList.remove(this._dragClass);
window.removeEventListener("mousemove", this._moveHandler);
window.removeEventListener("mouseup", this._upHandler);
window.removeEventListener("touchmove", this._moveHandler);
@@ -283,7 +287,7 @@ export default class FloatingWindow extends HTMLElement {
if (!this.geometry) this.geometry = this._defaultGeometry();
const point = event.touches ? event.touches[0] : event;
this._resize = { x: point.clientX, y: point.clientY, w: this.geometry.width, h: this.geometry.height };
this.classList.add("fw-dragging");
this.classList.add(this._dragClass);
this._resizeMove = (move) => this._onResizeDrag(move);
this._resizeUp = () => this._endResize();
window.addEventListener("mousemove", this._resizeMove);
@@ -306,7 +310,7 @@ export default class FloatingWindow extends HTMLElement {
_endResize() {
this._resize = null;
this.classList.remove("fw-dragging");
this.classList.remove(this._dragClass);
window.removeEventListener("mousemove", this._resizeMove);
window.removeEventListener("mouseup", this._resizeUp);
window.removeEventListener("touchmove", this._resizeMove);
+15 -127
View File
@@ -1,5 +1,6 @@
// retoor <retoor@molodetz.nl>
import FloatingWindow from "../components/FloatingWindow.js";
import Markdown from "./markdown.js";
import DeviiSocket from "./DeviiSocket.js";
import DeviiClient from "./DeviiClient.js";
@@ -13,7 +14,6 @@ const FONT_MIN = 10;
const FONT_MAX = 26;
const FONT_DEFAULT = 14;
const HISTORY_LIMIT = 500;
const MOBILE_QUERY = "(max-width: 640px)";
const TRACE_GLYPHS = {
call: "↳",
ok: "✓",
@@ -39,7 +39,7 @@ const HELP = [
"Anything else is sent to the agent.",
].join("\n");
export default class DeviiTerminalElement extends HTMLElement {
export default class DeviiTerminalElement extends FloatingWindow {
constructor() {
super();
this.markdown = new Markdown();
@@ -49,27 +49,19 @@ export default class DeviiTerminalElement extends HTMLElement {
this.historyIndex = 0;
this.draft = "";
this._historyKey = null;
this.state = "closed";
this.geometry = null;
this._drag = null;
this._hasFocus = false;
this._unloading = false;
this._mobile = window.matchMedia(MOBILE_QUERY);
}
get _dragClass() {
return "devii-dragging";
}
connectedCallback() {
this._ensureCss();
this._build();
this._buildChrome();
this._registerWindow();
this._applyFont(this._readFont());
if (globalThis.app && typeof globalThis.app.register === "function") {
globalThis.app.register(this);
}
if (globalThis.app && globalThis.app.windows) {
globalThis.app.windows.register(this);
this.addEventListener("pointerdown", () => {
if (globalThis.app && globalThis.app.windows) globalThis.app.windows.focus(this);
});
}
this._mobile.addEventListener("change", () => this._onViewportChange());
window.addEventListener("resize", () => this._clampWindow());
window.addEventListener("pagehide", () => this._captureFocusForUnload());
@@ -112,6 +104,7 @@ export default class DeviiTerminalElement extends HTMLElement {
}
this._restoring = false;
this._setState(initial);
this._observeResize();
if (this.state !== "closed") {
this._ensureConnected();
this._focusInput();
@@ -169,6 +162,10 @@ export default class DeviiTerminalElement extends HTMLElement {
}
}
_persistGeometry() {
this._persist();
}
_historyKeyFor(identity) {
const scope = identity && identity.loggedIn && identity.username
? `user:${identity.username}`
@@ -218,7 +215,7 @@ export default class DeviiTerminalElement extends HTMLElement {
document.head.appendChild(link);
}
_build() {
_buildChrome() {
this.innerHTML = "";
this.launcher = document.createElement("button");
this.launcher.type = "button";
@@ -278,9 +275,6 @@ export default class DeviiTerminalElement extends HTMLElement {
this.titlebar.addEventListener("mousedown", (event) => this._startDrag(event));
this.titlebar.addEventListener("touchstart", (event) => this._startDrag(event), { passive: false });
this.titlebar.addEventListener("dblclick", () => this._window("maximize"));
if (globalThis.app && globalThis.app.contextMenu) {
globalThis.app.contextMenu.attach(this.win, () => this._contextItems());
}
}
open() {
@@ -298,46 +292,10 @@ export default class DeviiTerminalElement extends HTMLElement {
else this.close();
}
_window(action) {
if (action === "close") {
this.close();
} else if (action === "maximize") {
this._setState(this.state === "maximized" ? "normal" : "maximized");
} else if (action === "fullscreen") {
this._setState(this.state === "fullscreen" ? "normal" : "fullscreen");
} else if (action === "font-inc") {
this._changeFont(1);
} else if (action === "font-dec") {
this._changeFont(-1);
} else if (action === "minimize") {
this.minimizeWindow();
} else if (action === "normalize") {
this.normalizeWindow();
}
}
minimizeWindow() {
this._presetGeometry(400, 260);
}
normalizeWindow() {
this._presetGeometry(760, 600);
}
_presetGeometry(width, height) {
const w = Math.max(300, Math.min(width, window.innerWidth - 16));
const h = Math.max(220, Math.min(height, window.innerHeight - 16));
this.geometry = {
width: w,
height: h,
left: Math.max(8, Math.round((window.innerWidth - w) / 2)),
top: Math.max(8, Math.round((window.innerHeight - h) / 2)),
};
this._clampGeometry();
this._setState("normal");
this._persist();
}
_contextItems() {
return [
{ label: "Minimize", onSelect: () => this.minimizeWindow() },
@@ -408,81 +366,11 @@ export default class DeviiTerminalElement extends HTMLElement {
};
}
_applyGeometry() {
if (!this.geometry) this.geometry = this._defaultGeometry();
const g = this.geometry;
this.win.style.width = `${g.width}px`;
this.win.style.height = `${g.height}px`;
this.win.style.left = `${g.left}px`;
this.win.style.top = `${g.top}px`;
}
_clampGeometry() {
if (!this.geometry) return;
const g = this.geometry;
g.width = Math.min(g.width, window.innerWidth - 16);
g.height = Math.min(g.height, window.innerHeight - 16);
g.left = Math.max(8, Math.min(g.left, window.innerWidth - g.width - 8));
g.top = Math.max(8, Math.min(g.top, window.innerHeight - g.height - 8));
}
_clampWindow() {
if (this.state !== "normal" || !this.geometry) return;
this._clampGeometry();
this._applyGeometry();
this._persist();
}
_onViewportChange() {
if (this.state === "closed") return;
this._setState("open");
}
_startDrag(event) {
if (this.state !== "normal") return;
if (event.target.tagName === "BUTTON") return;
event.preventDefault();
const point = event.touches ? event.touches[0] : event;
const rect = this.win.getBoundingClientRect();
this._drag = { dx: point.clientX - rect.left, dy: point.clientY - rect.top };
this.classList.add("devii-dragging");
this._moveHandler = (move) => this._onDrag(move);
this._upHandler = () => this._endDrag();
window.addEventListener("mousemove", this._moveHandler);
window.addEventListener("mouseup", this._upHandler);
window.addEventListener("touchmove", this._moveHandler, { passive: false });
window.addEventListener("touchend", this._upHandler);
}
_onDrag(event) {
if (!this._drag) return;
if (event.cancelable) event.preventDefault();
const point = event.touches ? event.touches[0] : event;
const g = this.geometry;
g.left = Math.max(8, Math.min(point.clientX - this._drag.dx, window.innerWidth - g.width - 8));
g.top = Math.max(8, Math.min(point.clientY - this._drag.dy, window.innerHeight - g.height - 8));
this.win.style.left = `${g.left}px`;
this.win.style.top = `${g.top}px`;
}
_endDrag() {
this._drag = null;
this.classList.remove("devii-dragging");
window.removeEventListener("mousemove", this._moveHandler);
window.removeEventListener("mouseup", this._upHandler);
window.removeEventListener("touchmove", this._moveHandler);
window.removeEventListener("touchend", this._upHandler);
this._syncSize();
}
_syncSize() {
if (this.state !== "normal") return;
const rect = this.win.getBoundingClientRect();
this.geometry.width = Math.round(rect.width);
this.geometry.height = Math.round(rect.height);
this._persist();
}
_onKey(event) {
if (event.key === "Enter") {
event.preventDefault();
@@ -751,7 +639,7 @@ export default class DeviiTerminalElement extends HTMLElement {
document.execCommand("copy");
done();
} catch (error) {
// copy unavailable; leave the button unchanged
return;
}
document.body.removeChild(area);
}
@@ -48,6 +48,16 @@ Every component **renders into the light DOM (no shadow root)** so the global CS
**The component-vs-module rule.** Self-contained presentational widgets become components. Partial-bound controllers that enhance server-rendered markup (votes, reactions, comments) and pure utilities stay plain modules, because they augment existing HTML rather than render standalone UI.
## Shared utilities
A few plain modules own the cross-cutting patterns so feature code stays small. Reach for these instead of hand-rolling a fetch, an interval, or a click handler:
- **`Http`** (`static/js/Http.js`) is the single fetch helper: `getJson`, `sendForm` (form-encoded POST that follows the login redirect), and `send` (POST that throws the server's `error.message` on a non-2xx response or a `200 {ok: false}` body). The container, admin, and project-files UIs all go through it rather than calling `fetch` directly.
- **`Poller`** (`static/js/Poller.js`) wraps the universal live-update loop: `new Poller(fn, intervalMs, { pauseHidden })` with `start`/`stop`/`tick`, swallowing per-tick errors so one failed poll never kills the loop. Every polling controller (notification counts, the service monitor, AI usage, the container manager and instance pages) uses it.
- **`JobPoller`** (`static/js/JobPoller.js`) is the frontend half of the async job framework: `JobPoller.run(statusUrl, { onDone, onFailed, onTimeout })` polls the status URL and fires one callback. The zip download and project fork flows share it.
- **`OptimisticAction`** (`static/js/OptimisticAction.js`) is the base for click-to-POST engagement controllers. `VoteManager`, `ReactionBar`, `BookmarkManager`, and `PollManager` extend it and call `submit(url, params, errorTarget, render)` for the shared POST -> render -> error path, keeping only their own event wiring and render.
- **`FloatingWindow`** (`static/js/components/FloatingWindow.js`) is the draggable, resizable window base shared by the container terminals and the Devii terminal; `WindowManager` (`app.windows`) gives every window a shared last-clicked-on-top z-order.
## Content rendering pipeline
`ContentRenderer.js` runs on every element marked `data-render`, in this exact order:
@@ -7,7 +7,7 @@ The async job framework (`services/jobs/`) is the standard way to run heavy, blo
## When to use it
Use a job service whenever a request would otherwise block on slow work: archiving, exporting, rendering, batch processing, or anything that should survive a page reload and be polled. The request enqueues a row and returns immediately; a background worker does the work; the client polls for completion.
Use a job service whenever a request would otherwise block on slow work: archiving, exporting, rendering, batch processing, or anything that should survive a page reload and be polled. The request enqueues a row and returns immediately; a background worker does the work; the client polls for completion. On the client, the shared `JobPoller.run(statusUrl, { onDone, onFailed, onTimeout })` helper (`static/js/JobPoller.js`) is the canonical way to drive that poll.
## The jobs table
@@ -2,9 +2,14 @@
# devii-terminal
The draggable terminal window for the Devii assistant. It connects to `/devii/ws`, renders
markdown replies, keeps input history, and supports normal, maximized, and fullscreen states with
geometry persisted to `localStorage`. It is created automatically by the `DeviiTerminal`
controller (`app.devii`) and appended to the page; you normally do not place it by hand.
markdown replies, keeps input history, and supports closed, normal, maximized, and fullscreen
states with geometry persisted to `localStorage`. It is created automatically by the
`DeviiTerminal` controller (`app.devii`) and appended to the page; you normally do not place it by
hand.
It extends the shared `FloatingWindow` base (`static/js/components/FloatingWindow.js`), reusing the
same drag, resize, geometry-persistence, and window chrome as the container terminals, and adds
its own launcher button, closed state, and socket/markdown/history.
Source: `static/js/devii/devii-terminal.js`.
+4 -2
View File
@@ -38,6 +38,8 @@ is available everywhere - including these documentation pages.
Components are the self-contained, presentational UI pieces. The rest of the frontend is
intentionally plain ES6 modules: page controllers that enhance server-rendered partials (votes,
reactions, comments, polls) and utilities (HTTP, DOM helpers). Those are not custom elements
because they operate on markup produced server-side rather than rendering standalone UI.
reactions, comments, polls - which share the `OptimisticAction` base) and small utilities (`Http`
for fetch, `Poller` and `JobPoller` for live and job-status polling, plus DOM helpers). Those are
not custom elements because they operate on markup produced server-side rather than rendering
standalone UI.
</div>
+1 -1
View File
@@ -38,7 +38,7 @@ The job uid (a sortable uuid7) is the capability token: anyone with the link can
## Frontend
`static/js/ZipDownloader.js` (instantiated as `app.zipDownloader`) drives the whole flow: it POSTs to the enqueue URL, polls `GET /zips/{uid}` on an interval, and triggers a hidden download link once the job is `done`, with toasts for progress and errors. Any element with a `data-zip-download` attribute is wired automatically (the "Download zip" button on the project detail page and the "Download as zip" button on the files page); the files context menu calls `app.zipDownloader.start(url)` directly for a single file or folder.
`static/js/ZipDownloader.js` (instantiated as `app.zipDownloader`) drives the whole flow: it POSTs to the enqueue URL, polls `GET /zips/{uid}` through the shared `JobPoller` (`static/js/JobPoller.js`), and triggers a hidden download link once the job is `done`, with toasts for progress and errors. Any element with a `data-zip-download` attribute is wired automatically (the "Download zip" button on the project detail page and the "Download as zip" button on the files page); the files context menu calls `app.zipDownloader.start(url)` directly for a single file or folder.
## Configuration, metrics, and quotas