// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
export class ContainerInstance {
constructor(root) {
this.root = root;
this.slug = root.dataset.slug;
this.uid = root.dataset.uid;
this.status = root.dataset.status;
this.base = `/projects/${this.slug}/containers`;
this.name = root.dataset.name || this.uid;
this.detailPoll = null;
this.logPoll = null;
}
init() {
this.renderActions(this.status);
this.updateTerminalAvailability();
this.bind();
this.startDetailPoll();
this.startLogPoll();
}
bind() {
this.q("#ci-exec-run").addEventListener("click", () => this.execOnce());
this.q("#ci-term-toggle").addEventListener("click", () => this.openTerminal());
const form = document.getElementById("ci-schedule-form");
if (form) {
form.addEventListener("submit", (e) => { e.preventDefault(); this.addSchedule(form); });
const kind = form.elements.kind;
kind.addEventListener("change", () => this.syncScheduleFields(kind.value));
this.syncScheduleFields(kind.value);
}
this.q("#ci-schedules").addEventListener("click", (e) => {
const btn = e.target.closest("[data-schedule-delete]");
if (btn) this.deleteSchedule(btn.dataset.scheduleDelete);
});
}
q(sel) { return this.root.querySelector(sel); }
toast(message, type) {
if (window.app && window.app.toast) window.app.toast.show(message, { type: type || "info" });
}
escape(value) {
const span = document.createElement("span");
span.textContent = value == null ? "" : String(value);
return span.innerHTML;
}
// ---------------- actions ----------------
renderActions(status) {
const running = status === "running";
const paused = status === "paused";
const spec = [
{ action: "start", label: "Start", disabled: running },
{ action: "stop", label: "Stop", disabled: status === "stopped" || status === "created" },
{ action: "restart", label: "Restart", disabled: !running },
{ action: "pause", label: "Pause", disabled: !running },
{ action: "resume", label: "Resume", disabled: !paused },
{ action: "sync", label: "Sync files", disabled: false },
{ action: "delete", label: "Delete", disabled: false },
];
const box = this.q("#ci-actions");
box.innerHTML = "";
for (const item of spec) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-secondary" + (item.action === "delete" ? " ci-danger" : "");
btn.textContent = item.label;
btn.disabled = item.disabled;
btn.addEventListener("click", () => this.instanceAction(item.action));
box.appendChild(btn);
}
}
async instanceAction(action) {
if (action === "delete" && !window.confirm("Delete this instance? The container is removed.")) return;
try {
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 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");
} catch (err) { this.toast(err.message, "error"); }
}
// ---------------- polling ----------------
startDetailPoll() {
this.detailPoll = new Poller(async () => {
const detail = await Http.getJson(`${this.base}/instances/${this.uid}`);
this.applyDetail(detail);
}, 4000);
}
applyDetail(detail) {
const inst = detail.instance || {};
if (inst.status && inst.status !== this.status) {
this.status = inst.status;
const badge = this.q("#ci-status");
badge.textContent = inst.status;
badge.className = `cm-badge cm-${inst.status}`;
this.renderActions(inst.status);
this.updateTerminalAvailability();
}
const stats = detail.stats || {};
this.setMetric("samples", stats.samples);
this.setMetric("cpu_avg", `${stats.cpu_avg ?? 0}%`);
this.setMetric("cpu_p95", `${stats.cpu_p95 ?? 0}%`);
this.setMetric("mem_max", stats.mem_max);
const runtime = detail.runtime || {};
const cid = this.q("#ci-container-id");
if (cid) cid.textContent = runtime.container_id || "-";
if (Array.isArray(detail.schedules)) this.renderSchedules(detail.schedules);
}
setMetric(key, value) {
const el = this.q(`[data-metric="${key}"]`);
if (el) el.textContent = value == null ? "0" : value;
}
startLogPoll() {
const pre = this.q("#ci-log");
this.logPoll = new Poller(async () => {
try {
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 (error) {
pre.classList.add("ci-empty");
pre.textContent = "Logs are currently unavailable.";
}
}, 3000);
}
// ---------------- exec ----------------
async execOnce() {
const input = this.q("#ci-exec-input");
const cmd = input.value.trim();
if (!cmd) return;
this.q("#ci-terminal").hidden = false;
const pre = this.q("#ci-term");
try {
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 = "";
} catch (err) { this.toast(err.message, "error"); }
}
updateTerminalAvailability() {
const toggle = this.q("#ci-term-toggle");
const running = this.status === "running";
toggle.disabled = !running;
toggle.title = running ? "" : "Start the instance to open an interactive shell";
}
openTerminal() {
if (this.status !== "running") return;
if (window.app && window.app.containerTerminals) {
window.app.containerTerminals.open(this.slug, this.uid, this.name);
}
}
cleanTerm(text) {
return text
.replace(/\x1b\][0-9];[^\x07\x1b]*(\x07|\x1b\\)/g, "")
.replace(/\x1b\[[0-9;?]*[ -\/]*[@-~]/g, "")
.replace(/\x1b[()#][0-9A-Za-z]/g, "")
.replace(/\x1b[=>]/g, "");
}
// ---------------- schedules ----------------
syncScheduleFields(kind) {
const form = document.getElementById("ci-schedule-form");
form.querySelectorAll("[data-sched-field]").forEach((field) => {
field.hidden = field.dataset.schedField !== kind;
});
}
async addSchedule(form) {
const kind = form.elements.kind.value;
const body = { action: form.elements.action.value, kind };
if (kind === "cron") body.cron = form.elements.cron.value.trim();
if (kind === "interval") body.every_seconds = form.elements.every_seconds.value;
if (kind === "once") body.delay_seconds = form.elements.delay_seconds.value;
try {
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();
this.syncScheduleFields(form.elements.kind.value);
this.toast("Schedule added", "success");
} catch (err) { this.toast(err.message, "error"); }
}
async deleteSchedule(sid) {
const ok = await window.app.dialog.confirm({
title: "Delete schedule",
message: "Delete this schedule? The automated task is removed.",
confirmLabel: "Delete",
danger: true,
});
if (!ok) return;
try {
await Http.send(`${this.base}/instances/${this.uid}/schedules/${sid}/delete`, {});
this.toast("Schedule removed", "success");
} catch (err) { this.toast(err.message, "error"); }
}
renderSchedules(schedules) {
const list = this.q("#ci-schedules");
if (!schedules.length) {
list.innerHTML = `<li class="cm-muted ci-schedule-empty">No schedules.</li>`;
return;
}
list.innerHTML = schedules.map((s) => `<li class="ci-schedule" data-sid="${this.escape(s.uid)}">
<span class="ci-schedule-action">${this.escape(s.action)}</span>
<span class="ci-schedule-next">next ${this.escape(s.next_run_at || "-")}</span>
<span class="cm-muted">runs ${this.escape(s.run_count || 0)}</span>
<button type="button" class="btn btn-secondary btn-sm" data-schedule-delete="${this.escape(s.uid)}">Delete</button>
</li>`).join("");
}
}