feat: add container manager CLI commands and docker-compose overlay for admin container lifecycle

This commit is contained in:
2026-06-09 04:41:27 +00:00
parent 317126efc2
commit c565e3b55c
83 changed files with 6078 additions and 84 deletions
+31
View File
@@ -0,0 +1,31 @@
/* retoor <retoor@molodetz.nl> */
.cm-page { max-width: 1400px; margin: 0 auto; padding: 1rem; }
.cm-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
.cm-title { font-size: 1.4rem; margin: 0; }
.cm-body { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: start; }
.cm-col { background: var(--surface, #1b1f24); border: 1px solid var(--border, #2a2f36); border-radius: 8px; padding: 1rem; }
.cm-col-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
.cm-col-head h2 { font-size: 1.1rem; margin: 0; }
.cm-list { list-style: none; margin: 0; padding: 0; }
.cm-item { padding: 0.5rem 0.75rem; border-radius: 6px; cursor: pointer; display: flex; justify-content: space-between; gap: 0.5rem; }
.cm-item:hover { background: var(--surface-2, #232830); }
.cm-item.active { background: var(--accent-soft, #2d3748); }
.cm-detail { margin-top: 1rem; border-top: 1px solid var(--border, #2a2f36); padding-top: 1rem; }
.cm-detail-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; flex-wrap: wrap; }
.cm-detail-name { font-weight: 600; flex: 1; }
.cm-editor { width: 100%; min-height: 260px; font-family: monospace; font-size: 0.85rem; background: #0f1216; color: #d7dde3; border: 1px solid var(--border, #2a2f36); border-radius: 6px; padding: 0.5rem; resize: vertical; }
.cm-builds { margin-top: 1rem; }
.cm-builds h3 { font-size: 0.95rem; margin: 0 0 0.5rem; }
.cm-log { background: #0f1216; color: #cfe3cf; font-family: monospace; font-size: 0.8rem; padding: 0.75rem; border-radius: 6px; max-height: 320px; overflow: auto; white-space: pre-wrap; margin-top: 0.75rem; }
.cm-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.75rem; }
.cm-metrics { font-family: monospace; font-size: 0.8rem; color: var(--muted, #9aa4b2); margin-bottom: 0.5rem; }
.cm-exec { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; }
.cm-exec input { flex: 1; padding: 0.4rem 0.6rem; border-radius: 6px; border: 1px solid var(--border, #2a2f36); background: #0f1216; color: #d7dde3; font-family: monospace; }
.cm-badge { font-size: 0.7rem; padding: 0.1rem 0.45rem; border-radius: 10px; background: #2a2f36; text-transform: uppercase; letter-spacing: 0.03em; }
.cm-badge.cm-running { background: #1f7a3d; color: #fff; }
.cm-badge.cm-crashed { background: #8a2b2b; color: #fff; }
.cm-badge.cm-stopped { background: #4a5160; color: #fff; }
.cm-badge.cm-paused { background: #8a6d1f; color: #fff; }
@media (max-width: 900px) { .cm-body { grid-template-columns: 1fr; } }
+10
View File
@@ -224,3 +224,13 @@
width: 180px;
}
}
.pf-readonly-banner {
margin: 0.75rem 0;
padding: 0.6rem 0.9rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-muted);
color: var(--text-muted);
font-size: 0.85rem;
}
+225
View File
@@ -0,0 +1,225 @@
// retoor <retoor@molodetz.nl>
export class ContainerManager {
constructor(root) {
this.root = root;
this.slug = root.dataset.slug;
this.base = `/projects/${this.slug}/containers`;
this.dockerfiles = [];
this.instances = [];
this.selectedDockerfile = null;
this.pollMs = 3000;
this.buildPoll = null;
}
init() {
const data = JSON.parse(this.root.querySelector("#cm-data").textContent || "{}");
this.dockerfiles = data.dockerfiles || [];
this.instances = data.instances || [];
this.bind();
this.renderDockerfiles();
this.renderInstances();
this.fillDockerfileSelect();
setInterval(() => this.refresh(), this.pollMs);
}
bind() {
this.q("#cm-save-version").addEventListener("click", () => this.saveVersion());
this.q("#cm-rebuild").addEventListener("click", () => this.rebuild());
const dfForm = document.getElementById("cm-new-dockerfile-form");
if (dfForm) dfForm.addEventListener("submit", (e) => { e.preventDefault(); this.createDockerfile(dfForm); });
const instForm = document.getElementById("cm-new-instance-form");
if (instForm) instForm.addEventListener("submit", (e) => { e.preventDefault(); this.createInstance(instForm); });
}
q(sel) { return this.root.querySelector(sel); }
closeModal(id) {
const modal = document.getElementById(id);
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.dockerfiles = data.dockerfiles || [];
this.instances = data.instances || [];
this.renderDockerfiles();
this.renderInstances();
this.fillDockerfileSelect();
} catch { /* ignore poll errors */ }
}
// ---------------- dockerfiles ----------------
renderDockerfiles() {
const list = this.q("#cm-dockerfile-list");
list.innerHTML = "";
for (const df of this.dockerfiles) {
const li = document.createElement("li");
li.className = "cm-item" + (this.selectedDockerfile === df.uid ? " active" : "");
li.textContent = `${df.name} (v${df.current_version}, build ${df.latest_build_number})`;
li.addEventListener("click", () => this.openDockerfile(df.uid));
list.appendChild(li);
}
}
fillDockerfileSelect() {
const select = document.getElementById("cm-inst-new-df");
if (!select) return;
const current = select.value;
select.innerHTML = "";
for (const df of this.dockerfiles) {
const option = document.createElement("option");
option.value = df.uid;
option.textContent = df.name;
select.appendChild(option);
}
if (current) select.value = current;
else if (this.selectedDockerfile) select.value = this.selectedDockerfile;
}
async createDockerfile(form) {
const name = form.elements.name.value.trim();
if (!name) return;
try {
const res = await this.post(`${this.base}/dockerfiles`, {
name, description: form.elements.description.value.trim(), tags: "",
});
this.closeModal("cm-new-dockerfile-modal");
form.reset();
this.toast("Dockerfile created, build queued", "success");
await this.refresh();
if (res.data && res.data.dockerfile) this.openDockerfile(res.data.dockerfile.uid);
} catch (err) { this.toast(err.message, "error"); }
}
async openDockerfile(uid) {
this.selectedDockerfile = uid;
try {
const detail = await this.getJson(`${this.base}/dockerfiles/${uid}`);
this.q("#cm-dockerfile-detail").hidden = false;
this.q("#cm-df-name").textContent = detail.dockerfile.name;
this.q("#cm-df-editor").value = detail.content || "";
this.renderBuilds(detail.builds || []);
this.renderDockerfiles();
this.fillDockerfileSelect();
} catch (err) { this.toast(err.message, "error"); }
}
renderBuilds(builds) {
const list = this.q("#cm-build-list");
list.innerHTML = "";
for (const b of builds) {
const li = document.createElement("li");
li.className = "cm-item";
li.textContent = `${b.image_tag} [${b.status}]`;
li.addEventListener("click", () => this.watchBuild(b.uid));
list.appendChild(li);
}
}
async saveVersion() {
if (!this.selectedDockerfile) return;
const content = this.q("#cm-df-editor").value;
try {
const res = await this.post(`${this.base}/dockerfiles/${this.selectedDockerfile}/version`, { content });
if (res.data && res.data.changed) {
this.toast("New version saved, build queued", "success");
if (res.data.build_uid) this.watchBuild(res.data.build_uid);
} else {
this.toast("No change in content", "info");
}
await this.refresh();
} catch (err) { this.toast(err.message, "error"); }
}
async rebuild() {
if (!this.selectedDockerfile) return;
try {
const res = await this.post(`${this.base}/dockerfiles/${this.selectedDockerfile}/build`, {});
this.toast("Rebuild queued", "success");
if (res.data && res.data.build_uid) this.watchBuild(res.data.build_uid);
} catch (err) { this.toast(err.message, "error"); }
}
watchBuild(uid) {
if (this.buildPoll) clearInterval(this.buildPoll);
const pre = this.q("#cm-build-log");
pre.hidden = false;
const tick = async () => {
try {
const build = await this.getJson(`${this.base}/builds/${uid}`);
pre.textContent = `[${build.status}] ${build.image_tag}\n\n${build.logs || ""}${build.error || ""}`;
pre.scrollTop = pre.scrollHeight;
if (["success", "failed", "cancelled"].includes(build.status)) {
clearInterval(this.buildPoll);
this.buildPoll = null;
this.refresh();
}
} catch { /* ignore */ }
};
tick();
this.buildPoll = setInterval(tick, 1500);
}
// ---------------- instances ----------------
renderInstances() {
const list = this.q("#cm-instance-list");
list.innerHTML = "";
for (const inst of this.instances) {
const li = document.createElement("li");
li.className = "cm-item";
const link = document.createElement("a");
link.className = "cm-item-link";
link.href = `/admin/containers/${inst.uid}`;
link.innerHTML = `<span>${inst.name}</span> <span class="cm-badge cm-${inst.status}">${inst.status}</span>`;
li.appendChild(link);
list.appendChild(li);
}
}
async createInstance(form) {
if (!this.dockerfiles.length) { this.toast("Create a Dockerfile first", "error"); return; }
const name = form.elements.name.value.trim();
if (!name) return;
try {
await this.post(`${this.base}/instances`, {
dockerfile_uid: form.elements.dockerfile_uid.value,
name,
boot_command: form.elements.boot_command.value.trim(),
restart_policy: form.elements.restart_policy.value,
ports: form.elements.ports.value.trim(),
ingress_slug: form.elements.ingress_slug.value.trim(),
ingress_port: form.elements.ingress_port.value.trim(),
autostart: form.elements.autostart.checked ? "true" : "false",
});
this.closeModal("cm-new-instance-modal");
form.reset();
this.toast("Instance created", "success");
await this.refresh();
} catch (err) { this.toast(err.message, "error"); }
}
}
+78 -9
View File
@@ -6,6 +6,8 @@ import DeviiClient from "./DeviiClient.js";
const CSS_ID = "devii-terminal-css";
const STORAGE_KEY = "devii-terminal-state";
const HISTORY_KEY = "devii-terminal-history";
const HISTORY_LIMIT = 500;
const MOBILE_QUERY = "(max-width: 640px)";
const TRACE_GLYPHS = {
call: "↳",
@@ -16,12 +18,14 @@ const TRACE_GLYPHS = {
"verify-gate": "verification gate",
compact: "context compaction",
};
const CLEAR_WORDS = new Set(["clear", "reset", "/clear", "/reset"]);
const CLEAR_WORDS = new Set(["clear", "/clear"]);
const RESET_WORDS = new Set(["reset", "/reset"]);
const CLOSE_WORDS = new Set(["exit", "close", "quit", "bye", "/exit", "/close", "/quit", "/bye"]);
const HELP = [
"Local commands:",
" /help show this help",
" clear | reset clear the conversation",
" clear clear the conversation",
" reset cancel the running task and clear the conversation",
" exit | close | quit | bye close the terminal",
" /show show devii (the avatar)",
" /hide hide devii",
@@ -38,6 +42,8 @@ export default class DeviiTerminalElement extends HTMLElement {
this.socket = null;
this.history = [];
this.historyIndex = 0;
this.draft = "";
this._historyKey = null;
this.state = "closed";
this.geometry = null;
this._drag = null;
@@ -57,7 +63,10 @@ export default class DeviiTerminalElement extends HTMLElement {
window.addEventListener("pagehide", () => this._captureFocusForUnload());
window.addEventListener("beforeunload", () => this._captureFocusForUnload());
this._identity = null;
this._fetchIdentity().then((id) => { this._identity = id; });
this._fetchIdentity().then((id) => {
this._identity = id;
this._initHistory(id);
});
window.addEventListener("focus", () => {
this._checkIdentity();
this._reportVisibility();
@@ -147,6 +156,40 @@ export default class DeviiTerminalElement extends HTMLElement {
}
}
_historyKeyFor(identity) {
const scope = identity && identity.loggedIn && identity.username
? `user:${identity.username}`
: "guest";
return `${HISTORY_KEY}:${scope}`;
}
_initHistory(identity) {
this._historyKey = this._historyKeyFor(identity);
this.history = this._loadHistory();
this.historyIndex = this.history.length;
this.draft = "";
}
_loadHistory() {
if (!this._historyKey) return [];
try {
const raw = window.localStorage.getItem(this._historyKey);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === "string") : [];
} catch (error) {
return [];
}
}
_saveHistory() {
if (!this._historyKey) return;
try {
window.localStorage.setItem(this._historyKey, JSON.stringify(this.history));
} catch (error) {
return;
}
}
_ensureConnected() {
if (this._connected) return;
this._connected = true;
@@ -360,10 +403,10 @@ export default class DeviiTerminalElement extends HTMLElement {
const text = this.input.value;
this.input.value = "";
this._submit(text);
} else if (event.key === "ArrowUp") {
} else if (event.key === "ArrowUp" || event.key === "PageUp") {
event.preventDefault();
this._recall(-1);
} else if (event.key === "ArrowDown") {
} else if (event.key === "ArrowDown" || event.key === "PageDown") {
event.preventDefault();
this._recall(1);
} else if (event.key === "Escape") {
@@ -373,20 +416,38 @@ export default class DeviiTerminalElement extends HTMLElement {
_recall(direction) {
if (!this.history.length) return;
this.historyIndex = Math.max(0, Math.min(this.history.length, this.historyIndex + direction));
this.input.value = this.history[this.historyIndex] || "";
if (this.historyIndex === this.history.length) {
this.draft = this.input.value;
}
const target = Math.max(0, Math.min(this.history.length, this.historyIndex + direction));
if (target === this.historyIndex) return;
this.historyIndex = target;
const value = target === this.history.length ? this.draft : this.history[target];
this.input.value = value;
this.input.setSelectionRange(value.length, value.length);
}
_submit(raw) {
const text = (raw || "").trim();
if (!text) return;
this.history.push(text);
if (this.history[this.history.length - 1] !== text) {
this.history.push(text);
if (this.history.length > HISTORY_LIMIT) {
this.history = this.history.slice(-HISTORY_LIMIT);
}
this._saveHistory();
}
this.historyIndex = this.history.length;
this.draft = "";
const lowered = text.toLowerCase();
if (CLEAR_WORDS.has(lowered)) {
if (RESET_WORDS.has(lowered)) {
this._resetConversation();
return;
}
if (CLEAR_WORDS.has(lowered)) {
this._clearConversation();
return;
}
if (CLOSE_WORDS.has(lowered)) {
this.close();
return;
@@ -406,6 +467,11 @@ export default class DeviiTerminalElement extends HTMLElement {
this.socket.send({ type: "reset" });
}
_clearConversation() {
this.output.innerHTML = "";
this.socket.send({ type: "clear" });
}
async _local(text) {
const [command, ...rest] = text.slice(1).split(" ");
const argument = rest.join(" ").trim();
@@ -468,6 +534,9 @@ export default class DeviiTerminalElement extends HTMLElement {
try {
const response = await fetch("/devii/session", { credentials: "same-origin" });
const data = await response.json();
if (data.baseUrl) {
this.markdown.baseUrl = data.baseUrl;
}
return { loggedIn: !!data.loggedIn, username: data.username || "" };
} catch (error) {
return null;
+53 -6
View File
@@ -7,10 +7,13 @@ const SAFE_HREF = /^(https?:\/\/|\/|#|mailto:)/i;
export default class Markdown {
constructor() {
this.highlighter = new Highlighter();
this.baseUrl = "";
}
escape(text) {
return text
return String(text)
.split(String.fromCharCode(0))
.join("")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
@@ -22,23 +25,67 @@ export default class Markdown {
const tick = String.fromCharCode(96);
const codeRe = new RegExp(tick + "([^" + tick + "]+)" + tick, "g");
let out = this.escape(text);
out = out.replace(codeRe, (_m, code) => `<code class="md-code">${code}</code>`);
const tokens = [];
const stash = (html) => {
const key = String.fromCharCode(0) + tokens.length + String.fromCharCode(0);
tokens.push(html);
return key;
};
out = out.replace(codeRe, (_m, code) => stash(`<code class="md-code">${code}</code>`));
const imgRe = new RegExp("!\\[([^\\]]*)\\]\\(([^)\\s]+)\\)", "g");
out = out.replace(imgRe, (_m, alt, src) => this._image(alt, src));
out = out.replace(imgRe, (_m, alt, src) => stash(this._image(alt, src)));
const linkRe = new RegExp("\\[([^\\]]+)\\]\\(([^)\\s]+)\\)", "g");
out = out.replace(linkRe, (_m, label, href) => this._link(label, href));
out = out.replace(linkRe, (_m, label, href) => stash(this._link(label, href)));
out = this._autolink(out, stash);
out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
out = out.replace(/__([^_]+)__/g, "<strong>$1</strong>");
out = out.replace(/(^|[^*])\*([^*\s][^*]*?)\*/g, "$1<em>$2</em>");
out = out.replace(/~~([^~]+)~~/g, "<del>$1</del>");
out = out.replace(new RegExp(String.fromCharCode(0) + "(\\d+)" + String.fromCharCode(0), "g"), (_m, i) => tokens[Number(i)]);
return out;
}
_resolveHref(href) {
if (/^(https?:\/\/|mailto:|#)/i.test(href)) {
return href;
}
if (href.startsWith("/") && !href.startsWith("//")) {
const base = (this.baseUrl || (typeof globalThis !== "undefined" && globalThis.location ? globalThis.location.origin : "")).replace(/\/+$/, "");
return base + href;
}
return null;
}
_autolink(text, stash) {
const NUL = String.fromCharCode(0);
const absRe = new RegExp("(^|[\\s(])(https?:\\/\\/[^\\s<)" + NUL + "]+)", "g");
text = text.replace(absRe, (_m, pre, url) => pre + this._autoAnchor(url, stash));
const relRe = new RegExp("(^|[\\s(])(\\/[A-Za-z0-9._~\\-][A-Za-z0-9._~\\-\\/%?=&;#:+@]*)", "g");
text = text.replace(relRe, (_m, pre, url) => pre + this._autoAnchor(url, stash));
return text;
}
_autoAnchor(rawUrl, stash) {
let url = rawUrl;
let trail = "";
const match = url.match(/[.,;:!?)\]}'"]+$/);
if (match) {
trail = match[0];
url = url.slice(0, url.length - trail.length);
}
const href = url ? this._resolveHref(url) : null;
if (!href) {
return rawUrl;
}
return stash(`<a href="${href}" target="_blank" rel="noopener noreferrer">${url}</a>`) + trail;
}
_link(label, href) {
if (!SAFE_HREF.test(href)) {
const resolved = this._resolveHref(href);
if (!resolved || !SAFE_HREF.test(resolved)) {
return this.escape(label);
}
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${label}</a>`;
return `<a href="${resolved}" target="_blank" rel="noopener noreferrer">${label}</a>`;
}
_image(alt, src) {