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&&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&&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] ? '••••••••••••' : ''"
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
-