|
// 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("");
|
|
}
|
|
}
|