Refactor API key handling and update system improvements

- Refactor API key management: move from global env-only to per-model config with dotenv fallback - Add API key placeholder masking and reveal functionality in WebUI - Consolidate API key validation logic into `has_provider_api_key()` helper - Improve update system: add branch filtering for tags, simplify backup naming - Add branch detection to version info and default to current branch for updates - Extract configure model settings link to constant

frdel committed Mar 24, 2026 at 14:41 UTC e680256b2934117885fc36f3c99984adb5b7a4d5
18 files changed +781 -111
api/self_update_tags.py new
+35
@@ -0,0 +1,35 @@
1 +from helpers.api import ApiHandler, Request, Response
2 +
3 +from helpers import runtime
4 +from helpers import self_update
5 +
6 +
7 +class SelfUpdateTags(ApiHandler):
8 + async def process(self, input: dict, request: Request) -> dict | Response:
9 + branch = str(input.get("branch", "")).strip().lower()
10 + query = str(input.get("query", ""))
11 + current_branch = self_update.get_repo_version_info().get("branch", "").strip().lower()
12 + default_branch = current_branch if current_branch in self_update.SUPPORTED_BRANCHES else "main"
13 +
14 + try:
15 + tags, error = self_update.get_available_tags(
16 + branch or None,
17 + query=query,
18 + )
19 + return {
20 + "success": True,
21 + "supported": runtime.is_dockerized(),
22 + "branch": branch or default_branch,
23 + "query": query,
24 + "tags": tags,
25 + "error": error,
26 + }
27 + except Exception as e:
28 + return {
29 + "success": False,
30 + "supported": runtime.is_dockerized(),
31 + "branch": branch or "main",
32 + "query": query,
33 + "tags": [],
34 + "error": str(e),
35 + }
conf/dummy_update_test.txt
+1
@@ -0,0 +1 @@
1 +Dummy file to test the update system.
\ No newline at end of file
extensions/python/banners/_20_missing_api_key.py
+15 -9
@@ -1,6 +1,6 @@
1 from helpers.extension import Extension
2 from helpers import plugins
3 -import models
3 +from plugins._model_config.helpers import model_config
4
5
6 class MissingApiKeyCheck(Extension):
@@ -8,6 +8,13 @@ class MissingApiKeyCheck(Extension):
8
9 LOCAL_PROVIDERS = {"ollama", "lm_studio"}
10 LOCAL_EMBEDDING = {"huggingface"}
11 + CONFIGURE_MODEL_SETTINGS_LINK = (
12 + """<a href="#" onclick="(async()=>{"""
13 + """const { store: s } = await import('/components/plugins/plugin-settings-store.js');"""
14 + """if(s&&s.openConfig){await s.openConfig('_model_config');}"""
15 + """})();return false;">"""
16 + """Configure model settings</a>"""
17 + )
18
19 async def execute(self, banners: list = [], frontend_context: dict = {}, **kwargs):
20 cfg = plugins.get_plugin_config("_model_config") or {}
@@ -28,8 +35,10 @@ class MissingApiKeyCheck(Extension):
35 if label == "Embedding Model" and provider_lower in self.LOCAL_EMBEDDING:
36 continue
37
31 - api_key = models.get_api_key(provider_lower)
32 - if not (api_key and api_key.strip() and api_key != "None"):
38 + if not model_config.has_provider_api_key(
39 + provider_lower,
40 + model_cfg.get("api_key", ""),
41 + ):
42 missing_providers.append({
43 "model_type": label,
44 "provider": provider,
@@ -47,8 +56,7 @@ class MissingApiKeyCheck(Extension):
56 "title": "Missing LLM API Key for current settings",
57 "html": f"""No API key configured for: {model_list}.<br>
58 Agent Zero will not be able to function properly unless you provide an API key or change your settings.<br>
50 - <a href="#" onclick="(async()=>{{await import('/components/plugins/plugin-settings-store.js');const s=Alpine.store('pluginSettingsPrototype');if(s&amp;&amp;s.open){{await s.open('_model_config',{{perProjectConfig:true,perAgentConfig:true}});}}openModal('components/plugins/plugin-settings.html');}})();return false;">
51 - Configure model settings</a>""",
59 + {self.CONFIGURE_MODEL_SETTINGS_LINK}""",
60 "dismissible": False,
61 "source": "backend"
62 })
@@ -73,8 +81,7 @@ class MissingApiKeyCheck(Extension):
81 # Skip if preset has its own api_key
82 if slot.get("api_key", "").strip():
83 continue
76 - api_key = models.get_api_key(provider_lower)
77 - if not (api_key and api_key.strip() and api_key != "None"):
84 + if not model_config.has_provider_api_key(provider_lower):
85 seen.add(provider_lower)
86 preset_missing.append(f"{preset_name}/{slot_label} ({provider})")
87
@@ -87,8 +94,7 @@ class MissingApiKeyCheck(Extension):
94 "title": "Missing API Key for model presets",
95 "html": f"""No API key configured for preset models: {preset_list}.<br>
96 These presets will not work until you provide the required API keys.<br>
90 - <a href="#" onclick="(async()=>{{await import('/components/plugins/plugin-settings-store.js');const s=Alpine.store('pluginSettingsPrototype');if(s&amp;&amp;s.open){{await s.open('_model_config',{{perProjectConfig:true,perAgentConfig:true}});}}openModal('components/plugins/plugin-settings.html');}})();return false;">
91 - Configure model settings</a>""",
97 + {self.CONFIGURE_MODEL_SETTINGS_LINK}""",
98 "dismissible": True,
99 "source": "backend"
100 })
helpers/self_update.py
+52 -8
@@ -17,6 +17,7 @@ BRANCH_OPTIONS = [
17 {"value": "testing", "label": "testing"},
18 {"value": "development", "label": "development"},
19 ]
20 +SUPPORTED_BRANCHES = {option["value"] for option in BRANCH_OPTIONS}
21 BACKUP_CONFLICT_POLICIES = {"rename", "overwrite", "fail"}
22
23 UPDATE_FILE_PATH = Path("/exe/a0-self-update.yaml")
@@ -133,7 +134,12 @@ def get_repo_version_info(repo_dir: str | Path | None = None) -> dict[str, str]:
134 repository = get_repo_dir(repo_dir)
135 describe = _run_git(repository, "describe", "--tags", "--always")
136 commit = _run_git(repository, "rev-parse", "HEAD")
137 + try:
138 + branch = _run_git(repository, "branch", "--show-current")
139 + except Exception:
140 + branch = ""
141 return {
142 + "branch": branch,
143 "describe": describe,
144 "short_tag": _normalize_describe_to_version(describe),
145 "commit": commit,
@@ -162,9 +168,7 @@ def build_default_backup_name(
168 target_tag: str | None = None,
169 ) -> str:
170 timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
165 - current_slug = _slugify_version(current_version)
166 - target_slug = _slugify_version(target_tag or current_version)
167 - return f"agent-zero-usr-{current_slug}-to-{target_slug}-{timestamp}.zip"
171 + return f"usr-{timestamp}.zip"
172
173
174 def _resolve_backup_path(
@@ -180,18 +184,58 @@ def _resolve_backup_path(
184 return path.resolve()
185
186
183 -def get_available_tags() -> tuple[list[str], str]:
187 +def _get_branch_reference_names(branch: str) -> list[str]:
188 + normalized_branch = branch.strip().lower()
189 + if normalized_branch not in SUPPORTED_BRANCHES:
190 + return []
191 + return [f"origin/{normalized_branch}", normalized_branch]
192 +
193 +
194 +def _get_branch_merged_tags(
195 + branch: str,
196 + repo_dir: str | Path | None = None,
197 +) -> set[str]:
198 + repository = get_repo_dir(repo_dir)
199 + for ref in _get_branch_reference_names(branch):
200 + try:
201 + _run_git(repository, "rev-parse", "--verify", ref)
202 + output = _run_git(repository, "tag", "--merged", ref)
203 + return {line.strip() for line in output.splitlines() if line.strip()}
204 + except Exception:
205 + continue
206 + return set()
207 +
208 +
209 +def get_available_tags(
210 + branch: str | None = None,
211 + *,
212 + repo_dir: str | Path | None = None,
213 + query: str = "",
214 +) -> tuple[list[str], str]:
215 result = git.get_remote_releases(OFFICIAL_REPO_AUTHOR, OFFICIAL_REPO_NAME)
216 if result.error:
217 return [], result.error
187 - return [release.tag for release in result.releases], ""
218 + tags = [release.tag for release in result.releases]
219 +
220 + if branch:
221 + merged_tags = _get_branch_merged_tags(branch, repo_dir=repo_dir)
222 + if merged_tags:
223 + tags = [tag for tag in tags if tag in merged_tags]
224 +
225 + normalized_query = query.strip().lower()
226 + if normalized_query:
227 + tags = [tag for tag in tags if normalized_query in tag.lower()]
228 +
229 + return tags, ""
230
231
232 def get_update_info(repo_dir: str | Path | None = None) -> dict[str, Any]:
233 repository = get_repo_dir(repo_dir)
234 version_info = get_repo_version_info(repository)
235 current_version = version_info["short_tag"]
194 - tags, tags_error = get_available_tags()
236 + current_branch = version_info.get("branch", "").strip().lower()
237 + default_branch = current_branch if current_branch in SUPPORTED_BRANCHES else "main"
238 + tags, tags_error = get_available_tags(default_branch, repo_dir=repository)
239 return {
240 "repo_dir": str(repository),
241 "current": version_info,
@@ -206,7 +250,7 @@ def get_update_info(repo_dir: str | Path | None = None) -> dict[str, Any]:
250 "log_file": str(get_log_file_path()),
251 },
252 "defaults": {
209 - "branch": "main",
253 + "branch": default_branch,
254 "tag": current_version,
255 "backup_usr": True,
256 "backup_path": str(get_default_backup_dir(repository)),
@@ -230,7 +274,7 @@ def schedule_update(
274 version_info = get_repo_version_info(repository)
275
276 normalized_branch = branch.strip().lower()
233 - if normalized_branch not in {option["value"] for option in BRANCH_OPTIONS}:
277 + if normalized_branch not in SUPPORTED_BRANCHES:
278 raise ValueError("Branch must be one of: main, testing, development.")
279
280 normalized_tag = tag.strip()
models.py
+3
@@ -68,6 +68,7 @@ class ModelConfig:
68 type: ModelType
69 provider: str
70 name: str
71 + api_key: str = ""
72 api_base: str = ""
73 ctx_length: int = 0
74 limit_requests: int = 0
@@ -78,6 +79,8 @@ class ModelConfig:
79
80 def build_kwargs(self):
81 kwargs = self.kwargs.copy() or {}
82 + if self.api_key and "api_key" not in kwargs:
83 + kwargs["api_key"] = self.api_key
84 if self.api_base and "api_base" not in kwargs:
85 kwargs["api_base"] = self.api_base
86 return kwargs
plugins/_model_config/api/model_config_set.py
+23 -3
@@ -1,7 +1,11 @@
1 +from copy import deepcopy
2 +
3 from helpers.api import ApiHandler, Request, Response
2 -from helpers import plugins, defer
4 +from helpers import plugins, defer, dotenv
5 from helpers.extension import call_extensions_async
6
7 +API_KEY_PLACEHOLDER = "************"
8 +
9
10 class ModelConfigSet(ApiHandler):
11 async def process(self, input: dict, request: Request) -> dict | Response:
@@ -12,6 +16,22 @@ class ModelConfigSet(ApiHandler):
16 if not config or not isinstance(config, dict):
17 return Response(status=400, response="Missing or invalid config")
18
19 + config_to_save = deepcopy(config)
20 + for section_name in ("chat_model", "utility_model", "embedding_model"):
21 + section = config_to_save.get(section_name, {})
22 + if not isinstance(section, dict):
23 + continue
24 + provider = str(section.get("provider", "")).strip()
25 + api_key = section.get("api_key", "")
26 + if (
27 + provider
28 + and isinstance(api_key, str)
29 + and api_key.strip()
30 + and api_key != API_KEY_PLACEHOLDER
31 + ):
32 + dotenv.save_dotenv_value(f"API_KEY_{provider.upper()}", api_key)
33 + section.pop("api_key", None)
34 +
35 # Read previous config BEFORE saving so we can detect changes
36 prev_config = plugins.get_plugin_config(
37 "_model_config",
@@ -23,12 +43,12 @@ class ModelConfigSet(ApiHandler):
43 "_model_config",
44 project_name=project_name,
45 agent_profile=agent_profile,
26 - settings=config,
46 + settings=config_to_save,
47 )
48
49 # Check if embedding model changed and notify
50 prev_embed = prev_config.get("embedding_model", {})
31 - new_embed = config.get("embedding_model", {})
51 + new_embed = config_to_save.get("embedding_model", {})
52 if (
53 prev_embed.get("provider") != new_embed.get("provider")
54 or prev_embed.get("name") != new_embed.get("name")
plugins/_model_config/helpers/model_config.py
+13 -5
@@ -6,6 +6,8 @@ from helpers.providers import get_providers
6
7 PRESETS_FILE = "presets.yaml"
8 DEFAULT_PRESETS_FILE = "default_presets.yaml"
9 +LOCAL_PROVIDERS = {"ollama", "lm_studio"}
10 +LOCAL_EMBEDDING = {"huggingface"}
11
12
13 def _get_presets_path() -> str:
@@ -158,6 +160,7 @@ def build_model_config(cfg: dict, model_type: models.ModelType) -> models.ModelC
160 type=model_type,
161 provider=cfg.get("provider", ""),
162 name=cfg.get("name", ""),
163 + api_key=cfg.get("api_key", ""),
164 api_base=cfg.get("api_base", ""),
165 ctx_length=int(cfg.get("ctx_length", 0)),
166 vision=bool(cfg.get("vision", False)),
@@ -222,14 +225,20 @@ def get_embedding_providers():
225 return get_providers("embedding")
226
227
228 +def has_provider_api_key(provider: str, configured_api_key: str = "") -> bool:
229 + configured_value = (configured_api_key or "").strip()
230 + if configured_value and configured_value != "None":
231 + return True
232 +
233 + api_key = models.get_api_key(provider.lower())
234 + return bool(api_key and api_key.strip() and api_key != "None")
235 +
236 +
237 def get_missing_api_key_providers(agent=None) -> list[dict]:
238 """Check which configured providers are missing API keys."""
239 cfg = get_config(agent)
240 missing = []
241
230 - LOCAL_PROVIDERS = {"ollama", "lm_studio"}
231 - LOCAL_EMBEDDING = {"huggingface"}
232 -
242 checks = [
243 ("Chat Model", cfg.get("chat_model", {})),
244 ("Utility Model", cfg.get("utility_model", {})),
@@ -246,8 +255,7 @@ def get_missing_api_key_providers(agent=None) -> list[dict]:
255 if label == "Embedding Model" and provider_lower in LOCAL_EMBEDDING:
256 continue
257
249 - api_key = models.get_api_key(provider_lower)
250 - if not (api_key and api_key.strip() and api_key != "None"):
258 + if not has_provider_api_key(provider_lower, model_cfg.get("api_key", "")):
259 missing.append({"model_type": label, "provider": provider})
260
261 return missing
plugins/_model_config/hooks.py
+1
@@ -4,4 +4,5 @@ def save_plugin_config(result=None, settings=None, **kwargs):
4 for section in ("chat_model", "utility_model", "embedding_model"):
5 if section in settings and isinstance(settings[section], dict):
6 settings[section].pop("_kwargs_text", None)
7 + settings[section].pop("api_key", None)
8 return settings
plugins/_model_config/webui/api-keys.html
+72 -9
@@ -10,12 +10,61 @@
10
11 <div x-data>
12 <template x-if="$store.modelConfig">
13 -<div x-data="{ keys: {}, loading: true }"
14 - x-init="
15 - await $store.modelConfig.ensureLoaded();
16 - $store.modelConfig.allProviders.forEach(p => keys[p.value] = '');
17 - loading = false;
18 - ">
13 +<div x-data="{
14 + keys: {},
15 + originalKeys: {},
16 + touched: {},
17 + loading: true,
18 + saving: false,
19 + error: '',
20 + get hasChanges() {
21 + return Object.keys(this.touched).some((provider) => this.touched[provider]);
22 + },
23 + async init() {
24 + await $store.modelConfig.ensureLoaded();
25 + await $store.modelConfig.refreshApiKeyStatus();
26 + $store.modelConfig.allProviders.forEach((provider) => {
27 + this.keys[provider.value] = '';
28 + this.originalKeys[provider.value] = '';
29 + this.touched[provider.value] = false;
30 + });
31 + this.loading = false;
32 + },
33 + markChanged(provider) {
34 + this.touched[provider] = this.keys[provider] !== this.originalKeys[provider];
35 + },
36 + async reveal(provider) {
37 + try {
38 + const value = await $store.modelConfig.revealApiKey(provider);
39 + this.keys[provider] = value || '';
40 + this.originalKeys[provider] = value || '';
41 + this.touched[provider] = false;
42 + } catch (e) {
43 + this.error = e?.message || 'Failed to reveal API key.';
44 + }
45 + },
46 + async saveAndClose() {
47 + this.saving = true;
48 + this.error = '';
49 + try {
50 + const updates = {};
51 + for (const provider of Object.keys(this.touched)) {
52 + if (!this.touched[provider]) continue;
53 + const value = (this.keys[provider] || '').trim();
54 + if (!value) continue;
55 + updates[provider] = value;
56 + }
57 + await $store.modelConfig.saveApiKeys(updates);
58 + await $store.modelConfig.refreshApiKeyStatus();
59 + window.closeModal?.();
60 + } catch (e) {
61 + this.error = e?.message || 'Failed to save API keys.';
62 + } finally {
63 + this.saving = false;
64 + }
65 + }
66 + }"
67 + x-init="init()">
68
69 <div class="api-keys-section">
70 <div class="section-title">API Keys</div>
@@ -24,6 +73,11 @@
73 For more information about Agent Zero Venice provider, see <a href="http://agent-zero.ai/?community/api-dashboard/about" target="_blank">Agent Zero Venice</a>.
74 </div>
75
76 + <div x-show="error" class="plugin-settings-error" style="margin-top: 1rem;">
77 + <span class="material-symbols-outlined">error</span>
78 + <span x-text="error"></span>
79 + </div>
80 +
81 <div x-show="loading" style="text-align:center; padding: 20px;">
82 <span class="material-symbols-outlined spinning">progress_activity</span>
83 </div>
@@ -39,13 +93,13 @@
93 x-model="keys[provider.value]"
94 :placeholder="provider.has_key ? '••••••••••••' : ''"
95 autocomplete="off"
42 - @input.debounce.800ms="keys[provider.value] && $store.modelConfig.saveApiKey(provider.value, keys[provider.value])"
96 + @input="markChanged(provider.value)"
97 style="padding-right:32px;" />
98 <span class="material-symbols-outlined eye-toggle"
99 @click="
100 showKey = !showKey;
101 if (showKey && !keys[provider.value] && provider.has_key) {
48 - $store.modelConfig.revealApiKey(provider.value).then(v => { if (v) keys[provider.value] = v; });
102 + reveal(provider.value);
103 }
104 "
105 x-text="showKey ? 'visibility' : 'visibility_off'"></span>
@@ -56,7 +110,16 @@
110 </div>
111
112 <div class="modal-footer" data-modal-footer>
59 - <button class="btn btn-cancel" @click="closeModal()">Close</button>
113 + <button class="btn btn-ok"
114 + @click="saveAndClose()"
115 + :disabled="loading || saving || !hasChanges">
116 + Save
117 + </button>
118 + <button class="btn btn-cancel"
119 + @click="window.closeModal?.()"
120 + :disabled="saving">
121 + Close
122 + </button>
123 </div>
124 </div>
125 </template>
plugins/_model_config/webui/config.html
+2 -1
@@ -10,7 +10,9 @@
10 <div x-data
11 x-init="
12 await $store.modelConfig.ensureLoaded();
13 + await $store.modelConfig.refreshApiKeyStatus();
14 $store.modelConfig.initConfigFields(config);
15 + $store.modelConfig.installPluginSettingsSaveHook(context, config);
16 const _origReset = context.resetToDefault.bind(context);
17 context.resetToDefault = async () => {
18 const before = context.settings;
@@ -117,7 +119,6 @@
119 x-model="$store.modelConfig.apiKeyValues[config[section.key].provider]"
120 :placeholder="$store.modelConfig.apiKeyStatus[config[section.key].provider] ? '&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;' : ''"
121 autocomplete="off"
120 - @input.debounce.800ms="$store.modelConfig.saveApiKeyIfSet(config[section.key].provider)"
122 style="padding-right:32px;" />
123 <span class="material-symbols-outlined eye-toggle"
124 @click="
plugins/_model_config/webui/model-config-store.js
+114 -7
@@ -67,6 +67,22 @@ export const store = createStore("modelConfig", {
67
68 init() {},
69
70 + _setProviderHasKey(provider, hasKey) {
71 + if (!provider) return;
72 + this.apiKeyStatus = { ...this.apiKeyStatus, [provider]: !!hasKey };
73 + const normalized = provider.toLowerCase();
74 + this.allProviders = (this.allProviders || []).map((item) =>
75 + item.value?.toLowerCase() === normalized ? { ...item, has_key: !!hasKey } : item
76 + );
77 + },
78 +
79 + _ensureApiKeySlot(provider) {
80 + if (!provider) return;
81 + if (!(provider in this.apiKeyValues)) {
82 + this.apiKeyValues = { ...this.apiKeyValues, [provider]: '' };
83 + }
84 + },
85 +
86 _normalizePresets(rawPresets) {
87 return (rawPresets || []).map(p => ({
88 name: p.name || '',
@@ -103,6 +119,35 @@ export const store = createStore("modelConfig", {
119 this._loaded = true;
120 },
121
122 + async refreshApiKeyStatus() {
123 + await this.ensureLoaded();
124 + const res = await fetchApi(`${API_BASE}/api_keys`, {
125 + method: 'POST',
126 + headers: { 'Content-Type': 'application/json' },
127 + body: JSON.stringify({ action: 'get' })
128 + });
129 + const data = await res.json();
130 + const keys = data.keys || {};
131 +
132 + const nextStatus = { ...this.apiKeyStatus };
133 + const nextValues = { ...this.apiKeyValues };
134 +
135 + for (const provider of this.allProviders) {
136 + const entry = keys[provider.value] || {};
137 + const hasKey = !!entry.has_key;
138 + nextStatus[provider.value] = hasKey;
139 + provider.has_key = hasKey;
140 + if (!hasKey && !nextValues[provider.value]) {
141 + nextValues[provider.value] = '';
142 + }
143 + }
144 +
145 + this.apiKeyStatus = nextStatus;
146 + this.apiKeyValues = nextValues;
147 + this.allProviders = [...this.allProviders];
148 + return keys;
149 + },
150 +
151 async _fetchConfigData() {
152 const res = await fetchApi(`${API_BASE}/model_config_get`, {
153 method: 'POST',
@@ -180,15 +225,39 @@ export const store = createStore("modelConfig", {
225 },
226
227 // API Key operations
183 - async saveApiKey(provider, value) {
184 - await fetchApi(`${API_BASE}/api_keys`, {
228 + async saveApiKeys(updates) {
229 + const normalized = {};
230 + for (const [provider, value] of Object.entries(updates || {})) {
231 + if (!provider || typeof value !== 'string') continue;
232 + if (!value.trim()) continue;
233 + normalized[provider] = value;
234 + }
235 +
236 + if (Object.keys(normalized).length === 0) {
237 + return { ok: true };
238 + }
239 +
240 + const res = await fetchApi(`${API_BASE}/api_keys`, {
241 method: 'POST',
242 headers: { 'Content-Type': 'application/json' },
187 - body: JSON.stringify({ action: 'set', keys: { [provider]: value } })
243 + body: JSON.stringify({ action: 'set', keys: normalized })
244 });
189 - this.apiKeyStatus = { ...this.apiKeyStatus, [provider]: true };
190 - const ap = this.allProviders.find(x => x.value === provider);
191 - if (ap) ap.has_key = true;
245 + const data = await res.json();
246 + if (!data?.ok) {
247 + throw new Error(data?.error || 'Failed to save API keys.');
248 + }
249 +
250 + const nextValues = { ...this.apiKeyValues };
251 + for (const [provider, value] of Object.entries(normalized)) {
252 + nextValues[provider] = value;
253 + this._setProviderHasKey(provider, true);
254 + }
255 + this.apiKeyValues = nextValues;
256 + return data;
257 + },
258 +
259 + async saveApiKey(provider, value) {
260 + return this.saveApiKeys({ [provider]: value });
261 },
262
263 saveApiKeyIfSet(provider) {
@@ -203,7 +272,45 @@ export const store = createStore("modelConfig", {
272 body: JSON.stringify({ action: 'reveal', provider })
273 });
274 const data = await res.json();
206 - return data.value || '';
275 + if (!data?.ok) {
276 + throw new Error(data?.error || 'Failed to load API key.');
277 + }
278 + const value = data.value || '';
279 + if (provider && value) {
280 + this._ensureApiKeySlot(provider);
281 + this.apiKeyValues = { ...this.apiKeyValues, [provider]: value };
282 + this._setProviderHasKey(provider, true);
283 + }
284 + return value;
285 + },
286 +
287 + async persistApiKeysForConfig(config) {
288 + const updates = {};
289 + for (const section of this.MODEL_SECTIONS) {
290 + const provider = config?.[section.key]?.provider;
291 + if (!provider) continue;
292 + const value = this.apiKeyValues[provider];
293 + if (typeof value === 'string' && value.trim()) {
294 + updates[provider] = value;
295 + }
296 + }
297 + return this.saveApiKeys(updates);
298 + },
299 +
300 + installPluginSettingsSaveHook(context, config) {
301 + if (!context || context.__modelConfigSaveHookInstalled) return;
302 + const originalSave = context.save.bind(context);
303 + context.save = async () => {
304 + context.error = null;
305 + try {
306 + await this.persistApiKeysForConfig(config);
307 + } catch (e) {
308 + context.error = e?.message || 'Failed to save API keys.';
309 + return;
310 + }
311 + await originalSave();
312 + };
313 + context.__modelConfigSaveHookInstalled = true;
314 },
315
316 // Model search
webui/components/settings/backup/backup-settings.html
+11 -1
@@ -1,6 +1,6 @@
1 <html>
2 <head>
3 - <title>Backup & Restore</title>
3 + <title>Update</title>
4 </head>
5
6 <body>
@@ -9,6 +9,12 @@
9 <div>
10 <nav>
11 <ul>
12 + <li>
13 + <a href="#section-self-update">
14 + <img src="/public/update_checker.svg" alt="Self Update" />
15 + <span>Self Update</span>
16 + </a>
17 + </li>
18 <li>
19 <a href="#section-backup-restore">
20 <img src="/public/backup_restore.svg" alt="Backup & Restore" />
@@ -18,6 +24,10 @@
24 </ul>
25 </nav>
26
27 + <div id="section-self-update" class="section">
28 + <x-component path="settings/backup/self-update.html"></x-component>
29 + </div>
30 +
31 <div id="section-backup-restore" class="section">
32 <x-component path="settings/backup/backup_restore.html"></x-component>
33 </div>
webui/components/settings/backup/self-update.html new
+51
@@ -0,0 +1,51 @@
1 +<html>
2 + <head>
3 + <title>Self Update</title>
4 + </head>
5 +
6 + <body>
7 + <div x-data>
8 + <div>
9 + <div class="section-title">Self Update</div>
10 +
11 + <div class="field">
12 + <div class="field-label">
13 + <div class="field-title">Open Self Update</div>
14 + <div class="field-description">
15 + Choose the target branch and tag, decide whether to zip <code>/a0/usr</code> first, and review the durable trigger, status, and log file locations used across restarts.
16 + </div>
17 + <template x-if="!$store.settings.additional?.is_dockerized">
18 + <div class="field-description">
19 + This action is currently available only in dockerized installs.
20 + </div>
21 + </template>
22 + </div>
23 + <div class="field-control">
24 + <button
25 + class="btn btn-field"
26 + @click="openModal('settings/external/self-update-modal.html')"
27 + :disabled="!$store.settings.additional?.is_dockerized"
28 + >
29 + Open Self Update
30 + </button>
31 + </div>
32 + </div>
33 +
34 + <div class="field">
35 + <div class="field-label">
36 + <div class="field-title">Enable Update Checker</div>
37 + <div class="field-description">
38 + Periodically check for newer Agent Zero releases and show a notification when an update is recommended.
39 + </div>
40 + </div>
41 + <div class="field-control">
42 + <label class="toggle">
43 + <input type="checkbox" x-model="$store.settings.settings.update_check_enabled" />
44 + <span class="toggler"></span>
45 + </label>
46 + </div>
47 + </div>
48 + </div>
49 + </div>
50 + </body>
51 +</html>
webui/components/settings/external/external-settings.html
-10
@@ -39,12 +39,6 @@
39 <span>External API</span>
40 </a>
41 </li>
42 - <li>
43 - <a href="#section-update-checker">
44 - <img src="/public/update_checker.svg" alt="Update Checker" />
45 - <span>Update Checker</span>
46 - </a>
47 - </li>
42 <li>
43 <a href="#section-tunnel">
44 <img src="/public/tunnel.svg" alt="Tunnel" />
@@ -69,10 +63,6 @@
63 <div id="section-external-api" class="section">
64 <x-component path="settings/external/external_api.html"></x-component>
65 </div>
72 - <div id="section-update-checker" class="section">
73 - <x-component path="settings/external/update_checker.html"></x-component>
74 - </div>
75 -
66 <!-- Tunnel section content -->
67 <div id="section-tunnel" class="section">
68 <x-component path="settings/tunnel/tunnel-section.html"></x-component>
webui/components/settings/external/self-update-modal.html
+155 -26
@@ -14,26 +14,30 @@
14 x-destroy="$store.selfUpdateStore.cleanup()"
15 class="self-update-modal"
16 >
17 - <div class="self-update-copy">
18 - <p>
19 - Agent Zero saves this request into
20 - <code x-text="$store.selfUpdateStore.info?.paths?.update_file || '/exe/a0-self-update.yaml'"></code>,
21 - restarts once, applies the requested branch and release tag before the UI
22 - starts again, then reloads this page when <code>/api/health</code> is healthy.
23 - </p>
24 - <p>
25 - If the updated UI does not become healthy within 2 minutes, the bootstrap
26 - manager in <code>/exe</code> restores the previous checkout and starts that
27 - version again, so even an older downgraded <code>/a0</code> can be upgraded back
28 - by creating the YAML file manually.
29 - </p>
30 - </div>
17 + <details class="self-update-howto">
18 + <summary>How it works?</summary>
19 + <div class="self-update-copy">
20 + <p>
21 + Agent Zero saves this request into
22 + <code x-text="$store.selfUpdateStore.info?.paths?.update_file || '/exe/a0-self-update.yaml'"></code>,
23 + restarts once, applies the requested branch and release tag before the UI
24 + starts again, then reloads this page when <code>/api/health</code> is healthy.
25 + </p>
26 + <p>
27 + If the updated UI does not become healthy within 2 minutes, the bootstrap
28 + manager in <code>/exe</code> restores the previous checkout and starts that
29 + version again, so even an older downgraded <code>/a0</code> can be upgraded back
30 + by creating the YAML file manually.
31 + </p>
32 + </div>
33 + </details>
34
35 <div class="self-update-summary-grid">
36 <div class="self-update-summary-card">
37 <div class="summary-label">Current version</div>
38 <div class="summary-value" x-text="$store.selfUpdateStore.currentVersion"></div>
39 <div class="summary-meta" x-text="$store.selfUpdateStore.info?.current?.short_commit || ''"></div>
40 + <div class="summary-meta" x-text="$store.selfUpdateStore.currentBranch || ''"></div>
41 </div>
42
43 <div class="self-update-summary-card" x-show="$store.selfUpdateStore.info?.pending">
@@ -94,6 +98,7 @@
98 <div class="field-control">
99 <select
100 x-model="$store.selfUpdateStore.form.branch"
101 + @change="$store.selfUpdateStore.onBranchChanged()"
102 :disabled="$store.selfUpdateStore.isBusy"
103 >
104 <template x-for="branch in ($store.selfUpdateStore.info?.branches || [])" :key="branch.value">
@@ -107,28 +112,52 @@
112 <div class="field-label">
113 <div class="field-title">Target release tag</div>
114 <div class="field-description">
110 - Start typing or pick one of the tags fetched from the official repo. Downgrades are allowed.
115 + Start typing to filter, or use the search button to fetch tags from the selected branch. Exact text entry is still allowed, including downgrades.
116 </div>
112 - <template x-if="$store.selfUpdateStore.info?.available_tags_error">
117 + <template x-if="$store.selfUpdateStore.tagsError">
118 <div class="field-description">
119 Tag lookup failed:
115 - <span x-text="$store.selfUpdateStore.info?.available_tags_error"></span>
120 + <span x-text="$store.selfUpdateStore.tagsError"></span>
121 </div>
122 </template>
123 </div>
119 - <div class="field-control">
124 + <div class="field-control self-update-tag-search" @click.outside="$store.selfUpdateStore.closeTagDropdown()">
125 <input
126 type="text"
122 - list="self-update-tag-list"
127 x-model="$store.selfUpdateStore.form.tag"
128 + @input="$store.selfUpdateStore.onTagInput()"
129 + @focus="$store.selfUpdateStore.openTagDropdown()"
130 + @keydown.enter.prevent="$store.selfUpdateStore.searchTags()"
131 placeholder="v0.9.0"
132 :disabled="$store.selfUpdateStore.isBusy"
133 + style="padding-right: 32px;"
134 />
127 - <datalist id="self-update-tag-list">
128 - <template x-for="tag in $store.selfUpdateStore.availableTags" :key="tag">
129 - <option :value="tag"></option>
135 + <span
136 + class="material-symbols-outlined self-update-tag-search-btn"
137 + :class="{ 'spinning': $store.selfUpdateStore.tagsLoading }"
138 + @click="if (!$store.selfUpdateStore.isBusy) $store.selfUpdateStore.searchTags()"
139 + title="Search tags from selected branch"
140 + x-text="$store.selfUpdateStore.tagsLoading ? 'progress_activity' : 'search'"
141 + ></span>
142 + <div
143 + class="self-update-tag-results"
144 + x-show="$store.selfUpdateStore.tagDropdownOpen"
145 + x-transition.opacity
146 + >
147 + <template x-if="$store.selfUpdateStore.tagsLoading">
148 + <div class="self-update-tag-item disabled">Loading tags...</div>
149 + </template>
150 + <template x-if="!$store.selfUpdateStore.tagsLoading && $store.selfUpdateStore.tagSuggestions.length === 0">
151 + <div class="self-update-tag-item disabled">No matching tags found</div>
152 </template>
131 - </datalist>
153 + <template x-for="tag in $store.selfUpdateStore.tagSuggestions" :key="tag">
154 + <div
155 + class="self-update-tag-item"
156 + @click="$store.selfUpdateStore.selectTag(tag)"
157 + x-text="tag"
158 + ></div>
159 + </template>
160 + </div>
161 </div>
162 </div>
163
@@ -172,7 +201,7 @@
201 <div class="field-label">
202 <div class="field-title">Backup filename</div>
203 <div class="field-description">
175 - The manager normalizes this into a safe <code>.zip</code> filename.
204 + The manager normalizes this into a safe <code>.zip</code> filename. Leave it as-is for the default <code>usr-timestamp.zip</code> format.
205 </div>
206 </div>
207 <div class="field-control">
@@ -220,8 +249,12 @@
249 <div class="self-update-loading" x-show="$store.selfUpdateStore.loading">
250 Loading update status...
251 </div>
223 - <div class="self-update-loading" x-show="$store.selfUpdateStore.restarting">
224 - Restarting Agent Zero and waiting for the health check...
252 + <div class="self-update-progress-state" x-show="$store.selfUpdateStore.restarting">
253 + <div class="self-update-progress-spinner"></div>
254 + <div>
255 + <div class="self-update-progress-title" x-text="$store.selfUpdateStore.restartStatusText || 'Update in progress'"></div>
256 + <div class="self-update-progress-copy" x-text="$store.selfUpdateStore.restartDetailText || 'Waiting for Agent Zero to restart and become healthy again.'"></div>
257 + </div>
258 </div>
259
260 <div class="modal-footer" data-modal-footer>
@@ -258,6 +291,51 @@
291 gap: 1rem;
292 }
293
294 + .self-update-tag-search {
295 + position: relative;
296 + }
297 +
298 + .self-update-tag-search-btn {
299 + position: absolute;
300 + right: 0.5rem;
301 + top: 0.55rem;
302 + cursor: pointer;
303 + color: var(--color-text-secondary, #777);
304 + display: inline-flex;
305 + align-items: center;
306 + justify-content: center;
307 + }
308 +
309 + .self-update-tag-results {
310 + position: absolute;
311 + inset: calc(100% + 0.35rem) 0 auto 0;
312 + max-height: 14rem;
313 + overflow-y: auto;
314 + z-index: 20;
315 + border: 1px solid var(--color-border, #ddd);
316 + border-radius: 0.75rem;
317 + background: var(--color-surface, #fff);
318 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
319 + }
320 +
321 + .self-update-tag-item {
322 + padding: 0.65rem 0.85rem;
323 + cursor: pointer;
324 + }
325 +
326 + .self-update-tag-item:hover {
327 + background: var(--color-panel-bg, rgba(0, 0, 0, 0.04));
328 + }
329 +
330 + .self-update-tag-item.disabled {
331 + cursor: default;
332 + color: var(--color-text-secondary, #777);
333 + }
334 +
335 + .self-update-tag-item.disabled:hover {
336 + background: transparent;
337 + }
338 +
339 .self-update-copy {
340 color: var(--color-text-secondary);
341 line-height: 1.5;
@@ -267,6 +345,57 @@
345 margin: 0 0 0.75rem;
346 }
347
348 + .self-update-howto {
349 + border: 1px solid var(--color-border, #ddd);
350 + border-radius: 0.9rem;
351 + background: var(--color-surface, #fff);
352 + padding: 0.9rem 1rem;
353 + }
354 +
355 + .self-update-howto summary {
356 + cursor: pointer;
357 + font-weight: 700;
358 + list-style: none;
359 + }
360 +
361 + .self-update-howto summary::-webkit-details-marker {
362 + display: none;
363 + }
364 +
365 + .self-update-howto[open] summary {
366 + margin-bottom: 0.75rem;
367 + }
368 +
369 + .self-update-progress-state {
370 + display: flex;
371 + align-items: center;
372 + gap: 0.9rem;
373 + padding: 1rem 1.1rem;
374 + border-radius: 0.9rem;
375 + background: rgba(37, 99, 235, 0.08);
376 + color: var(--color-text, #111827);
377 + }
378 +
379 + .self-update-progress-spinner {
380 + width: 1.5rem;
381 + height: 1.5rem;
382 + flex: 0 0 auto;
383 + border-radius: 999px;
384 + border: 3px solid rgba(37, 99, 235, 0.18);
385 + border-top-color: var(--color-primary, #2563eb);
386 + animation: spin 1s linear infinite;
387 + }
388 +
389 + .self-update-progress-title {
390 + font-weight: 700;
391 + margin-bottom: 0.25rem;
392 + }
393 +
394 + .self-update-progress-copy {
395 + color: var(--color-text-secondary, #475569);
396 + line-height: 1.45;
397 + }
398 +
399 .self-update-summary-grid {
400 display: grid;
401 gap: 0.75rem;
webui/components/settings/external/self-update-store.js
+232 -4
@@ -4,13 +4,22 @@ import { store as notificationStore } from "/components/notifications/notificati
4
5 const HEALTH_POLL_INTERVAL_MS = 2000;
6 const HEALTH_WAIT_BUFFER_MS = 30000;
7 +const TAG_SEARCH_DEBOUNCE_MS = 250;
8 +const SELF_UPDATE_RETURN_URL_KEY = "a0:self-update:return-url";
9 +const SELF_UPDATE_OVERLAY_ID = "self-update-progress-overlay";
10
11 const model = {
12 loading: false,
13 saving: false,
14 restarting: false,
15 + tagsLoading: false,
16 error: "",
17 + tagsError: "",
18 info: null,
19 + tagSuggestions: [],
20 + tagDropdownOpen: false,
21 + restartStatusText: "",
22 + restartDetailText: "",
23 form: {
24 branch: "main",
25 tag: "",
@@ -20,6 +29,8 @@ const model = {
29 backup_conflict_policy: "rename",
30 },
31 _reconnectTimer: null,
32 + _tagSearchTimer: null,
33 + _tagRequestId: 0,
34
35 get isBusy() {
36 return this.loading || this.saving || this.restarting;
@@ -33,8 +44,8 @@ const model = {
44 return this.info?.current?.short_tag || "unknown";
45 },
46
36 - get availableTags() {
37 - return Array.isArray(this.info?.available_tags) ? this.info.available_tags : [];
47 + get currentBranch() {
48 + return this.info?.current?.branch || "";
49 },
50
51 async init() {
@@ -43,10 +54,17 @@ const model = {
54
55 cleanup() {
56 this.clearReconnectTimer();
57 + this.clearTagSearchTimer();
58 this.error = "";
59 + this.tagsError = "";
60 this.loading = false;
61 this.saving = false;
62 this.restarting = false;
63 + this.tagsLoading = false;
64 + this.tagDropdownOpen = false;
65 + this.restartStatusText = "";
66 + this.restartDetailText = "";
67 + this.removeProgressOverlay();
68 },
69
70 clearReconnectTimer() {
@@ -56,6 +74,13 @@ const model = {
74 }
75 },
76
77 + clearTagSearchTimer() {
78 + if (this._tagSearchTimer) {
79 + clearTimeout(this._tagSearchTimer);
80 + this._tagSearchTimer = null;
81 + }
82 + },
83 +
84 formatTimestamp(value) {
85 if (!value) return "";
86 try {
@@ -69,6 +94,123 @@ const model = {
94 return `${branch || "main"} / ${tag || "None"}`;
95 },
96
97 + getSavedReturnUrl() {
98 + try {
99 + return sessionStorage.getItem(SELF_UPDATE_RETURN_URL_KEY) || "";
100 + } catch {
101 + return "";
102 + }
103 + },
104 +
105 + saveReturnUrl(url = "") {
106 + try {
107 + if (url) {
108 + sessionStorage.setItem(SELF_UPDATE_RETURN_URL_KEY, url);
109 + } else {
110 + sessionStorage.removeItem(SELF_UPDATE_RETURN_URL_KEY);
111 + }
112 + } catch {
113 + // ignore storage errors
114 + }
115 + },
116 +
117 + getProgressOverlay() {
118 + return document.getElementById(SELF_UPDATE_OVERLAY_ID);
119 + },
120 +
121 + ensureProgressOverlay() {
122 + let overlay = this.getProgressOverlay();
123 + if (!overlay) {
124 + overlay = document.createElement("div");
125 + overlay.id = SELF_UPDATE_OVERLAY_ID;
126 + overlay.innerHTML = `
127 + <div class="self-update-progress-card">
128 + <div class="self-update-progress-spinner"></div>
129 + <div class="self-update-progress-title"></div>
130 + <div class="self-update-progress-detail"></div>
131 + </div>
132 + `;
133 + Object.assign(overlay.style, {
134 + position: "fixed",
135 + inset: "0",
136 + zIndex: "10000",
137 + display: "flex",
138 + alignItems: "center",
139 + justifyContent: "center",
140 + padding: "1.5rem",
141 + background: "rgba(15, 23, 42, 0.42)",
142 + backdropFilter: "blur(6px)",
143 + });
144 + document.body.appendChild(overlay);
145 +
146 + const style = document.createElement("style");
147 + style.id = `${SELF_UPDATE_OVERLAY_ID}-styles`;
148 + style.textContent = `
149 + #${SELF_UPDATE_OVERLAY_ID} .self-update-progress-card {
150 + width: min(28rem, calc(100vw - 2rem));
151 + border-radius: 1rem;
152 + background: var(--color-surface, #ffffff);
153 + color: var(--color-text, #111827);
154 + box-shadow: 0 24px 64px rgba(15, 23, 42, 0.22);
155 + padding: 1.5rem;
156 + text-align: center;
157 + }
158 + #${SELF_UPDATE_OVERLAY_ID} .self-update-progress-spinner {
159 + width: 2.5rem;
160 + height: 2.5rem;
161 + margin: 0 auto 1rem;
162 + border-radius: 999px;
163 + border: 3px solid rgba(15, 23, 42, 0.12);
164 + border-top-color: var(--color-primary, #2563eb);
165 + animation: self-update-spin 1s linear infinite;
166 + }
167 + #${SELF_UPDATE_OVERLAY_ID} .self-update-progress-title {
168 + font-size: 1.05rem;
169 + font-weight: 700;
170 + margin-bottom: 0.5rem;
171 + }
172 + #${SELF_UPDATE_OVERLAY_ID} .self-update-progress-detail {
173 + color: var(--color-text-secondary, #475569);
174 + line-height: 1.5;
175 + }
176 + @keyframes self-update-spin {
177 + from { transform: rotate(0deg); }
178 + to { transform: rotate(360deg); }
179 + }
180 + `;
181 + document.head.appendChild(style);
182 + }
183 + this.updateProgressOverlay();
184 + },
185 +
186 + updateProgressOverlay() {
187 + const overlay = this.getProgressOverlay();
188 + if (!overlay) return;
189 + const title = overlay.querySelector(".self-update-progress-title");
190 + const detail = overlay.querySelector(".self-update-progress-detail");
191 + if (title) {
192 + title.textContent = this.restartStatusText || "Applying self-update";
193 + }
194 + if (detail) {
195 + detail.textContent =
196 + this.restartDetailText ||
197 + "Agent Zero is restarting, applying the requested release, and will reload this page when the health check responds again.";
198 + }
199 + },
200 +
201 + removeProgressOverlay() {
202 + this.getProgressOverlay()?.remove();
203 + document.getElementById(`${SELF_UPDATE_OVERLAY_ID}-styles`)?.remove();
204 + },
205 +
206 + setRestartState(statusText, detailText = "") {
207 + this.restartStatusText = statusText;
208 + this.restartDetailText = detailText;
209 + if (this.restarting) {
210 + this.ensureProgressOverlay();
211 + }
212 + },
213 +
214 async refresh() {
215 this.loading = true;
216 this.error = "";
@@ -79,6 +221,7 @@ const model = {
221 }
222 this.info = response;
223 this.applyFormState(response.pending || response.defaults || {});
224 + await this.searchTags({ openResults: false });
225 } catch (error) {
226 console.error("Failed to load self-update info:", error);
227 this.error = error.message || "Failed to load self-update info.";
@@ -98,6 +241,71 @@ const model = {
241 source?.backup_conflict_policy || "rename";
242 },
243
244 + closeTagDropdown() {
245 + this.tagDropdownOpen = false;
246 + },
247 +
248 + openTagDropdown() {
249 + this.tagDropdownOpen = true;
250 + },
251 +
252 + async onBranchChanged() {
253 + this.openTagDropdown();
254 + await this.searchTags();
255 + },
256 +
257 + onTagInput() {
258 + this.openTagDropdown();
259 + this.scheduleTagSearch();
260 + },
261 +
262 + scheduleTagSearch() {
263 + this.clearTagSearchTimer();
264 + this._tagSearchTimer = setTimeout(() => {
265 + this._tagSearchTimer = null;
266 + void this.searchTags();
267 + }, TAG_SEARCH_DEBOUNCE_MS);
268 + },
269 +
270 + async searchTags({ openResults = true } = {}) {
271 + const requestId = ++this._tagRequestId;
272 + this.tagsLoading = true;
273 + this.tagsError = "";
274 +
275 + try {
276 + const response = await API.callJsonApi("self_update_tags", {
277 + branch: this.form.branch,
278 + query: this.form.tag,
279 + });
280 + if (!response?.success) {
281 + throw new Error(response?.error || "Failed to fetch release tags.");
282 + }
283 + if (requestId !== this._tagRequestId) {
284 + return;
285 + }
286 + this.tagSuggestions = Array.isArray(response?.tags) ? response.tags : [];
287 + this.tagsError = response?.error || "";
288 + this.tagDropdownOpen = openResults;
289 + } catch (error) {
290 + console.error("Failed to fetch self-update tags:", error);
291 + if (requestId !== this._tagRequestId) {
292 + return;
293 + }
294 + this.tagSuggestions = [];
295 + this.tagsError = error.message || "Failed to fetch release tags.";
296 + this.tagDropdownOpen = openResults;
297 + } finally {
298 + if (requestId === this._tagRequestId) {
299 + this.tagsLoading = false;
300 + }
301 + }
302 + },
303 +
304 + selectTag(tag) {
305 + this.form.tag = tag;
306 + this.tagDropdownOpen = false;
307 + },
308 +
309 async scheduleUpdate() {
310 if (!this.form.branch?.trim()) {
311 this.error = "Choose a branch.";
@@ -135,6 +343,7 @@ const model = {
343 undefined,
344 true,
345 );
346 + this.saveReturnUrl(window.location.href);
347 await this.restartAndReload();
348 } catch (error) {
349 console.error("Failed to schedule self-update:", error);
@@ -147,14 +356,25 @@ const model = {
356 async restartAndReload() {
357 this.restarting = true;
358 this.clearReconnectTimer();
359 + this.setRestartState(
360 + "Starting self-update",
361 + "The request was saved. Agent Zero is about to restart and apply the requested branch and tag."
362 + );
363 + this.ensureProgressOverlay();
364
365 try {
152 - await API.fetchApi("/restart", {
366 + const token = await API.getCsrfToken();
367 + void fetch("/api/restart", {
368 method: "POST",
369 + credentials: "same-origin",
370 + keepalive: true,
371 headers: {
372 "Content-Type": "application/json",
373 + "X-CSRF-Token": token,
374 },
375 body: JSON.stringify({}),
376 + }).catch(() => {
377 + // The restart request usually terminates the backend mid-flight.
378 });
379 } catch (_error) {
380 // The restart request often terminates the backend mid-flight.
@@ -168,6 +388,10 @@ const model = {
388 HEALTH_WAIT_BUFFER_MS;
389 const deadline = Date.now() + maxWaitMs;
390 let lastError = "";
391 + this.setRestartState(
392 + "Update in progress",
393 + "Agent Zero is restarting and the updater is running. This page will reload automatically when /api/health starts responding again."
394 + );
395
396 while (Date.now() < deadline) {
397 try {
@@ -177,7 +401,9 @@ const model = {
401 cache: "no-store",
402 });
403 if (response.ok) {
180 - window.location.reload();
404 + const returnUrl = this.getSavedReturnUrl() || window.location.href;
405 + this.saveReturnUrl("");
406 + window.location.replace(returnUrl);
407 return;
408 }
409 lastError = `Health check returned HTTP ${response.status}.`;
@@ -194,6 +420,8 @@ const model = {
420 }
421
422 this.restarting = false;
423 + this.removeProgressOverlay();
424 + this.saveReturnUrl("");
425 this.error =
426 "Agent Zero did not come back within the expected window. It may still be rolling back. " +
427 (lastError ? `Last health check error: ${lastError}` : "");
webui/components/settings/external/update_checker.html
-26
@@ -24,32 +24,6 @@
24 </label>
25 </div>
26 </div>
27 -
28 - <div class="field">
29 - <div class="field-label">
30 - <div class="field-title">Manual Self Update</div>
31 - <div class="field-description">
32 - Schedule a branch plus release tag, optionally zip <code>/a0/usr</code>,
33 - restart Agent Zero, and let the durable bootstrap manager in
34 - <code>/exe</code> either finish the update or automatically roll back if
35 - the UI never becomes healthy.
36 - </div>
37 - <template x-if="!$store.settings.additional?.is_dockerized">
38 - <div class="field-description">
39 - This action is currently available only in dockerized installs.
40 - </div>
41 - </template>
42 - </div>
43 - <div class="field-control">
44 - <button
45 - class="btn btn-field"
46 - @click="openModal('settings/external/self-update-modal.html')"
47 - :disabled="!$store.settings.additional?.is_dockerized"
48 - >
49 - Open Self Update
50 - </button>
51 - </div>
52 - </div>
27 </div>
28 </template>
29 </div>
webui/components/settings/settings.html
+1 -2
@@ -53,7 +53,7 @@
53 <div class="settings-tab"
54 :class="{'active': $store.settings.activeTab === 'backup'}"
55 @click="$store.settings.switchTab('backup')"
56 - title="Backup & Restore">Backup & Restore</div>
56 + title="Update">Update</div>
57 </div>
58 </div>
59
@@ -139,4 +139,3 @@
139 to { transform: rotate(360deg); }
140 }
141 </style>
142 -