src/ui/supergrok-vps.js
Rama main · réplica de lectura del repositorio original.
import { bindSshAuthentication, clearFieldError, createCredentialSshGuard, markRejectedField } from "./ssh-form.js";
import { flashCopied, readWizardSession, whenVisible, wizardApiError } from "./session.js";
import { initWizardTopbar } from "./topbar.js";
const token = readWizardSession();
const element = (id) => document.getElementById(id);
const state = {
busy: false,
fingerprint: null,
plan: null,
requestGeneration: 0,
loginGeneration: 0,
pollTimer: null,
progressTimer: null,
progressStartedAt: 0,
status: null,
};
let sshIdentityDecision = 0;
const hostReport = () => ({ flow: "supergrok", container: element("container").value, network: element("network").value });
const sshSession = createCredentialSshGuard({ token, hostReport, onIdentityDecision() { sshIdentityDecision++; }, onMismatch() {
invalidateBoundaryState();
element("container").replaceChildren();
element("network").replaceChildren();
element("ssh-panel").hidden = false;
element("selection-panel").hidden = true;
resetHost();
setStage(1);
} });
const actionLabels = {
install: "Install a fresh private SuperGrok companion",
"sign-in": "Start a fresh official Grok device sign-in",
"sign-out": "Sign out only this companion's Grok session",
remove: "Remove this companion and its Grok session volume",
"cancel-sign-in": "Cancel this companion's pending device sign-in",
};
function setMessage(text) {
element("status-message").textContent = text;
}
function showError(error) {
const text = markRejectedField(error?.message ?? "The operation could not be completed.", element, "error-message");
element("error-message").textContent = text;
element("error-message").hidden = false;
element("error-message").focus();
globalThis.relmioGuide?.error?.(error);
}
function clearError() {
element("error-message").hidden = true;
element("error-message").textContent = "";
globalThis.relmioGuide?.clearError?.();
}
function setStage(stage) {
document.body.dataset.stage = String(stage);
for (const marker of document.querySelectorAll("[data-stage-marker]")) {
const markerStage = Number(marker.dataset.stageMarker);
if (markerStage < stage) marker.dataset.state = "done";
else delete marker.dataset.state;
if (markerStage === stage) marker.setAttribute("aria-current", "step");
else marker.removeAttribute("aria-current");
}
syncView();
}
// One step panel is visible at a time: the panel for the current stage, or the
// nearest available panel while a reviewed action is being applied.
const STAGE_VIEWS = {
1: ["ssh-panel", "selection-panel"],
2: ["selection-panel", "ssh-panel"],
3: ["review-panel", "settings-panel", "selection-panel", "ssh-panel"],
4: ["settings-panel", "selection-panel", "ssh-panel"],
};
let currentView = "ssh-panel";
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 order = STAGE_VIEWS[document.body.dataset.stage] ?? STAGE_VIEWS[1];
const next = order.find((id) => !element(id).hidden) ?? "ssh-panel";
const continueButton = element("selection-continue");
// Write only real changes: this runs from a MutationObserver on `hidden`.
if (continueButton.hidden !== element("settings-panel").hidden) continueButton.hidden = element("settings-panel").hidden;
if (next === currentView) return;
const previous = element(currentView);
const active = document.activeElement;
for (const id of STAGE_VIEWS[3]) element(id).toggleAttribute("data-current", id === next);
currentView = next;
const heading = element(next).querySelector("h2");
if (active && previous.contains(active)) focusVisible(heading);
else if (!active || active === document.body) focusVisible(heading);
}
async function readProjectMeta() {
if (!token) throw new Error("This private wizard session is missing.");
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();
}
function formatElapsed(seconds) {
return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`;
}
function updateProgress() {
const elapsed = Math.max(0, Math.floor((Date.now() - state.progressStartedAt) / 1_000));
element("operation-progress-elapsed").textContent = formatElapsed(elapsed);
element("operation-progress-elapsed").setAttribute("datetime", `PT${elapsed}S`);
}
function startProgress(label) {
state.progressStartedAt = Date.now();
element("operation-progress-label").textContent = label;
element("operation-progress-bar").setAttribute("aria-valuetext", label);
element("operation-progress").hidden = false;
// "nearest" scrolls only when the card is out of view, so the startup check does not move the page.
element("operation-progress").scrollIntoView?.({ block: "nearest", behavior: "instant" });
updateProgress();
state.progressTimer = window.setInterval(updateProgress, 1_000);
}
function stopProgress() {
window.clearInterval(state.progressTimer);
state.progressTimer = null;
element("operation-progress").hidden = true;
element("operation-progress-label").textContent = "No operation is running.";
element("operation-progress-elapsed").textContent = "00:00";
element("operation-progress-elapsed").setAttribute("datetime", "PT0S");
element("operation-progress-bar").setAttribute("aria-valuetext", "No operation is running.");
}
function controls() {
return [...document.querySelectorAll("button, input, select, textarea, summary, a[href]")]
.filter((control) => !control.closest?.(".rm-guide-layer, [data-guide-help]"));
}
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, work) {
if (state.busy) return undefined;
state.busy = true;
const origin = document.activeElement;
clearError();
setMessage(label);
document.body.dataset.operationBusy = "true";
element("route-steps-content").setAttribute("aria-busy", "true");
const snapshots = controls().map((control) => ({
control,
disabled: typeof control.disabled === "boolean" ? control.disabled : null,
tabIndex: control.getAttribute("tabindex"),
}));
for (const { control, disabled } of snapshots) {
if (disabled !== null) control.disabled = true;
else control.setAttribute("tabindex", "-1");
}
startProgress(label);
try {
return await work();
} catch (error) {
showError(error);
return undefined;
} finally {
stopProgress();
for (const { control, disabled, tabIndex } of snapshots) {
if (disabled !== null) control.disabled = disabled;
else if (tabIndex === null) control.removeAttribute("tabindex");
else control.setAttribute("tabindex", tabIndex);
}
state.busy = false;
document.body.dataset.operationBusy = "false";
element("route-steps-content").setAttribute("aria-busy", "false");
syncConnectionControls();
// 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 });
}
}
}
async function api(path, body = {}) {
if (!token) {
throw new Error("This private wizard session is missing. Return to Relmio and open a fresh setup link.");
}
await sshSession.before(path);
let response;
try {
response = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Setup-Token": token },
body: JSON.stringify(body),
});
} catch {
throw new Error("The local wizard server is not reachable. Keep its terminal open, then reopen Relmio.");
}
const result = await response.json().catch(() => null);
if (!result || typeof result !== "object" || Array.isArray(result)) {
throw new Error("The wizard returned an unreadable response. Start a fresh setup link.");
}
if (!response.ok) throw wizardApiError(result, response.status, "The operation could not be completed.");
await sshSession.after(path, result);
return result;
}
function selectedBoundary() {
return { containerName: element("container").value, networkName: element("network").value };
}
function boundaryKey(boundary = selectedBoundary()) {
return `${boundary.containerName}\u0000${boundary.networkName}`;
}
function stopLoginPolling({ clearVisible = false } = {}) {
state.loginGeneration += 1;
window.clearTimeout(state.pollTimer);
state.pollTimer = null;
if (clearVisible) {
element("login-panel").hidden = true;
element("device-code").textContent = "";
element("device-link").hidden = true;
element("device-link").removeAttribute("href");
}
}
function scheduleLoginPoll(installId, generation, delay = 2_000) {
window.clearTimeout(state.pollTimer);
state.pollTimer = null;
if (
generation !== state.loginGeneration ||
state.status?.installId !== installId
) return;
state.pollTimer = window.setTimeout(() => {
state.pollTimer = null;
// A hidden tab waits and checks once when it is shown again.
whenVisible(() => void pollLogin(installId, generation));
}, delay);
}
function invalidateReview({ hide = true } = {}) {
state.plan = null;
element("confirm-action").checked = false;
element("apply-button").disabled = true;
if (hide) element("review-panel").hidden = true;
}
function invalidateBoundaryState() {
state.requestGeneration += 1;
invalidateReview();
stopLoginPolling({ clearVisible: true });
state.status = null;
element("client-key").value = "";
element("settings-panel").hidden = true;
element("models-result").textContent = "";
}
function syncConnectionControls() {
const trusted = Boolean(state.fingerprint && element("trust-host").checked);
sshAuthentication.sync({ trusted });
element("connect-button").disabled = !trusted;
element("apply-button").disabled = !state.plan || !element("confirm-action").checked;
}
function selectOptions(node, values) {
node.replaceChildren(...values.map(([value, label]) => {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
return option;
}));
}
function renderStatus(next) {
if (state.status?.installId !== next.installId) {
element("client-key").value = "";
element("models-result").textContent = "";
}
state.status = next;
const copy = next.state === "absent"
? "No Relmio-managed SuperGrok companion is installed for this VPS."
: next.state === "healthy"
? "The private companion is healthy. Complete official Grok sign-in, then check your account models."
: "An incomplete companion was detected. Review its removal before installing again.";
element("installation-state").textContent = copy;
element("settings-panel").hidden = next.state !== "healthy";
for (const button of document.querySelectorAll("[data-action]")) {
const action = button.dataset.action;
button.hidden = next.credentialActionRunning
? action !== "cancel-sign-in"
: action === "cancel-sign-in" || (action === "install" ? next.state !== "absent" : action === "remove" ? next.state === "absent" : next.state !== "healthy");
}
setStage(next.state === "healthy" ? 4 : 2);
}
async function refreshStatus() {
invalidateReview();
sshSession.refreshHostReport();
const boundary = selectedBoundary();
if (!boundary.networkName) return;
const requestGeneration = ++state.requestGeneration;
const next = await api("/api/vps/supergrok/status", boundary);
if (requestGeneration !== state.requestGeneration || boundaryKey(boundary) !== boundaryKey()) return;
renderStatus(next);
setMessage("VPS status checked. Existing n8n is unchanged.");
}
async function loadNetworks() {
invalidateBoundaryState();
const containerName = element("container").value;
const requestGeneration = ++state.requestGeneration;
const result = await api("/api/networks", { containerName });
if (requestGeneration !== state.requestGeneration || containerName !== element("container").value) return;
selectOptions(element("network"), result.networks.map((name) => [name, name]));
if (result.networks.includes(result.recommended)) element("network").value = result.recommended;
await refreshStatus();
}
async function discover() {
invalidateBoundaryState();
const requestGeneration = ++state.requestGeneration;
const result = await api("/api/discover");
if (requestGeneration !== state.requestGeneration) return;
if (!Array.isArray(result.containers) || result.containers.length === 0) {
// The server says what it looked for when it has that sentence.
throw new Error(typeof result.noneFoundMessage === "string" && result.noneFoundMessage.length <= 240
? result.noneFoundMessage : "No running n8n container was found on this VPS.");
}
selectOptions(element("container"), result.containers.map((item) => [item.name, `${item.name} · ${item.image}`]));
element("ssh-panel").hidden = true;
element("selection-panel").hidden = false;
setStage(2);
await loadNetworks();
}
function resetHost() {
state.fingerprint = null;
invalidateBoundaryState();
element("trust-host").checked = false;
element("fingerprint-box").hidden = true;
element("fingerprint").textContent = "";
element("password").value = "";
syncConnectionControls();
}
async function prepareReview(action) {
invalidateReview();
const boundary = selectedBoundary();
const reviewed = await api("/api/vps/supergrok/plan", { ...boundary, action });
if (reviewed.action !== action || reviewed.containerName !== boundary.containerName || reviewed.networkName !== boundary.networkName ||
reviewed.operationLockPath !== "/docker/n8n-openai-oauth/.supergrok-operation.lock" ||
reviewed.temporaryBuildStatePath !== (["install", "sign-in", "sign-out"].includes(action) ? "/docker/n8n-openai-oauth/.supergrok-operation.lock/buildx" : null)) {
throw new Error("The reviewed SuperGrok build boundary is invalid. Review a fresh plan.");
}
if (boundaryKey(boundary) !== boundaryKey()) return;
state.plan = reviewed;
element("review-summary").textContent = `${actionLabels[reviewed.action]}. ${reviewed.action === "remove" ? "Its saved OAuth session will be deleted. Other companions and n8n data stay." : "No existing provider credentials will be copied."} Serializes with ${reviewed.operationLockPath}.`;
if (reviewed.temporaryBuildStatePath) {
element("review-summary").textContent += ` After you confirm, builds use a temporary root-only folder at ${reviewed.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 model caches.`;
}
element("review-container").textContent = reviewed.containerName;
element("review-network").textContent = reviewed.networkName;
element("apply-button").textContent = actionLabels[reviewed.action];
element("review-panel").hidden = false;
setStage(3);
focusVisible(element("review-title"));
setMessage("Review the action and confirm when ready.");
}
async function pollLogin(installId, generation) {
if (generation !== state.loginGeneration) return;
if (state.busy) {
scheduleLoginPoll(installId, generation, 500);
return;
}
try {
const result = await api("/api/vps/supergrok/login-status", { installId });
if (generation !== state.loginGeneration || state.status?.installId !== installId) return;
if (state.busy) {
scheduleLoginPoll(installId, generation, 500);
return;
}
element("login-panel").hidden = false;
element("login-state").textContent = result.state === "pending"
? "Complete the sign-in on the official Grok page, then return here."
: result.state === "complete"
? "The official Grok action completed. Check models to verify your account connection."
: result.state === "expired"
? "This device code expired before sign-in completed. Start a fresh official Grok sign-in."
: result.state === "cancelled"
? "This device sign-in was cancelled. Start a new official Grok sign-in when ready."
: "The official Grok sign-in failed. Refresh status and start a new sign-in.";
element("device-code").textContent = result.userCode || "";
element("device-link").hidden = !result.verificationUrl;
if (result.verificationUrl) element("device-link").href = result.verificationUrl;
if (result.state === "pending") {
scheduleLoginPoll(installId, generation);
} else {
await perform("Refreshing account state…", refreshStatus);
}
} catch {
if (generation === state.loginGeneration) setMessage("The sign-in status could not be refreshed. Reconnect if the SSH session expired.");
}
}
for (const id of ["host", "port"]) element(id).addEventListener("input", () => {
clearFieldError(element(id), "error-message");
clearError();
resetHost();
});
for (const id of ["username", "password"]) element(id).addEventListener("input", () => {
clearFieldError(element(id), "error-message");
clearError();
});
element("ssh-form").addEventListener("invalid", (event) => {
const label = { host: "Hostname", port: "Port", username: "Username", password: "Password" }[event.target.id];
if (!label) return;
event.preventDefault();
showError(new Error(`${label} is invalid.`));
}, true);
element("scan-button").addEventListener("click", () => {
clearError();
for (const id of ["host", "port"]) {
if (!element(id).checkValidity()) {
showError(new Error(id === "host" ? "Hostname is invalid." : "Port is invalid."));
return;
}
}
void perform("Checking VPS identity…", async () => {
resetHost();
const result = await api("/api/ssh/fingerprint", { host: element("host").value, port: element("port").value });
for (const id of ["host", "port"]) clearFieldError(element(id), "error-message");
state.fingerprint = result.fingerprint;
element("fingerprint").textContent = state.fingerprint;
element("fingerprint-box").hidden = false;
setMessage("Compare the fingerprint before authenticating.");
}).then(() => {
// As on the main setup page, a finished check hands focus to the confirmation.
if (state.fingerprint && document.activeElement === element("scan-button")) element("trust-host").focus();
});
});
element("trust-host").addEventListener("change", () => {
if (!element("trust-host").checked) element("password").value = "";
syncConnectionControls();
});
element("ssh-form").addEventListener("submit", (event) => {
event.preventDefault();
if (!state.fingerprint || !element("trust-host").checked) return;
void perform("Connecting and finding n8n…", async () => {
await api("/api/ssh/connect", sshAuthentication.request(state.fingerprint));
// The confirmed identity has been used. Another attempt starts with a fresh check.
resetHost();
for (const id of ["username", "password"]) clearFieldError(element(id), "error-message");
await discover();
});
});
element("container").addEventListener("change", () => void perform("Reading private networks…", loadNetworks));
element("network").addEventListener("change", () => void perform("Checking companion…", refreshStatus));
element("refresh-button").addEventListener("click", () => void perform("Checking companion…", refreshStatus));
for (const button of document.querySelectorAll("[data-action]")) {
button.addEventListener("click", () => void perform("Preparing the review…", () => prepareReview(button.dataset.action)));
}
element("confirm-action").addEventListener("change", syncConnectionControls);
element("cancel-review").addEventListener("click", () => {
if (state.busy) return;
invalidateReview();
setStage(2);
focusVisible(element("selection-title"));
setMessage("No companion action was applied.");
});
element("apply-button").addEventListener("click", () => {
if (!state.plan || !element("confirm-action").checked) return;
void perform("Applying the reviewed companion action…", async () => {
const reviewed = state.plan;
invalidateReview();
const result = await api("/api/vps/supergrok/apply", { ...reviewed, confirmed: true });
await refreshStatus();
if (result.clientKey) element("client-key").value = result.clientKey;
if (result.state === "pending") {
stopLoginPolling();
const generation = ++state.loginGeneration;
scheduleLoginPoll(result.installId, generation, 1_500);
}
if (result.state === "cancelled" || result.removed) stopLoginPolling({ clearVisible: true });
if (result.removed) element("client-key").value = "";
setMessage(result.removed ? "The SuperGrok companion was removed. Existing n8n is unchanged." : "The reviewed companion action completed. Existing n8n is unchanged.");
});
});
element("models-button").addEventListener("click", () => {
void perform("Checking your account models…", async () => {
const result = await api("/api/vps/supergrok/models", { installId: state.status?.installId, clientKey: element("client-key").value });
element("models-result").textContent = result.status === 200
? `Available models: ${result.models.join(", ")}`
: result.code === "login_required"
? "Sign-in is required. Start an official Grok device sign-in for this companion."
: "Account model discovery failed. Try again after checking your Grok account.";
setMessage("Model check finished.");
});
});
element("copy-key").addEventListener("click", async (event) => {
const button = event.currentTarget;
const input = element("client-key");
if (state.busy || !input.value) return;
try {
try {
await navigator.clipboard.writeText(input.value);
} catch {
const fallback = document.createElement("textarea");
fallback.value = input.value;
fallback.style.position = "fixed";
fallback.style.opacity = "0";
document.body.append(fallback);
try {
fallback.focus();
fallback.select();
if (!document.execCommand("copy")) throw new Error("Clipboard unavailable.");
} finally {
fallback.remove();
button.focus();
}
}
flashCopied(button);
setMessage("Local bearer copied. Paste it directly into the n8n credential.");
} catch {
showError(new Error("Clipboard access was refused by the browser. Select the local bearer manually."));
}
});
element("disconnect-button").addEventListener("click", () => {
void perform("Disconnecting…", async () => {
await api("/api/disconnect");
invalidateBoundaryState();
element("ssh-panel").hidden = false;
element("selection-panel").hidden = true;
resetHost();
setStage(1);
setMessage("Disconnected. Your installed companion remains on the VPS.");
});
});
window.addEventListener("pagehide", () => {
stopLoginPolling();
element("client-key").value = "";
});
element("settings-back").addEventListener("click", () => {
if (!state.busy) setStage(2);
});
element("selection-continue").addEventListener("click", () => {
if (!state.busy && !element("settings-panel").hidden) setStage(4);
});
new MutationObserver(syncView).observe(element("main-content"), { subtree: true, attributeFilter: ["hidden"] });
initWizardTopbar({ session: token, isBusy: () => state.busy, loadProjectMeta: readProjectMeta });
const sshAuthentication = bindSshAuthentication({ token, trustId: "trust-host", onChange: invalidateBoundaryState, shouldApplyConnectionStatus: () => sshIdentityDecision === 0 });
if (!token) {
setMessage("Open this page from the Relmio wizard to establish a private session.");
} else {
void perform("Checking the VPS connection…", async () => {
try {
await sshSession.adoptCurrent();
await discover();
} catch (error) {
if (error.message !== "Connect to the VPS first.") throw error;
element("ssh-panel").hidden = false;
setStage(1);
setMessage("Connect to your VPS to begin. No ChatGPT sign-in is required.");
}
});
}