src/ui/local-model-vps.js
Rama main · réplica de lectura del repositorio original.
import { bindWizardNavigation, flashCopied, readWizardSession, whenVisible, wizardApiError } from "./session.js";
import { initWizardTopbar } from "./topbar.js";
import { bindSshAuthentication, clearFieldError, clearHostReport, markRejectedField, readSshIdentity, sameSshIdentity, setFieldError, showHostReport, validateSshIdentity } from "./ssh-form.js";
import { HOSTING_PROVIDERS, getHostingProvider } from "../domain/hosting-providers.js";
const token = readWizardSession();
const el = (id) => document.getElementById(id);
const MODELS = new Set(["qwen3:0.6b", "qwen3:1.7b", "qwen3.5:2b", "qwen3.5:4b", "qwen3.5:9b"]);
const STATES = new Set(["absent", "unavailable", "partial", "runtime-ready", "downloading", "model-ready", "model-error"]);
const ENDPOINT = "http://n8n-local-model:11434/v1";
const RUNTIME_IMAGE = "ollama/ollama:0.34.4@sha256:8262851b2846b87c649eddf3e76beb270c52f4d1bc94559f47efde16b0841551";
const state = { busy: false, fingerprint: null, connectedIdentity: null, generation: 0, plan: null, status: null, timer: null, manual: false };
const sshAuthentication = bindSshAuthentication({ token, trustId: "trust", onChange: invalidate, allowSudo: true });
function selectedHosting() {
try { return getHostingProvider(el("hosting-provider").value); }
catch { return null; }
}
function supportedHosting() { return selectedHosting()?.kind === "managed-vm"; }
function requireSupportedHosting() {
if (!supportedHosting()) throw new Error("This product cannot use managed VPS SSH. Review its hosting options instead.");
}
function updateHosting() {
const provider = selectedHosting();
el("hosting-hint").textContent = provider?.sshHint ?? "Unknown hosting product. Managed VPS SSH is unavailable.";
el("hosting-alternative").hidden = supportedHosting();
}
setOptions(el("hosting-provider"), HOSTING_PROVIDERS.map(({ id, label }) => [id, label]));
// Hostinger KVM VPS is the tested VPS route, so it starts selected.
if (HOSTING_PROVIDERS.some(({ id }) => id === "hostinger")) el("hosting-provider").value = "hostinger";
updateHosting();
el("hosting-provider").addEventListener("change", () => {
invalidate();
updateHosting();
// Guidance never replaces a human-selected host, login, auth, or administrative context.
syncTrust(); syncConfirm();
});
bindWizardNavigation(el("hosting-guide-link"), "/hosting", token);
function message(text) { el("message").textContent = text; }
// Shows a failure: an Error from a failed call or a plain sentence. Error help
// gets the whole failure, so it can read its status and code.
function error(failure) {
const text = typeof failure === "string" ? failure : failure?.message || "The operation failed.";
el("error").textContent = markRejectedField(text, el, "error");
el("error").hidden = false;
el("error").focus();
globalThis.relmioGuide?.error?.(typeof failure === "string" ? { message: failure } : failure);
}
function invalidate() {
state.generation++;
state.plan = null;
el("review-panel").hidden = true;
el("confirm").checked = false;
el("cache-confirm").checked = false;
el("apply").disabled = true;
clearTimeout(state.timer);
state.timer = null;
}
function connectedIdentityMatches() {
return state.connectedIdentity !== null &&
el("host").value === state.connectedIdentity.host &&
Number(el("port").value) === state.connectedIdentity.port &&
el("username").value === state.connectedIdentity.username &&
el("ssh-authentication").value === state.connectedIdentity.authentication &&
el("ssh-privilege").value === state.connectedIdentity.privilege &&
state.fingerprint === state.connectedIdentity.fingerprint &&
el("trust").checked;
}
function requireConnectedIdentity() {
requireSupportedHosting();
if (connectedIdentityMatches()) return state.connectedIdentity;
invalidate();
el("settings-panel").hidden = true;
el("selection-panel").hidden = true;
el("manual-recovery").hidden = true;
throw new Error("The trusted VPS identity changed. Disconnect and reconnect before reviewing model actions.");
}
function adoptConnectedIdentity(identity) {
validateSshIdentity(identity);
state.connectedIdentity = { ...identity };
el("host").value = identity.host;
el("port").value = String(identity.port);
el("username").value = identity.username;
el("ssh-authentication").value = identity.authentication;
el("ssh-privilege").value = identity.privilege;
el("ssh-session").textContent = `Verified ${identity.username}@${identity.host}:${identity.port}, ${identity.authentication}, ${identity.privilege}, UID ${identity.loginUid} → ${identity.effectiveUid}; ${identity.scope}.`;
el("ssh-disconnect").hidden = false;
state.fingerprint = identity.fingerprint;
el("fingerprint").textContent = identity.fingerprint;
el("fingerprint-box").hidden = false;
el("trust").checked = true;
el("manual-recovery").hidden = true;
syncTrust();
void showHostReport(token, hostReport());
}
async function verifyConnectedIdentity() {
const identity = requireConnectedIdentity();
let authenticated;
try { authenticated = await readSshIdentity(token, { allowModelOnly: true }); }
catch (failure) {
invalidate();
el("settings-panel").hidden = true;
el("selection-panel").hidden = true;
el("manual-recovery").hidden = true;
throw failure;
}
if (!sameSshIdentity(authenticated, identity)) {
invalidate();
el("settings-panel").hidden = true;
el("selection-panel").hidden = true;
el("manual-recovery").hidden = true;
throw new Error("The authenticated VPS changed. Disconnect and reconnect before reviewing model actions.");
}
return identity;
}
function syncTrust() {
const connected = state.connectedIdentity !== null;
const eligible = supportedHosting();
const trusted = Boolean(state.fingerprint && el("trust").checked);
for (const id of ["host", "port", "trust"]) el(id).disabled = connected || !eligible;
el("hosting-provider").disabled = connected;
el("scan").disabled = connected || state.busy || !eligible;
sshAuthentication.sync({ trusted: trusted && eligible, locked: connected || !eligible });
el("connect").disabled = connected || !trusted || state.busy || !eligible;
}
function syncConfirm() {
el("apply").disabled = !supportedHosting() || !connectedIdentityMatches() || !state.plan || !el("confirm").checked ||
(state.plan.action === "remove" && !el("cache-confirm").checked) || state.busy;
}
function boundary() {
return state.manual
? { containerName: el("manual-container").value, networkName: el("manual-network").value }
: { containerName: el("container").value, networkName: el("network").value };
}
function hostReport() {
const selected = boundary();
return { flow: "local-model", container: selected.containerName, network: selected.networkName };
}
function current(key, generation) {
const selected = boundary();
return generation === state.generation && key === `${selected.containerName}\0${selected.networkName}`;
}
async function api(path, body) {
if (!token) throw new Error("Open a fresh private Relmio wizard session.");
const response = await fetch(path, {
method: "POST", headers: { "Content-Type": "application/json", "X-Setup-Token": token },
body: JSON.stringify(body), credentials: "omit", mode: "same-origin", redirect: "error",
});
const data = await response.json().catch(() => null);
if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error("Unreadable wizard response. Reconnect and inspect again.");
if (!response.ok) throw wizardApiError(data, response.status, "The operation failed.");
return data;
}
for (const name of ["click", "pointerdown", "keydown", "beforeinput", "input", "change", "submit"]) {
document.addEventListener(name, (event) => {
if (!state.busy || event.target?.closest?.(".rm-guide-layer, [data-guide-help]") || (name === "keydown" && event.key === "Tab")) return;
event.preventDefault();
event.stopImmediatePropagation();
}, true);
}
async function perform(label, task) {
if (state.busy) return;
state.busy = true;
const origin = document.activeElement;
document.body.dataset.operationBusy = "true";
el("error").hidden = true;
el("progress").hidden = true;
message(label);
// "nearest" scrolls only when the status is out of view, so the startup check does not move the page.
el("message").scrollIntoView?.({ block: "nearest", behavior: "instant" });
el("route-steps-content").setAttribute("aria-busy", "true");
const controls = [...document.querySelectorAll("button, input, select, textarea, summary, a[href]")]
.filter((control) => !control.closest?.(".rm-guide-layer, [data-guide-help]")).map((control) => ({
control, disabled: typeof control.disabled === "boolean" ? control.disabled : null,
tabIndex: control.getAttribute("tabindex"),
}));
for (const { control, disabled } of controls) {
if (disabled !== null) control.disabled = true;
else control.setAttribute("tabindex", "-1");
}
try { await task(); } catch (failure) { error(failure); }
finally {
state.busy = false;
document.body.dataset.operationBusy = "false";
el("route-steps-content").setAttribute("aria-busy", "false");
for (const { control, disabled, tabIndex } of controls) {
if (disabled !== null) control.disabled = disabled;
else if (tabIndex === null) control.removeAttribute("tabindex");
else control.setAttribute("tabindex", tabIndex);
}
syncTrust(); syncConfirm();
// Keyboard focus returns to the control that started this, unless an error or a new view took it.
if ([document.body, origin].includes(document.activeElement) && origin?.isConnected && !origin.disabled && origin.getClientRects().length) {
origin.focus({ preventScroll: true });
}
}
}
function setOptions(select, values) {
select.replaceChildren(...values.map(([value, label]) => {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
return option;
}));
}
function validStatus(status) {
if (!STATES.has(status?.state)) throw new Error("The model status is invalid. Reconnect and inspect again.");
if (!["absent", "unavailable"].includes(status.state)) {
if (!MODELS.has(status.modelId) || (status.endpoint != null && status.endpoint !== ENDPOINT)) {
throw new Error("The selected model identity changed. Reconnect and inspect again.");
}
}
return status;
}
function formatBytes(bytes) {
return Number.isSafeInteger(bytes) && bytes >= 0 ? `${(bytes / 1_000_000_000).toFixed(2)} GB` : "not yet known";
}
function renderStatus(status) {
state.status = validStatus(status);
el("installation-state").textContent = {
absent: "No owned local model found. Review a new install.",
unavailable: "Docker or the owned model cannot be checked safely. Do not apply changes until the check succeeds.",
partial: "An incomplete install may allow a reviewed retry after ownership checks. Ambiguous or active operations cannot be retried.",
"runtime-ready": "The runtime is installed. Inference is not verified yet.",
downloading: "The selected model is downloading or being checked.",
"model-ready": "The selected model completed a bounded inference check.",
"model-error": "Download or inference check failed. The model cache is kept. Review a retry.",
}[status.state];
const owned = !["absent", "unavailable"].includes(status.state);
el("review-install").hidden = state.manual || status.state !== "absent";
el("review-retry").hidden = state.manual || !["runtime-ready", "model-error", "partial"].includes(status.state);
el("review-remove").hidden = status.state === "absent" || (!state.manual && !owned);
el("settings-panel").hidden = status.state !== "model-ready";
if (status.state === "model-ready") {
el("selected-model").textContent = status.modelId;
el("readiness").textContent = "The model answered a bounded check. Test your own n8n workflow separately. This is not proof that every workflow works.";
}
if (status.state === "downloading") schedulePoll();
else {
clearTimeout(state.timer);
state.timer = null;
el("progress").hidden = true;
message(el("installation-state").textContent);
}
}
async function refreshStatus() {
await verifyConnectedIdentity();
invalidate();
el("settings-panel").hidden = true;
const selected = boundary();
void showHostReport(token, hostReport());
if (!selected.networkName) return;
const generation = state.generation;
const key = `${selected.containerName}\0${selected.networkName}`;
const result = await api("/api/vps/local-model/status", selected);
if (!current(key, generation)) return;
renderStatus(result);
}
async function loadNetworks() {
await verifyConnectedIdentity();
invalidate();
state.manual = false;
const containerName = el("container").value;
const generation = state.generation;
const result = await api("/api/networks", { containerName });
if (generation !== state.generation || containerName !== el("container").value || !Array.isArray(result.networks)) return;
setOptions(el("network"), result.networks.map((name) => [name, name]));
if (result.networks.includes(result.recommended)) el("network").value = result.recommended;
await refreshStatus();
}
async function discover() {
await verifyConnectedIdentity();
invalidate();
el("selection-panel").hidden = true;
el("settings-panel").hidden = true;
el("manual-recovery").hidden = true;
const generation = state.generation;
const result = await api("/api/discover", {});
if (generation !== state.generation) return;
if (!Array.isArray(result.containers)) throw new Error("The n8n discovery response is invalid.");
if (result.containers.length === 0) {
const failure = new Error(typeof result.noneFoundMessage === "string" && result.noneFoundMessage.length <= 240
? result.noneFoundMessage : "No running n8n container found.");
failure.code = "NO_RUNNING_N8N";
throw failure;
}
setOptions(el("container"), result.containers.map((container) => [container.name, container.name]));
await loadNetworks();
el("selection-panel").hidden = false;
}
function validPlan(plan, action) {
if (!plan || plan.action !== action || typeof plan.planId !== "string" ||
plan.containerName !== boundary().containerName || plan.networkName !== boundary().networkName ||
!MODELS.has(plan.modelId) || plan.endpoint !== ENDPOINT ||
plan.catalogRevision !== "2026-09-26" || plan.runtimeImage !== RUNTIME_IMAGE ||
!/^sha256:[a-f0-9]{64}$/u.test(plan.approvedModelDigest) ||
!Number.isSafeInteger(plan.memoryBytes) || plan.memoryBytes <= 0 ||
!Number.isFinite(plan.cpus) || plan.cpus <= 0 ||
!Number.isSafeInteger(plan.contextTokens) || plan.contextTokens <= 0 ||
!Number.isSafeInteger(plan.expectedDownloadBytes) || plan.expectedDownloadBytes <= 0 ||
!Number.isSafeInteger(plan.requiredDiskBytes) || plan.requiredDiskBytes <= 0 ||
!Number.isSafeInteger(plan.reservedMemoryBytes) || plan.reservedMemoryBytes <= 0 ||
!Number.isSafeInteger(plan.hostResources?.memoryBytes) || plan.hostResources.memoryBytes <= 0 ||
!Number.isFinite(plan.hostResources.cpus) || plan.hostResources.cpus <= 0 ||
!Number.isSafeInteger(plan.hostResources.diskAvailableBytes) || plan.hostResources.diskAvailableBytes <= 0 ||
plan.installDirectory !== "/docker/n8n-openai-oauth/local-model" ||
plan.operationLockPath !== "/docker/n8n-openai-oauth/.local-model-operation.lock" ||
plan.temporaryBuildStatePath !== (action === "remove" ? null : "/docker/n8n-openai-oauth/.local-model-operation.lock/buildx") ||
(action === "install"
? !plan.sharedRootBootstrap ||
Object.keys(plan.sharedRootBootstrap).sort().join(",") !== "directory,directoryMode,markerPath,mayCreateDirectory,mayCreateMarker" ||
plan.sharedRootBootstrap.directory !== "/docker/n8n-openai-oauth" ||
plan.sharedRootBootstrap.markerPath !== "/docker/n8n-openai-oauth/.managed-by-relmio-root" ||
plan.sharedRootBootstrap.mayCreateDirectory !== true ||
plan.sharedRootBootstrap.mayCreateMarker !== true ||
plan.sharedRootBootstrap.directoryMode !== "0700"
: plan.sharedRootBootstrap !== null) ||
!Array.isArray(plan.publishedPorts) || plan.publishedPorts.length !== 0 ||
!Array.isArray(plan.existingN8nChanges) || plan.existingN8nChanges.length !== 0 ||
plan.existingN8nRestarts !== 0 ||
(action === "remove" && plan.clearModelCache !== true)) {
throw new Error("The reviewed model action is invalid. Inspect again.");
}
return plan;
}
async function review(action) {
const identity = await verifyConnectedIdentity();
invalidate();
if (state.manual && action !== "remove") throw new Error("Recovery without running n8n can only remove attested owned files and cache.");
const selected = boundary();
const generation = state.generation;
const key = `${selected.containerName}\0${selected.networkName}`;
const body = { ...selected, action, ...(action === "install" ? { modelId: el("model").value } : {}), ...(action === "remove" ? { clearModelCache: true } : {}) };
const plan = validPlan(await api("/api/vps/local-model/plan", body), action);
await verifyConnectedIdentity();
if (!current(key, generation)) return;
state.plan = plan;
el("review-action").textContent = action === "remove" ? "Permanently remove the owned runtime and model cache" : action === "retry" ? "Retry the same selected model" : "Install runtime and download selected model";
el("review-action").textContent += action === "install"
? `; may create ${plan.sharedRootBootstrap.directory} and ${plan.sharedRootBootstrap.markerPath}, and sets that folder to mode ${plan.sharedRootBootstrap.directoryMode} (root only).`
: "; no shared-root creation.";
// Lock, build, measurement, digest and runtime facts sit behind "Technical
// details" so the review fits one screen (DESIGN.md); every fact stays on the
// reviewed page.
el("review-details").textContent = action === "install" ? `Uses lock ${plan.operationLockPath}.` : `Serializes with ${plan.operationLockPath}.`;
if (plan.temporaryBuildStatePath) {
el("review-details").textContent += ` After you confirm, builds use a temporary root-only folder at ${plan.temporaryBuildStatePath}. If cleanup is uncertain, that folder and its lock may remain for you to inspect. Registry credentials are not copied or printed. Cleanup does not change n8n or the model cache.`;
}
el("review-details").textContent += ` Measured server: ${formatBytes(plan.hostResources.memoryBytes)} memory, ${plan.hostResources.cpus} CPUs, ${formatBytes(plan.hostResources.diskAvailableBytes)} available disk; ${formatBytes(plan.reservedMemoryBytes)} reserved for OS, n8n and helper. Approved manifest ${plan.approvedModelDigest}. Runtime: ${plan.runtimeImage}.`;
el("review-boundary").textContent = `${identity.username}@${identity.host}:${identity.port} (${identity.authentication}; ${identity.privilege}; UID ${identity.loginUid} → ${identity.effectiveUid}; SSH ${identity.fingerprint}) · ${plan.containerName} / ${plan.networkName}`;
el("review-model").textContent = plan.modelId;
el("review-budget").textContent = `${formatBytes(plan.memoryBytes)} model memory; ${plan.cpus} CPUs; ${plan.contextTokens} context tokens.`;
el("review-download").textContent = action === "remove" ? "Owned model cache and weights will be deleted" : `${formatBytes(plan.expectedDownloadBytes)} expected model layers; ${formatBytes(plan.requiredDiskBytes)} estimated free disk requirement`;
el("deletion-warning").hidden = action !== "remove";
el("cache-confirm-row").hidden = action !== "remove";
el("review-panel").hidden = false;
el("review-panel").scrollIntoView?.({ block: "start" });
message("Review the plan. Nothing has changed yet.");
syncConfirm();
}
async function applyReview() {
try { requireConnectedIdentity(); } catch (failure) { error(failure); return; }
const plan = state.plan;
if (!plan || !el("confirm").checked || (plan.action === "remove" && !el("cache-confirm").checked)) return;
const selected = boundary();
if (selected.containerName !== plan.containerName || selected.networkName !== plan.networkName) { invalidate(); return; }
await perform("Applying only the confirmed model action…", async () => {
await verifyConnectedIdentity();
const result = await api("/api/vps/local-model/apply", { ...selected, planId: plan.planId, action: plan.action, confirmed: true, ...(plan.action === "remove" ? { clearModelCache: el("cache-confirm").checked } : {}) });
invalidate();
renderStatus(result);
message("Action applied. Refresh or reopen the wizard at any time to inspect progress.");
});
}
// The bar is determinate once both byte counts are known.
function showDownloadProgress(completed, total) {
const known = Number.isSafeInteger(completed) && Number.isSafeInteger(total) && completed >= 0 && total > 0;
const ratio = known ? Math.min(1, completed / total) : 0;
const bar = el("progress-bar");
bar.classList.toggle("rm-progress--indeterminate", !known);
if (known) bar.setAttribute("aria-valuenow", String(Math.round(ratio * 100)));
else bar.removeAttribute("aria-valuenow");
bar.firstElementChild.style.setProperty("--rm-progress", String(ratio));
}
function schedulePoll() {
clearTimeout(state.timer);
const generation = state.generation;
const selected = boundary();
const key = `${selected.containerName}\0${selected.networkName}`;
// A hidden tab waits and checks once when it is shown again.
state.timer = setTimeout(() => whenVisible(async () => {
if (state.busy || !current(key, generation)) return;
if (!connectedIdentityMatches()) {
invalidate();
el("settings-panel").hidden = true;
error("The trusted VPS identity changed. Disconnect and reconnect before inspecting the model.");
return;
}
try {
const result = await api("/api/vps/local-model/operation-status", { ...selected, installId: state.status?.installId });
if (!current(key, generation)) return;
if (result?.installId !== state.status?.installId || !MODELS.has(result.modelId)) throw new Error("The operation identity changed.");
if (result.state !== "downloading") {
await refreshStatus();
return;
}
const progress = result.operation;
if (!progress || !["downloading", "verifying", "model-ready", "model-error", "partial"].includes(progress.state)) throw new Error("Operation progress is invalid.");
const wasHidden = el("progress").hidden;
el("progress").hidden = false;
el("progress-text").textContent = `${progress.phase}: ${formatBytes(progress.completedBytes)} of ${formatBytes(progress.totalBytes)} transferred. No ETA is available.`;
showDownloadProgress(progress.completedBytes, progress.totalBytes);
if (wasHidden) el("progress").scrollIntoView?.({ block: "nearest", behavior: "instant" });
schedulePoll();
} catch (failure) { error(failure?.message ? failure : "Progress inspection failed. Refresh status."); }
}), 2500);
}
el("scan").addEventListener("click", () => {
for (const id of ["host", "port"]) {
if (!el(id).checkValidity()) {
error(id === "host" ? "Hostname is invalid." : "Port is invalid.");
return;
}
}
void perform("Checking VPS host identity…", async () => {
if (state.connectedIdentity) throw new Error("Disconnect before scanning another VPS host.");
requireSupportedHosting();
invalidate(); state.fingerprint = null; el("trust").checked = false; syncTrust();
const result = await api("/api/ssh/fingerprint", { host: el("host").value, port: Number(el("port").value) });
if (typeof result.fingerprint !== "string" || !result.fingerprint) throw new Error("Host fingerprint is unavailable.");
for (const id of ["host", "port"]) clearFieldError(el(id), "error");
state.fingerprint = result.fingerprint;
el("fingerprint").textContent = result.fingerprint;
el("fingerprint-box").hidden = false;
message("Check the server identity before you connect.");
}).then(() => {
// As on the main setup page, a finished check hands focus to the confirmation.
if (state.fingerprint && document.activeElement === el("scan")) el("trust").focus();
});
});
el("trust").addEventListener("change", () => { invalidate(); syncTrust(); });
for (const id of ["host", "port"]) el(id).addEventListener("input", () => {
clearFieldError(el(id), "error");
el("error").hidden = true;
invalidate();
el("settings-panel").hidden = true;
if (!state.connectedIdentity) {
state.fingerprint = null; el("fingerprint-box").hidden = true; el("trust").checked = false;
}
syncTrust();
});
for (const id of ["username", "password"]) el(id).addEventListener("input", () => {
clearFieldError(el(id), "error");
el("error").hidden = true;
});
el("ssh-form").addEventListener("invalid", (event) => {
const label = { host: "Hostname", port: "Port", username: "Username", password: "Password" }[event.target.id];
if (!label) return;
event.preventDefault();
error(`${label} is invalid.`);
}, true);
el("ssh-form").addEventListener("submit", (event) => { event.preventDefault(); if (state.connectedIdentity || !state.fingerprint || !el("trust").checked) return; void perform("Connecting to trusted VPS…", async () => {
requireSupportedHosting();
await api("/api/ssh/connect", sshAuthentication.request(state.fingerprint));
for (const id of ["username", "password"]) clearFieldError(el(id), "error");
adoptConnectedIdentity(await readSshIdentity(token, { allowModelOnly: true }));
try {
await discover();
message("Connected. Choose n8n and review the measured resources.");
} catch (failure) {
if (failure?.code !== "NO_RUNNING_N8N") throw failure;
el("manual-recovery").hidden = false;
message(`${failure.message} Inspect an existing owned model for reviewed removal using its original exact names.`);
}
}); });
el("container").addEventListener("change", () => { void perform("Inspecting selected n8n…", loadNetworks); });
el("network").addEventListener("change", () => { void perform("Inspecting selected network…", refreshStatus); });
el("model").addEventListener("change", invalidate);
el("inspect-owned").addEventListener("click", () => { void perform("Inspecting only the specified owned model…", async () => {
const name = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/u;
const invalid = ["manual-container", "manual-network"].filter((id) => !name.test(el(id).value));
if (invalid.length) {
for (const id of invalid) setFieldError(el(id), "error");
throw new Error("Enter the exact original Docker container and network names.");
}
for (const id of ["manual-container", "manual-network"]) clearFieldError(el(id), "error");
state.manual = true;
el("selection-panel").hidden = false;
await refreshStatus();
}); });
for (const id of ["manual-container", "manual-network"]) el(id).addEventListener("input", () => {
clearFieldError(el(id), "error");
el("error").hidden = true;
invalidate(); el("settings-panel").hidden = true;
el("review-install").hidden = true; el("review-retry").hidden = true; el("review-remove").hidden = true;
});
el("refresh").addEventListener("click", () => { void perform("Refreshing owned model status…", refreshStatus); });
for (const [id, action] of [["review-install", "install"], ["review-retry", "retry"], ["review-remove", "remove"]]) {
el(id).addEventListener("click", () => { void perform("Reviewing selected action…", () => review(action)); });
}
el("confirm").addEventListener("change", syncConfirm);
el("cache-confirm").addEventListener("change", syncConfirm);
el("cancel-review").addEventListener("click", invalidate);
el("apply").addEventListener("click", () => { void applyReview(); });
el("disconnect").addEventListener("click", () => { void perform("Disconnecting…", async () => {
invalidate(); await api("/api/disconnect", {}); el("selection-panel").hidden = true; el("settings-panel").hidden = true; el("manual-recovery").hidden = true;
state.connectedIdentity = null; state.fingerprint = null; el("trust").checked = false; el("fingerprint-box").hidden = true;
el("ssh-session").textContent = "No authenticated VPS session."; el("ssh-disconnect").hidden = true;
clearHostReport();
state.status = null; message("Disconnected. The owned model remains on your server.");
}); });
async function copyText(text, button) {
try {
await navigator.clipboard.writeText(text);
} catch {
const input = document.createElement("textarea");
input.value = text;
input.style.position = "fixed";
input.style.opacity = "0";
document.body.append(input);
try {
input.focus();
input.select();
if (!document.execCommand("copy")) throw new Error("Clipboard unavailable.");
} finally {
input.remove();
button.focus();
}
}
}
for (const [id, targetId] of [["copy-url", "base-url"], ["copy-model", "selected-model"], ["copy-key", "placeholder-key"]]) {
el(id).addEventListener("click", async (event) => {
if (state.busy) return;
const button = event.currentTarget;
const text = el(targetId).textContent;
if (!text) return;
try {
await copyText(text, button);
flashCopied(button);
message("Copied to clipboard.");
} catch {
error("Clipboard unavailable; select and copy the text manually.");
}
});
}
window.addEventListener("pagehide", () => { clearTimeout(state.timer); state.timer = null; });
// One step panel shows at a time (DESIGN.md one-screen rule): the furthest open
// step, or an earlier one the person went back to. Recovery without n8n shows
// the names it checks together with the owned-model status and actions.
const VIEWS = ["review-panel", "settings-panel", "selection-panel", "manual-recovery", "connection-panel"];
const VIEW_STAGES = { "connection-panel": 1, "manual-recovery": 2, "selection-panel": 2, "review-panel": 3, "settings-panel": 4 };
const view = { furthest: "connection-panel", shown: "connection-panel", returnedTo: null };
function focusVisible(target) {
target.focus({ preventScroll: true });
const rect = target.getBoundingClientRect();
const footer = target.closest(".rm-panel")?.querySelector(".rm-panel__footer");
const bottom = footer && getComputedStyle(footer).position === "sticky"
? Math.min(innerHeight, footer.getBoundingClientRect().top) : innerHeight;
if (rect.top < document.querySelector(".rm-topbar").getBoundingClientRect().bottom || rect.bottom > bottom) {
target.scrollIntoView({ block: "start", behavior: "instant" });
}
}
function syncView() {
const furthest = VIEWS.find((id) => !el(id).hidden);
if (furthest !== view.furthest) { view.furthest = furthest; view.returnedTo = null; }
const next = view.returnedTo && !el(view.returnedTo).hidden ? view.returnedTo : furthest;
const shown = next === "selection-panel" && !el("manual-recovery").hidden ? ["manual-recovery", next] : [next];
// Write only real changes: this runs from a MutationObserver on `hidden`.
if (el("selection-continue").hidden !== el("settings-panel").hidden) el("selection-continue").hidden = el("settings-panel").hidden;
if (shown.join() === view.shown) return;
const active = document.activeElement;
const leaving = VIEWS.filter((id) => el(id).hasAttribute("data-current")).some((id) => el(id).contains(active));
for (const id of VIEWS) el(id).toggleAttribute("data-current", shown.includes(id));
view.shown = shown.join();
for (const marker of document.querySelectorAll("[data-stage-marker]")) {
const markerStage = Number(marker.dataset.stageMarker);
if (markerStage < VIEW_STAGES[next]) marker.dataset.state = "done";
else delete marker.dataset.state;
if (markerStage === VIEW_STAGES[next]) marker.setAttribute("aria-current", "step");
else marker.removeAttribute("aria-current");
}
const heading = el(shown[0]).querySelector("h2");
if (leaving) focusVisible(heading);
else if (!active || active === document.body) focusVisible(heading);
}
function returnTo(id) {
syncView();
view.returnedTo = id;
syncView();
}
el("cancel-review").addEventListener("click", () => returnTo("selection-panel"));
el("settings-back").addEventListener("click", () => { if (!state.busy) returnTo("selection-panel"); });
el("selection-continue").addEventListener("click", () => { if (!state.busy) returnTo(null); });
new MutationObserver(syncView).observe(el("main-content"), { subtree: true, attributeFilter: ["hidden"] });
initWizardTopbar({
session: token,
isBusy: () => state.busy,
loadProjectMeta: token ? async () => {
const response = await fetch("/api/local/project-meta", { headers: { "X-Setup-Token": token }, credentials: "omit", mode: "same-origin", redirect: "error", cache: "no-store" });
if (!response.ok) throw new Error("Relmio project details are unavailable.");
return response.json();
} : undefined,
});
if (!token) {
message("Open this page from a fresh Relmio wizard session.");
} else {
void perform("Checking the VPS connection…", async () => {
try {
adoptConnectedIdentity(await readSshIdentity(token, { allowModelOnly: true }));
await discover();
message("Connected. Choose n8n and review the measured resources.");
} catch (failure) {
if (failure?.message === "Connect to the VPS first.") {
message("Check the server identity to begin.");
} else if (failure?.code === "NO_RUNNING_N8N") {
el("manual-recovery").hidden = false;
message(`${failure.message} Inspect an existing owned model for reviewed removal using its original exact names.`);
} else {
el("selection-panel").hidden = true;
el("settings-panel").hidden = true;
el("manual-recovery").hidden = true;
throw failure;
}
}
});
}