Files
devplacepy/devplacepy/static/js/ContainerInstance.js
T
retoorandClaude Sonnet 5 70ceb3cf81 Make workspace/project file sync propagate deletions instead of resurrecting them
The old sync compared only the two live sides (project rows vs workspace
files), so a file present in the project but missing on disk was
indistinguishable from "never materialized here yet" - it always got
re-exported, which is why deleting a file inside a container made it come
back. The mirror direction had the same bug: a file deleted from the
project's file editor was silently re-imported from the container's stale
copy on the next tick.

Fixes it with a persisted per-file sync baseline (new project_file_sync_state
table: db_epoch/fs_epoch as they stood right after the previous sync), the
same role a rsync/Unison state file plays in any real bidirectional sync.
Deleting on either side now propagates to the other, unless the deleted
side's counterpart was edited after the last sync, in which case the edit
wins and the file is restored. A read-only project always exports (never
imports, including on tie) and always removes a workspace's stale local
copy, so it stays a faithful mirror. Sync of an unchanged file is now a true
no-op (zero writes) instead of rewriting it every ~60s tick forever.

sync_dir_bidirectional's return dict gains deleted_in_project/
deleted_in_workspace alongside exported/imported; both API call sites
already pass the whole dict through untouched. The instance sync toast now
summarizes all four counts instead of just imports.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TjdKTWgWpW2SMNW8SFqxz5
2026-09-03 08:47:57 +02:00

264 lines
11 KiB
JavaScript

// 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.canManage = root.dataset.canManage === "1";
this.detailPoll = null;
this.logPoll = null;
}
init() {
this.renderActions(this.status);
this.updateTerminalAvailability();
this.bind();
this.startDetailPoll();
this.startLogPoll();
this.subscribe();
}
subscribe() {
const pubsub = window.app && window.app.pubsub;
if (!pubsub) return;
pubsub.subscribe(`container.${this.uid}.detail`, (data) => this.applyDetail(data));
pubsub.subscribe(`container.${this.uid}.logs`, (data) => this.renderLogs(data.logs || ""));
}
bind() {
const execRun = this.q("#ci-exec-run");
if (execRun) execRun.addEventListener("click", () => this.execOnce());
const termToggle = this.q("#ci-term-toggle");
if (termToggle) termToggle.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;
}
renderActions(status) {
if (!this.canManage) {
const box = this.q("#ci-actions");
box.innerHTML = `<span class="cm-muted">View only. This container is managed by its owner.</span>`;
return;
}
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(this.syncSummary(res.data), "success");
else this.toast(`${action} requested`, "success");
} catch (err) { this.toast(err.message, "error"); }
}
syncSummary(data) {
const counts = data || {};
const parts = [];
if (counts.exported) parts.push(`${counts.exported} exported`);
if (counts.imported) parts.push(`${counts.imported} imported`);
if (counts.deleted_in_project) parts.push(`${counts.deleted_in_project} deleted in project`);
if (counts.deleted_in_workspace) parts.push(`${counts.deleted_in_workspace} deleted in workspace`);
return parts.length ? `Synced: ${parts.join(", ")}` : "Synced: already up to date";
}
startDetailPoll() {
this.detailPoll = new Poller(async () => {
const detail = await Http.getJson(`${this.base}/instances/${this.uid}`);
this.applyDetail(detail);
}, 20000, { pauseHidden: true });
}
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() {
this.logPoll = new Poller(async () => {
try {
const data = await Http.getJson(`${this.base}/instances/${this.uid}/logs?tail=400`);
this.renderLogs(data.logs || "");
} catch (error) {
const pre = this.q("#ci-log");
pre.classList.add("ci-empty");
pre.textContent = "Logs are currently unavailable.";
}
}, 20000, { pauseHidden: true });
}
renderLogs(logs) {
const pre = this.q("#ci-log");
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;
}
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");
if (!toggle) return;
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, "");
}
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;
}
const removeBtn = (s) => this.canManage
? `<button type="button" class="btn btn-secondary btn-sm" data-schedule-delete="${this.escape(s.uid)}">Delete</button>`
: "";
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>
${removeBtn(s)}
</li>`).join("");
}
}