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:
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"); }
|
||||
}
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user