agent-zero/webui/components/settings/external/self-update-store.js
frdel 68ad5aca46 Remove return URL persistence from self-update flow
Replace sessionStorage-based return URL tracking with simple page reload after backend restart. Remove getSavedReturnUrl, saveReturnUrl helper methods and SELF_UPDATE_RETURN_URL_KEY constant. Update tests to verify removal of URL persistence code.
2026-03-26 09:35:42 +01:00

536 lines
16 KiB
JavaScript

import { createStore } from "/js/AlpineStore.js";
import * as API from "/js/api.js";
import { store as notificationStore } from "/components/notifications/notification-store.js";
import { openModal, closeModal } from "/js/modals.js";
const HEALTH_POLL_INTERVAL_MS = 2000;
const HEALTH_WAIT_BUFFER_MS = 30000;
const SELF_UPDATE_OVERLAY_ID = "self-update-progress-overlay";
const SELF_UPDATE_MODAL_PATH = "settings/external/self-update-modal.html";
const SELF_UPDATE_MANUAL_BACKUP_MODAL_PATH = "settings/backup/backup_restore.html";
const MIN_SELECTOR_VERSION = [1, 0];
const model = {
loading: false,
saving: false,
restarting: false,
tagsLoading: false,
error: "",
tagsError: "",
info: null,
availableTags: [],
higherMajorVersions: [],
restartStatusText: "",
restartDetailText: "",
form: {
branch: "main",
tag: "",
backup_usr: true,
backup_path: "",
backup_name: "",
backup_conflict_policy: "rename",
},
_reconnectTimer: null,
_tagRequestId: 0,
get isBusy() {
return this.loading || this.saving || this.restarting;
},
get isSupported() {
return Boolean(this.info?.supported);
},
get currentVersion() {
return this.info?.current?.short_tag || "unknown";
},
get currentBranch() {
return this.info?.current?.branch || "";
},
get trimmedTag() {
return (this.form.tag || "").trim();
},
get hasAvailableTags() {
return this.availableTags.length > 0;
},
get selectedTagExistsOnBranch() {
const tag = this.trimmedTag;
return Boolean(tag) && this.availableTags.includes(tag);
},
get higherMajorVersionMessage() {
if (!this.higherMajorVersions.length) return "";
const versionLabels = this.higherMajorVersions.map((major) => `v${major}.x`);
const versionText =
versionLabels.length === 1
? versionLabels[0]
: `${versionLabels.slice(0, -1).join(", ")} and ${versionLabels[versionLabels.length - 1]}`;
return `A newer major release line is available on this branch (${versionText}). Major upgrades require downloading a newer Docker image before using self-update.`;
},
get versionSelectPlaceholder() {
if (this.tagsLoading) return "Loading versions...";
if (!this.hasAvailableTags) return "No versions available";
return "Select a version";
},
get canScheduleUpdate() {
return (
this.isSupported &&
!this.isBusy &&
!this.tagsLoading &&
this.hasAvailableTags &&
this.isSupportedSelectorTag(this.form.tag) &&
this.selectedTagExistsOnBranch
);
},
async init() {
await this.refresh();
},
cleanup() {
this.clearReconnectTimer();
this.error = "";
this.tagsError = "";
this.loading = false;
this.saving = false;
this.restarting = false;
this.tagsLoading = false;
this.availableTags = [];
this.higherMajorVersions = [];
this.restartStatusText = "";
this.restartDetailText = "";
this.removeProgressOverlay();
},
clearReconnectTimer() {
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
},
formatTimestamp(value) {
if (!value) return "";
try {
return new Date(value).toLocaleString();
} catch {
return value;
}
},
formatBranchTag(branch, tag) {
return `${branch || "main"} / ${tag || "None"}`;
},
getProgressOverlay() {
return document.getElementById(SELF_UPDATE_OVERLAY_ID);
},
ensureProgressOverlay() {
let overlay = this.getProgressOverlay();
if (!overlay) {
overlay = document.createElement("div");
overlay.id = SELF_UPDATE_OVERLAY_ID;
overlay.innerHTML = `
<div class="self-update-progress-card">
<div class="self-update-progress-spinner"></div>
<div class="self-update-progress-title"></div>
<div class="self-update-progress-detail"></div>
</div>
`;
Object.assign(overlay.style, {
position: "fixed",
inset: "0",
zIndex: "10000",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1.5rem",
background:
"color-mix(in srgb, var(--color-background) 74%, transparent)",
backdropFilter: "blur(6px)",
});
document.body.appendChild(overlay);
const style = document.createElement("style");
style.id = `${SELF_UPDATE_OVERLAY_ID}-styles`;
style.textContent = `
#${SELF_UPDATE_OVERLAY_ID} .self-update-progress-card {
width: min(28rem, calc(100vw - 2rem));
border-radius: 1rem;
border: 1px solid var(--color-border);
background: color-mix(
in srgb,
var(--color-panel) 92%,
var(--color-background)
);
color: var(--color-text);
box-shadow: 0 24px 64px color-mix(
in srgb,
var(--color-background) 65%,
transparent
);
padding: 1.5rem;
text-align: center;
font-family: var(--font-family-main);
}
#${SELF_UPDATE_OVERLAY_ID} .self-update-progress-spinner {
width: 2.5rem;
height: 2.5rem;
margin: 0 auto 1rem;
border-radius: 999px;
border: 3px solid color-mix(
in srgb,
var(--color-border) 55%,
transparent
);
border-top-color: var(--color-primary);
animation: self-update-spin 1s linear infinite;
}
#${SELF_UPDATE_OVERLAY_ID} .self-update-progress-title {
font-size: 1.05rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
#${SELF_UPDATE_OVERLAY_ID} .self-update-progress-detail {
color: var(--color-text-muted);
line-height: 1.5;
}
@keyframes self-update-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
}
this.updateProgressOverlay();
},
updateProgressOverlay() {
const overlay = this.getProgressOverlay();
if (!overlay) return;
const title = overlay.querySelector(".self-update-progress-title");
const detail = overlay.querySelector(".self-update-progress-detail");
if (title) {
title.textContent = this.restartStatusText || "Applying self-update";
}
if (detail) {
detail.textContent =
this.restartDetailText ||
"Agent Zero is restarting, applying the requested release, and will reload this page when the health check responds again.";
}
},
removeProgressOverlay() {
this.getProgressOverlay()?.remove();
document.getElementById(`${SELF_UPDATE_OVERLAY_ID}-styles`)?.remove();
},
setRestartState(statusText, detailText = "") {
this.restartStatusText = statusText;
this.restartDetailText = detailText;
if (this.restarting) {
this.ensureProgressOverlay();
}
},
async refresh() {
this.loading = true;
this.error = "";
try {
const response = await API.callJsonApi("self_update_get", {});
if (!response?.success) {
throw new Error(response?.error || "Failed to load self-update info.");
}
this.info = response;
this.applyFormState(response.pending || response.defaults || {});
this.applyAvailableTags({
tags: response.available_tags,
higherMajorVersions: response.available_higher_major_versions,
error: response.available_tags_error,
});
} catch (error) {
console.error("Failed to load self-update info:", error);
this.error = error.message || "Failed to load self-update info.";
} finally {
this.loading = false;
}
},
applyFormState(source) {
this.form.branch =
source?.branch ||
this.info?.defaults?.branch ||
this.currentBranch ||
"main";
this.form.tag =
typeof source?.tag === "string"
? source.tag
: this.info?.defaults?.tag || this.currentVersion;
this.form.backup_usr =
typeof source?.backup_usr === "boolean" ? source.backup_usr : true;
this.form.backup_path = source?.backup_path || "";
this.form.backup_name = source?.backup_name || "";
this.form.backup_conflict_policy =
source?.backup_conflict_policy || "rename";
},
applyAvailableTags({ tags = [], higherMajorVersions = [], error = "" } = {}) {
this.availableTags = Array.isArray(tags) ? tags : [];
this.higherMajorVersions = Array.isArray(higherMajorVersions)
? higherMajorVersions
: [];
this.tagsError = error || "";
if (!this.availableTags.length) {
this.form.tag = "";
return;
}
const preferredTag = this.trimmedTag;
if (preferredTag && this.availableTags.includes(preferredTag)) {
return;
}
const defaultTag = (this.info?.defaults?.tag || "").trim();
this.form.tag =
defaultTag && this.availableTags.includes(defaultTag)
? defaultTag
: this.availableTags[0];
},
async openModal() {
await openModal(SELF_UPDATE_MODAL_PATH);
},
async openManualBackupModal() {
await openModal(SELF_UPDATE_MANUAL_BACKUP_MODAL_PATH);
},
async onBranchChanged() {
await this.fetchTags();
},
async fetchTags() {
const requestId = ++this._tagRequestId;
this.tagsLoading = true;
this.tagsError = "";
try {
const response = await API.callJsonApi("self_update_tags", {
branch: this.form.branch,
});
if (!response?.success) {
throw new Error(response?.error || "Failed to fetch release tags.");
}
if (requestId !== this._tagRequestId) {
return;
}
this.applyAvailableTags({
tags: response.tags,
higherMajorVersions: response.higher_major_versions,
error: response.error,
});
} catch (error) {
console.error("Failed to fetch self-update tags:", error);
if (requestId !== this._tagRequestId) {
return;
}
this.applyAvailableTags();
this.tagsError = error.message || "Failed to fetch release tags.";
} finally {
if (requestId === this._tagRequestId) {
this.tagsLoading = false;
}
}
},
parseSelectorTag(value) {
const match = /^v(\d+)\.(\d+)$/.exec((value || "").trim());
if (!match) return null;
return [
Number.parseInt(match[1], 10),
Number.parseInt(match[2], 10),
];
},
isSupportedSelectorTag(value) {
const parsed = this.parseSelectorTag(value);
if (!parsed) return false;
for (let i = 0; i < MIN_SELECTOR_VERSION.length; i += 1) {
if (parsed[i] > MIN_SELECTOR_VERSION[i]) return true;
if (parsed[i] < MIN_SELECTOR_VERSION[i]) return false;
}
return true;
},
async scheduleUpdate() {
if (!this.form.branch?.trim()) {
this.error = "Choose a branch.";
return;
}
if (!this.form.tag?.trim()) {
this.error = "Choose a version from the list.";
return;
}
if (!this.parseSelectorTag(this.form.tag)) {
this.error = "Release tag must use the format vX.Y.";
return;
}
if (!this.isSupportedSelectorTag(this.form.tag)) {
this.error = "Release tag must be v1.0 or newer.";
return;
}
if (!this.selectedTagExistsOnBranch) {
await this.fetchTags();
if (!this.selectedTagExistsOnBranch) {
this.error = `Version ${this.trimmedTag} does not exist on branch ${this.form.branch || "main"}.`;
return;
}
}
this.saving = true;
this.error = "";
try {
const response = await API.callJsonApi("self_update_schedule", {
branch: this.form.branch,
tag: this.form.tag,
backup_usr: this.form.backup_usr,
backup_path: this.form.backup_path,
backup_name: this.form.backup_name,
backup_conflict_policy: this.form.backup_conflict_policy,
});
if (!response?.success) {
throw new Error(response?.error || "Failed to schedule the self-update.");
}
if (this.info) {
this.info.pending = response.pending;
}
await notificationStore.frontendWarning(
"Agent Zero is restarting to apply the requested branch and release tag.",
"Self Update",
10,
"self-update-restart",
undefined,
true,
);
await this.restartAndReload();
} catch (error) {
console.error("Failed to schedule self-update:", error);
this.error = error.message || "Failed to schedule the self-update.";
} finally {
this.saving = false;
}
},
async restartAndReload() {
this.restarting = true;
this.clearReconnectTimer();
let observedBackendUnavailable = false;
this.setRestartState(
"Starting self-update",
"The request was saved. Agent Zero is about to restart and apply the requested branch and tag."
);
this.ensureProgressOverlay();
try {
const token = await API.getCsrfToken();
const restartResponse = await fetch("/api/restart", {
method: "POST",
credentials: "same-origin",
keepalive: true,
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": token,
},
body: JSON.stringify({}),
});
if (restartResponse && !restartResponse.ok) {
throw new Error(`Restart request failed with HTTP ${restartResponse.status}.`);
}
} catch (_error) {
// The restart request often terminates the backend mid-flight.
}
const maxWaitMs =
((this.info?.pending?.health_timeout_seconds ||
this.info?.defaults?.health_timeout_seconds ||
120) *
1000) +
HEALTH_WAIT_BUFFER_MS;
const deadline = Date.now() + maxWaitMs;
let lastError = "";
this.setRestartState(
"Update in progress",
"Agent Zero is restarting and the updater is running. This page will reload automatically when /api/health starts responding again."
);
while (Date.now() < deadline) {
try {
const response = await fetch("/api/health", {
method: "GET",
credentials: "same-origin",
cache: "no-store",
});
if (response.ok && observedBackendUnavailable) {
window.location.reload();
return;
}
if (response.ok) {
this.setRestartState(
"Restarting backend",
"Waiting for Agent Zero to disconnect before reloading the page."
);
lastError = "Health check is still responding before the restart has completed.";
} else {
observedBackendUnavailable = true;
this.setRestartState(
"Update in progress",
"Agent Zero is restarting and the updater is running. This page will reload automatically when the health check becomes healthy again."
);
lastError = `Health check returned HTTP ${response.status}.`;
}
} catch (error) {
observedBackendUnavailable = true;
this.setRestartState(
"Update in progress",
"Agent Zero is temporarily unavailable while it restarts. Waiting for the new runtime to become healthy."
);
lastError = error?.message || String(error);
}
await new Promise((resolve) => {
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null;
resolve();
}, HEALTH_POLL_INTERVAL_MS);
});
}
this.restarting = false;
this.removeProgressOverlay();
this.error =
"Agent Zero did not come back within the expected window. It may still be rolling back. " +
(lastError ? `Last health check error: ${lastError}` : "");
await this.refresh();
},
close() {
closeModal(SELF_UPDATE_MODAL_PATH);
},
};
const store = createStore("selfUpdateStore", model);
export { store };