Add unzip to Docker base packages, refactor API key handling, improve self-update system

- Add unzip package to Docker base installation - Remove deprecated missing API key banner extension - Refactor API key management: clear drafts on init, track dirty state, allow empty string saves - Add version filtering to self-update: enforce minimum v0.9.9, validate tag format - Improve self-update UI: convert howto to Bootstrap collapse, fix branch selector reactivity, group tag suggestions - Fix settings

frdel committed Mar 24, 2026 at 17:17 UTC 75b8085b65c88986818f316f9b0ca06171aa80a9
12 files changed +480 -91
docker/base/fs/ins/install_base_packages1.sh
+1 -1
@@ -6,6 +6,6 @@ echo "====================BASE PACKAGES1 START===================="
6 apt-get update && apt-get upgrade -y
7
8 apt-get install -y --no-install-recommends \
9 - sudo curl wget git cron
9 + sudo curl wget git cron unzip
10
11 echo "====================BASE PACKAGES1 END===================="
docs/guides/self-update.md new
+58
@@ -0,0 +1,58 @@
1 +# Self Update
2 +
3 +Agent Zero includes a Docker-oriented self-update flow for switching to a specific repository version tag on `main`, `testing`, or `development`.
4 +
5 +## How it works
6 +
7 +1. The WebUI writes a YAML request file outside `/a0` so the request survives upgrades and downgrades.
8 +2. Agent Zero restarts.
9 +3. The durable updater in `/exe` reads the YAML request before starting the UI.
10 +4. If requested, it creates a zip backup of `/a0/usr`.
11 +5. It fetches the requested branch and version tag from the official Agent Zero repository.
12 +6. It updates `/a0` while preserving gitignored paths such as `/a0/usr`.
13 +7. It starts Agent Zero again and waits for `/api/health` to become healthy.
14 +8. If the UI does not become healthy within the allowed time, it restores the previous checkout and starts that version again.
15 +
16 +## Durable files
17 +
18 +The self-update flow stores its runtime files outside `/a0`:
19 +
20 +- Trigger file: `/exe/a0-self-update.yaml`
21 +- Status file: `/exe/a0-self-update-status.yaml`
22 +- Last attempt log: `/exe/a0-self-update.log`
23 +
24 +Because these files live in `/exe`, you can recover from an older downgraded `/a0` by creating a new update YAML manually.
25 +
26 +## Backup behavior
27 +
28 +The updater can create a zip backup of `/a0/usr` before replacing repository files.
29 +
30 +- The default backup directory is `/a0/tmp/self-update-backups`
31 +- The default file name format is `usr-YYYYMMDD-HHMMSS.zip`
32 +- Conflict handling supports rename, overwrite, or fail-before-restart
33 +
34 +## Version selection
35 +
36 +The WebUI fetches repository version tags for the selected branch and lets you enter any exact version manually, including downgrades.
37 +
38 +Agent Zero version tags follow this format:
39 +
40 +`v{epoch}.{major}.{minor}.{rest}`
41 +
42 +Examples:
43 +
44 +- `v0.9.9.2`
45 +- `v1.0.3.0`
46 +
47 +## Major version limitation
48 +
49 +Self-update is intentionally limited to changes within the same `epoch.major` line.
50 +
51 +If the requested version changes the `epoch` or `major` part of the version, the UI blocks the update and shows a warning. Those upgrades require downloading a new Docker image because they can include operating system level changes or other breaking changes outside the repository checkout.
52 +
53 +## Safety notes
54 +
55 +- Gitignored paths are preserved during update
56 +- Obsolete tracked files are removed as part of the checkout replacement
57 +- Rollback is automatic when the updated UI fails its health check
58 +- The updater itself lives outside `/a0`, so it is not lost by downgrading to an older repository state
helpers/self_update.py
+31
@@ -19,6 +19,7 @@ BRANCH_OPTIONS = [
19 ]
20 SUPPORTED_BRANCHES = {option["value"] for option in BRANCH_OPTIONS}
21 BACKUP_CONFLICT_POLICIES = {"rename", "overwrite", "fail"}
22 +MIN_SELECTOR_VERSION = (0, 9, 9)
23
24 UPDATE_FILE_PATH = Path("/exe/a0-self-update.yaml")
25 STATUS_FILE_PATH = Path("/exe/a0-self-update-status.yaml")
@@ -206,6 +207,30 @@ def _get_branch_merged_tags(
207 return set()
208
209
210 +def _parse_selector_version(tag: str) -> tuple[int, int, int] | None:
211 + match = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)(?:\..+)?", tag.strip())
212 + if not match:
213 + return None
214 + return (
215 + int(match.group(1)),
216 + int(match.group(2)),
217 + int(match.group(3)),
218 + )
219 +
220 +
221 +def _is_selector_supported_tag(tag: str) -> bool:
222 + parsed = _parse_selector_version(tag)
223 + return parsed is not None and parsed >= MIN_SELECTOR_VERSION
224 +
225 +
226 +def _filter_selector_supported_tags(tags: list[str]) -> list[str]:
227 + return [tag for tag in tags if _is_selector_supported_tag(tag)]
228 +
229 +
230 +def is_valid_selector_tag(tag: str) -> bool:
231 + return _parse_selector_version(tag) is not None
232 +
233 +
234 def get_available_tags(
235 branch: str | None = None,
236 *,
@@ -222,6 +247,8 @@ def get_available_tags(
247 if merged_tags:
248 tags = [tag for tag in tags if tag in merged_tags]
249
250 + tags = _filter_selector_supported_tags(tags)
251 +
252 normalized_query = query.strip().lower()
253 if normalized_query:
254 tags = [tag for tag in tags if normalized_query in tag.lower()]
@@ -280,6 +307,10 @@ def schedule_update(
307 normalized_tag = tag.strip()
308 if not normalized_tag:
309 raise ValueError("A release tag is required.")
310 + if not is_valid_selector_tag(normalized_tag):
311 + raise ValueError(
312 + "Release tag must use the format vX.Y.Z with optional extra segments such as .W or .W-suffix."
313 + )
314
315 normalized_policy = backup_conflict_policy.strip().lower()
316 if normalized_policy not in BACKUP_CONFLICT_POLICIES:
plugins/_model_config/extensions/python/banners/_20_missing_api_key.py renamed
plugins/_model_config/webui/api-keys.html
+2 -3
@@ -22,6 +22,7 @@
22 },
23 async init() {
24 await $store.modelConfig.ensureLoaded();
25 + $store.modelConfig.resetApiKeyDrafts();
26 await $store.modelConfig.refreshApiKeyStatus();
27 $store.modelConfig.allProviders.forEach((provider) => {
28 this.keys[provider.value] = '';
@@ -50,9 +51,7 @@
51 const updates = {};
52 for (const provider of Object.keys(this.touched)) {
53 if (!this.touched[provider]) continue;
53 - const value = (this.keys[provider] || '').trim();
54 - if (!value) continue;
55 - updates[provider] = value;
54 + updates[provider] = this.keys[provider] || '';
55 }
56 await $store.modelConfig.saveApiKeys(updates);
57 await $store.modelConfig.refreshApiKeyStatus();
plugins/_model_config/webui/config.html
+2
@@ -10,6 +10,7 @@
10 <div x-data
11 x-init="
12 await $store.modelConfig.ensureLoaded();
13 + $store.modelConfig.resetApiKeyDrafts();
14 await $store.modelConfig.refreshApiKeyStatus();
15 $store.modelConfig.initConfigFields(config);
16 $store.modelConfig.installPluginSettingsSaveHook(context, config);
@@ -119,6 +120,7 @@
120 x-model="$store.modelConfig.apiKeyValues[config[section.key].provider]"
121 :placeholder="$store.modelConfig.apiKeyStatus[config[section.key].provider] ? '&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;&#x2022;' : ''"
122 autocomplete="off"
123 + @input="$store.modelConfig.touchApiKey(config[section.key].provider)"
124 style="padding-right:32px;" />
125 <span class="material-symbols-outlined eye-toggle"
126 @click="
plugins/_model_config/webui/model-config-store.js
+50 -12
@@ -52,6 +52,7 @@ export const store = createStore("modelConfig", {
52 embeddingProviders: [],
53 apiKeyStatus: {},
54 apiKeyValues: {},
55 + apiKeyDirty: {},
56 allProviders: [],
57 _loaded: false,
58
@@ -81,6 +82,19 @@ export const store = createStore("modelConfig", {
82 if (!(provider in this.apiKeyValues)) {
83 this.apiKeyValues = { ...this.apiKeyValues, [provider]: '' };
84 }
85 + if (!(provider in this.apiKeyDirty)) {
86 + this.apiKeyDirty = { ...this.apiKeyDirty, [provider]: false };
87 + }
88 + },
89 +
90 + _setApiKeyDirty(provider, isDirty) {
91 + if (!provider) return;
92 + this._ensureApiKeySlot(provider);
93 + this.apiKeyDirty = { ...this.apiKeyDirty, [provider]: !!isDirty };
94 + },
95 +
96 + touchApiKey(provider) {
97 + this._setApiKeyDirty(provider, true);
98 },
99
100 _normalizePresets(rawPresets) {
@@ -98,13 +112,16 @@ export const store = createStore("modelConfig", {
112 this.embeddingProviders = data.embedding_providers || [];
113 this.apiKeyStatus = data.api_key_status || {};
114 const keys = {};
115 + const dirty = {};
116 const seen = new Set();
117 for (const p of [...this.chatProviders, ...this.embeddingProviders]) {
118 if (!p.value || seen.has(p.value)) continue;
119 seen.add(p.value);
120 if (!(p.value in keys)) keys[p.value] = '';
121 + if (!(p.value in dirty)) dirty[p.value] = false;
122 }
123 this.apiKeyValues = keys;
124 + this.apiKeyDirty = dirty;
125
126 const allProviders = [];
127 const provSeen = new Set();
@@ -131,23 +148,40 @@ export const store = createStore("modelConfig", {
148
149 const nextStatus = { ...this.apiKeyStatus };
150 const nextValues = { ...this.apiKeyValues };
151 + const nextDirty = { ...this.apiKeyDirty };
152
153 for (const provider of this.allProviders) {
154 const entry = keys[provider.value] || {};
155 const hasKey = !!entry.has_key;
156 nextStatus[provider.value] = hasKey;
157 provider.has_key = hasKey;
140 - if (!hasKey && !nextValues[provider.value]) {
158 + if (!(provider.value in nextDirty)) {
159 + nextDirty[provider.value] = false;
160 + }
161 + if (!hasKey && !nextDirty[provider.value]) {
162 nextValues[provider.value] = '';
163 }
164 }
165
166 this.apiKeyStatus = nextStatus;
167 this.apiKeyValues = nextValues;
168 + this.apiKeyDirty = nextDirty;
169 this.allProviders = [...this.allProviders];
170 return keys;
171 },
172
173 + resetApiKeyDrafts() {
174 + const nextValues = {};
175 + const nextDirty = {};
176 + for (const provider of this.allProviders || []) {
177 + if (!provider?.value) continue;
178 + nextValues[provider.value] = '';
179 + nextDirty[provider.value] = false;
180 + }
181 + this.apiKeyValues = nextValues;
182 + this.apiKeyDirty = nextDirty;
183 + },
184 +
185 async _fetchConfigData() {
186 const res = await fetchApi(`${API_BASE}/model_config_get`, {
187 method: 'POST',
@@ -229,8 +263,7 @@ export const store = createStore("modelConfig", {
263 const normalized = {};
264 for (const [provider, value] of Object.entries(updates || {})) {
265 if (!provider || typeof value !== 'string') continue;
232 - if (!value.trim()) continue;
233 - normalized[provider] = value;
266 + normalized[provider] = value.trim() ? value : '';
267 }
268
269 if (Object.keys(normalized).length === 0) {
@@ -248,11 +281,14 @@ export const store = createStore("modelConfig", {
281 }
282
283 const nextValues = { ...this.apiKeyValues };
284 + const nextDirty = { ...this.apiKeyDirty };
285 for (const [provider, value] of Object.entries(normalized)) {
286 nextValues[provider] = value;
253 - this._setProviderHasKey(provider, true);
287 + nextDirty[provider] = false;
288 + this._setProviderHasKey(provider, !!value.trim());
289 }
290 this.apiKeyValues = nextValues;
291 + this.apiKeyDirty = nextDirty;
292 return data;
293 },
294
@@ -261,8 +297,9 @@ export const store = createStore("modelConfig", {
297 },
298
299 saveApiKeyIfSet(provider) {
264 - const val = this.apiKeyValues[provider];
265 - if (val) return this.saveApiKey(provider, val);
300 + if (provider in this.apiKeyValues) {
301 + return this.saveApiKey(provider, this.apiKeyValues[provider] || '');
302 + }
303 },
304
305 async revealApiKey(provider) {
@@ -276,23 +313,24 @@ export const store = createStore("modelConfig", {
313 throw new Error(data?.error || 'Failed to load API key.');
314 }
315 const value = data.value || '';
279 - if (provider && value) {
316 + if (provider) {
317 this._ensureApiKeySlot(provider);
318 this.apiKeyValues = { ...this.apiKeyValues, [provider]: value };
282 - this._setProviderHasKey(provider, true);
319 + this._setApiKeyDirty(provider, false);
320 + this._setProviderHasKey(provider, !!value.trim());
321 }
322 return value;
323 },
324
325 async persistApiKeysForConfig(config) {
326 const updates = {};
327 + const seen = new Set();
328 for (const section of this.MODEL_SECTIONS) {
329 const provider = config?.[section.key]?.provider;
291 - if (!provider) continue;
330 + if (!provider || seen.has(provider) || !this.apiKeyDirty[provider]) continue;
331 + seen.add(provider);
332 const value = this.apiKeyValues[provider];
293 - if (typeof value === 'string' && value.trim()) {
294 - updates[provider] = value;
295 - }
333 + updates[provider] = typeof value === 'string' ? value : '';
334 }
335 return this.saveApiKeys(updates);
336 },
tests/test_model_config_api_keys.py new
+85
@@ -0,0 +1,85 @@
1 +import sys
2 +import threading
3 +import types
4 +from pathlib import Path
5 +
6 +from flask import Flask
7 +
8 +
9 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
10 +if str(PROJECT_ROOT) not in sys.path:
11 + sys.path.insert(0, str(PROJECT_ROOT))
12 +
13 +sys.modules["giturlparse"] = types.SimpleNamespace(parse=lambda *args, **kwargs: None)
14 +
15 +
16 +class _DummyObserver:
17 + def __init__(self):
18 + self._alive = False
19 +
20 + def is_alive(self):
21 + return self._alive
22 +
23 + def start(self):
24 + self._alive = True
25 +
26 + def stop(self):
27 + self._alive = False
28 +
29 + def join(self, *args, **kwargs):
30 + return None
31 +
32 + def unschedule_all(self):
33 + return None
34 +
35 + def schedule(self, *args, **kwargs):
36 + return None
37 +
38 +
39 +watchdog = types.ModuleType("watchdog")
40 +watchdog.observers = types.SimpleNamespace(Observer=_DummyObserver)
41 +watchdog.events = types.SimpleNamespace(FileSystemEventHandler=object)
42 +sys.modules["watchdog"] = watchdog
43 +sys.modules["watchdog.observers"] = watchdog.observers
44 +sys.modules["watchdog.events"] = watchdog.events
45 +
46 +from plugins._model_config.api.api_keys import ApiKeys
47 +import models
48 +
49 +
50 +def test_model_config_api_keys_can_be_cleared_via_backend(monkeypatch, tmp_path):
51 + from helpers import dotenv
52 +
53 + env_file = tmp_path / ".env"
54 + monkeypatch.setattr(dotenv, "get_dotenv_file_path", lambda: str(env_file))
55 +
56 + for key in ("API_KEY_OPENROUTER", "OPENROUTER_API_KEY", "OPENROUTER_API_TOKEN"):
57 + monkeypatch.delenv(key, raising=False)
58 +
59 + handler = ApiKeys(Flask(__name__), threading.Lock())
60 +
61 + assert handler._set_keys({"keys": {"openrouter": "sk-test-openrouter"}}) == {"ok": True}
62 + assert models.get_api_key("openrouter") == "sk-test-openrouter"
63 +
64 + assert handler._set_keys({"keys": {"openrouter": ""}}) == {"ok": True}
65 + assert models.get_api_key("openrouter") == "None"
66 + assert handler._reveal_key({"provider": "openrouter"}) == {"ok": True, "value": ""}
67 +
68 +
69 +def test_model_config_frontend_tracks_inline_api_key_edits():
70 + store_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "model-config-store.js"
71 + config_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "config.html"
72 + modal_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "api-keys.html"
73 +
74 + store_content = store_path.read_text(encoding="utf-8")
75 + config_content = config_path.read_text(encoding="utf-8")
76 + modal_content = modal_path.read_text(encoding="utf-8")
77 +
78 + assert "apiKeyDirty" in store_content
79 + assert "resetApiKeyDrafts()" in store_content
80 + assert "!provider || seen.has(provider) || !this.apiKeyDirty[provider]" in store_content
81 + assert "normalized[provider] = value.trim() ? value : '';" in store_content
82 + assert "$store.modelConfig.resetApiKeyDrafts();" in config_content
83 + assert '@input="$store.modelConfig.touchApiKey(config[section.key].provider)"' in config_content
84 + assert "updates[provider] = this.keys[provider] || '';" in modal_content
85 + assert "$store.modelConfig.resetApiKeyDrafts();" in modal_content
tests/test_self_update_tag_filter.py new
+39
@@ -0,0 +1,39 @@
1 +import sys
2 +import types
3 +from pathlib import Path
4 +
5 +
6 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
7 +if str(PROJECT_ROOT) not in sys.path:
8 + sys.path.insert(0, str(PROJECT_ROOT))
9 +
10 +sys.modules["giturlparse"] = types.SimpleNamespace(parse=lambda *args, **kwargs: None)
11 +
12 +from helpers import self_update
13 +
14 +
15 +def test_self_update_minimum_selector_tag_is_enforced():
16 + assert self_update.is_valid_selector_tag("v0.9.9")
17 + assert self_update._is_selector_supported_tag("v0.9.9.0")
18 + assert self_update._is_selector_supported_tag("v0.10.0.0")
19 + assert self_update.is_valid_selector_tag("v0.9.12.5-pre")
20 + assert not self_update.is_valid_selector_tag("0.9.9")
21 + assert not self_update.is_valid_selector_tag("v0.9")
22 + assert not self_update._is_selector_supported_tag("v0.9.8.9")
23 + assert not self_update._is_selector_supported_tag("v0.7.0.0")
24 +
25 +
26 +def test_self_update_frontend_filters_old_tag_suggestions():
27 + store_path = (
28 + PROJECT_ROOT
29 + / "webui"
30 + / "components"
31 + / "settings"
32 + / "external"
33 + / "self-update-store.js"
34 + )
35 + content = store_path.read_text(encoding="utf-8")
36 +
37 + assert "const MIN_SELECTOR_VERSION = [0, 9, 9];" in content
38 + assert "response.tags.filter((tag) => this.isSupportedSuggestionTag(tag))" in content
39 + assert "if (!this.parseSelectorTag(this.form.tag)) {" in content
webui/components/settings/backup/self-update.html
+6 -4
@@ -4,8 +4,9 @@
4 </head>
5
6 <body>
7 - <div x-data>
8 - <div>
7 + <div x-data="{ get settings() { return $store.settings.settings } }">
8 + <template x-if="settings">
9 + <div>
10 <div class="section-title">Self Update</div>
11
12 <div class="field">
@@ -40,12 +41,13 @@
41 </div>
42 <div class="field-control">
43 <label class="toggle">
43 - <input type="checkbox" x-model="$store.settings.settings.update_check_enabled" />
44 + <input type="checkbox" x-model="settings.update_check_enabled" />
45 <span class="toggler"></span>
46 </label>
47 </div>
48 </div>
48 - </div>
49 + </div>
50 + </template>
51 </div>
52 </body>
53 </html>
webui/components/settings/external/self-update-modal.html
+106 -43
@@ -14,23 +14,35 @@
14 x-destroy="$store.selfUpdateStore.cleanup()"
15 class="self-update-modal"
16 >
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>
17 + <div class="self-update-howto">
18 + <button
19 + type="button"
20 + class="self-update-howto-toggle"
21 + data-bs-toggle="collapse"
22 + data-bs-target="#self-update-howto-collapse"
23 + aria-expanded="false"
24 + aria-controls="self-update-howto-collapse"
25 + >
26 + <span>How it works?</span>
27 + <span class="material-symbols-outlined self-update-howto-toggle-icon">expand_more</span>
28 + </button>
29 + <div class="collapse" id="self-update-howto-collapse">
30 + <div class="self-update-howto-body self-update-copy">
31 + <p>
32 + Agent Zero saves this request into
33 + <code x-text="$store.selfUpdateStore.info?.paths?.update_file || '/exe/a0-self-update.yaml'"></code>,
34 + restarts once, applies the requested branch and release tag before the UI
35 + starts again, then reloads this page when <code>/api/health</code> is healthy.
36 + </p>
37 + <p>
38 + If the updated UI does not become healthy within 2 minutes, the bootstrap
39 + manager in <code>/exe</code> restores the previous checkout and starts that
40 + version again, so even an older downgraded <code>/a0</code> can be upgraded back
41 + by creating the YAML file manually.
42 + </p>
43 + </div>
44 </div>
33 - </details>
45 + </div>
46
47 <div class="self-update-summary-grid">
48 <div class="self-update-summary-card">
@@ -98,6 +110,7 @@
110 <div class="field-control">
111 <select
112 x-model="$store.selfUpdateStore.form.branch"
113 + x-effect="$nextTick(() => { $el.value = $store.selfUpdateStore.form.branch || 'main'; })"
114 @change="$store.selfUpdateStore.onBranchChanged()"
115 :disabled="$store.selfUpdateStore.isBusy"
116 >
@@ -110,9 +123,11 @@
123
124 <div class="field">
125 <div class="field-label">
113 - <div class="field-title">Target release tag</div>
126 + <div class="field-title">Version</div>
127 <div class="field-description">
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.
128 + Enter a version tag from the
129 + <a href="https://github.com/agent0ai/agent-zero" target="_blank" rel="noreferrer">Agent Zero repository</a>.
130 + Start typing to filter, or use the search button to fetch versions from the selected branch. Exact text entry is still allowed, including downgrades.
131 </div>
132 <template x-if="$store.selfUpdateStore.tagsError">
133 <div class="field-description">
@@ -150,7 +165,20 @@
165 <template x-if="!$store.selfUpdateStore.tagsLoading && $store.selfUpdateStore.tagSuggestions.length === 0">
166 <div class="self-update-tag-item disabled">No matching tags found</div>
167 </template>
153 - <template x-for="tag in $store.selfUpdateStore.tagSuggestions" :key="tag">
168 + <template x-for="tag in $store.selfUpdateStore.groupedTagSuggestions.matched" :key="`match-${tag}`">
169 + <div
170 + class="self-update-tag-item"
171 + @click="$store.selfUpdateStore.selectTag(tag)"
172 + x-text="tag"
173 + ></div>
174 + </template>
175 + <div
176 + class="self-update-tag-separator"
177 + x-show="$store.selfUpdateStore.groupedTagSuggestions.matched.length > 0 && $store.selfUpdateStore.groupedTagSuggestions.rest.length > 0"
178 + >
179 + Other versions
180 + </div>
181 + <template x-for="tag in $store.selfUpdateStore.groupedTagSuggestions.rest" :key="`rest-${tag}`">
182 <div
183 class="self-update-tag-item"
184 @click="$store.selfUpdateStore.selectTag(tag)"
@@ -161,6 +189,10 @@
189 </div>
190 </div>
191
192 + <div class="self-update-warning-banner" x-show="$store.selfUpdateStore.versionCompatibilityWarning">
193 + <span x-text="$store.selfUpdateStore.versionCompatibilityWarning"></span>
194 + </div>
195 +
196 <div class="field">
197 <div class="field-label">
198 <div class="field-title">Back up <code>/a0/usr</code> first</div>
@@ -261,7 +293,7 @@
293 <button
294 class="btn btn-ok"
295 @click="$store.selfUpdateStore.scheduleUpdate()"
264 - :disabled="!$store.selfUpdateStore.isSupported || $store.selfUpdateStore.isBusy"
296 + :disabled="!$store.selfUpdateStore.canScheduleUpdate"
297 >
298 Schedule Update And Restart
299 </button>
@@ -300,7 +332,7 @@
332 right: 0.5rem;
333 top: 0.55rem;
334 cursor: pointer;
303 - color: var(--color-text-secondary, #777);
335 + color: var(--color-text-muted);
336 display: inline-flex;
337 align-items: center;
338 justify-content: center;
@@ -312,32 +344,42 @@
344 max-height: 14rem;
345 overflow-y: auto;
346 z-index: 20;
315 - border: 1px solid var(--color-border, #ddd);
347 + border: 1px solid var(--color-border);
348 border-radius: 0.75rem;
317 - background: var(--color-surface, #fff);
349 + background: var(--color-panel);
350 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
351 }
352
353 .self-update-tag-item {
354 padding: 0.65rem 0.85rem;
355 cursor: pointer;
356 + color: var(--color-text);
357 }
358
359 .self-update-tag-item:hover {
327 - background: var(--color-panel-bg, rgba(0, 0, 0, 0.04));
360 + background: var(--color-background-hover);
361 }
362
363 .self-update-tag-item.disabled {
364 cursor: default;
332 - color: var(--color-text-secondary, #777);
365 + color: var(--color-text-muted);
366 }
367
368 .self-update-tag-item.disabled:hover {
369 background: transparent;
370 }
371
372 + .self-update-tag-separator {
373 + padding: 0.45rem 0.85rem;
374 + border-top: 1px solid var(--color-border);
375 + color: var(--color-text-muted);
376 + font-size: 0.85rem;
377 + text-transform: uppercase;
378 + letter-spacing: 0.04em;
379 + }
380 +
381 .self-update-copy {
340 - color: var(--color-text-secondary);
382 + color: var(--color-text-muted);
383 line-height: 1.5;
384 }
385
@@ -346,24 +388,36 @@
388 }
389
390 .self-update-howto {
349 - border: 1px solid var(--color-border, #ddd);
391 + border: 1px solid var(--color-border);
392 border-radius: 0.9rem;
351 - background: var(--color-surface, #fff);
352 - padding: 0.9rem 1rem;
393 + background: var(--color-panel);
394 }
395
355 - .self-update-howto summary {
396 + .self-update-howto-toggle {
397 + width: 100%;
398 + display: flex;
399 + align-items: center;
400 + justify-content: space-between;
401 + gap: 0.75rem;
402 + padding: 0.9rem 1rem;
403 + border: 0;
404 + background: transparent;
405 cursor: pointer;
406 font-weight: 700;
358 - list-style: none;
407 + color: var(--color-text);
408 + text-align: left;
409 + }
410 +
411 + .self-update-howto-toggle-icon {
412 + transition: transform 0.2s ease;
413 }
414
361 - .self-update-howto summary::-webkit-details-marker {
362 - display: none;
415 + .self-update-howto-toggle[aria-expanded="true"] .self-update-howto-toggle-icon {
416 + transform: rotate(180deg);
417 }
418
365 - .self-update-howto[open] summary {
366 - margin-bottom: 0.75rem;
419 + .self-update-howto-body {
420 + padding: 0 1rem 1rem;
421 }
422
423 .self-update-progress-state {
@@ -373,7 +427,7 @@
427 padding: 1rem 1.1rem;
428 border-radius: 0.9rem;
429 background: rgba(37, 99, 235, 0.08);
376 - color: var(--color-text, #111827);
430 + color: var(--color-text);
431 }
432
433 .self-update-progress-spinner {
@@ -392,10 +446,19 @@
446 }
447
448 .self-update-progress-copy {
395 - color: var(--color-text-secondary, #475569);
449 + color: var(--color-text-muted);
450 line-height: 1.45;
451 }
452
453 + .self-update-warning-banner {
454 + padding: 0.85rem 0.95rem;
455 + border: 1px solid var(--color-warning-text);
456 + border-radius: 0.8rem;
457 + background: color-mix(in srgb, var(--color-warning-text) 12%, transparent);
458 + color: var(--color-warning-text);
459 + line-height: 1.5;
460 + }
461 +
462 .self-update-summary-grid {
463 display: grid;
464 gap: 0.75rem;
@@ -407,14 +470,14 @@
470 border: 1px solid var(--color-border);
471 border-radius: 10px;
472 padding: 0.9rem 1rem;
410 - background: var(--color-bg-secondary);
473 + background: var(--color-panel);
474 }
475
476 .summary-label {
477 font-size: 0.8rem;
478 text-transform: uppercase;
479 letter-spacing: 0.04em;
417 - color: var(--color-text-secondary);
480 + color: var(--color-text-muted);
481 }
482
483 .summary-value {
@@ -426,7 +489,7 @@
489 .summary-meta,
490 .status-path {
491 margin-top: 0.35rem;
429 - color: var(--color-text-secondary);
492 + color: var(--color-text-muted);
493 font-size: 0.9rem;
494 word-break: break-word;
495 }
@@ -449,12 +512,12 @@
512 }
513
514 .self-update-loading {
452 - background: var(--color-bg-secondary);
453 - color: var(--color-text-secondary);
515 + background: var(--color-panel);
516 + color: var(--color-text-muted);
517 }
518
519 .self-update-file-hint {
457 - color: var(--color-text-secondary);
520 + color: var(--color-text-muted);
521 line-height: 1.5;
522 font-size: 0.95rem;
523 }
webui/components/settings/external/self-update-store.js
+100 -28
@@ -4,9 +4,9 @@ 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;
7 const SELF_UPDATE_RETURN_URL_KEY = "a0:self-update:return-url";
8 const SELF_UPDATE_OVERLAY_ID = "self-update-progress-overlay";
9 +const MIN_SELECTOR_VERSION = [0, 9, 9];
10
11 const model = {
12 loading: false,
@@ -29,7 +29,6 @@ const model = {
29 backup_conflict_policy: "rename",
30 },
31 _reconnectTimer: null,
32 - _tagSearchTimer: null,
32 _tagRequestId: 0,
33
34 get isBusy() {
@@ -48,13 +47,48 @@ const model = {
47 return this.info?.current?.branch || "";
48 },
49
50 + get groupedTagSuggestions() {
51 + const tags = Array.isArray(this.tagSuggestions) ? this.tagSuggestions : [];
52 + const query = (this.form.tag || "").trim().toLowerCase();
53 + if (!query) {
54 + return { matched: [], rest: tags };
55 + }
56 +
57 + const matched = [];
58 + const rest = [];
59 + for (const tag of tags) {
60 + if ((tag || "").toLowerCase().includes(query)) {
61 + matched.push(tag);
62 + } else {
63 + rest.push(tag);
64 + }
65 + }
66 + return { matched, rest };
67 + },
68 +
69 + get versionCompatibilityWarning() {
70 + const current = this.parseVersionTag(this.currentVersion);
71 + const target = this.parseVersionTag(this.form.tag);
72 + if (!current || !target) return "";
73 + if (current.epoch !== target.epoch || current.major !== target.major) {
74 + return (
75 + "Updating across major versions requires downloading a new Docker image, " +
76 + "because those releases can include operating system level changes or other breaking changes."
77 + );
78 + }
79 + return "";
80 + },
81 +
82 + get canScheduleUpdate() {
83 + return this.isSupported && !this.isBusy && !this.versionCompatibilityWarning;
84 + },
85 +
86 async init() {
87 await this.refresh();
88 },
89
90 cleanup() {
91 this.clearReconnectTimer();
57 - this.clearTagSearchTimer();
92 this.error = "";
93 this.tagsError = "";
94 this.loading = false;
@@ -74,13 +108,6 @@ const model = {
108 }
109 },
110
77 - clearTagSearchTimer() {
78 - if (this._tagSearchTimer) {
79 - clearTimeout(this._tagSearchTimer);
80 - this._tagSearchTimer = null;
81 - }
82 - },
83 -
111 formatTimestamp(value) {
112 if (!value) return "";
113 try {
@@ -138,7 +165,8 @@ const model = {
165 alignItems: "center",
166 justifyContent: "center",
167 padding: "1.5rem",
141 - background: "rgba(15, 23, 42, 0.42)",
168 + background:
169 + "color-mix(in srgb, var(--color-background) 74%, transparent)",
170 backdropFilter: "blur(6px)",
171 });
172 document.body.appendChild(overlay);
@@ -149,19 +177,33 @@ const model = {
177 #${SELF_UPDATE_OVERLAY_ID} .self-update-progress-card {
178 width: min(28rem, calc(100vw - 2rem));
179 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);
180 + border: 1px solid var(--color-border);
181 + background: color-mix(
182 + in srgb,
183 + var(--color-panel) 92%,
184 + var(--color-background)
185 + );
186 + color: var(--color-text);
187 + box-shadow: 0 24px 64px color-mix(
188 + in srgb,
189 + var(--color-background) 65%,
190 + transparent
191 + );
192 padding: 1.5rem;
193 text-align: center;
194 + font-family: var(--font-family-main);
195 }
196 #${SELF_UPDATE_OVERLAY_ID} .self-update-progress-spinner {
197 width: 2.5rem;
198 height: 2.5rem;
199 margin: 0 auto 1rem;
200 border-radius: 999px;
163 - border: 3px solid rgba(15, 23, 42, 0.12);
164 - border-top-color: var(--color-primary, #2563eb);
201 + border: 3px solid color-mix(
202 + in srgb,
203 + var(--color-border) 55%,
204 + transparent
205 + );
206 + border-top-color: var(--color-primary);
207 animation: self-update-spin 1s linear infinite;
208 }
209 #${SELF_UPDATE_OVERLAY_ID} .self-update-progress-title {
@@ -170,7 +212,7 @@ const model = {
212 margin-bottom: 0.5rem;
213 }
214 #${SELF_UPDATE_OVERLAY_ID} .self-update-progress-detail {
173 - color: var(--color-text-secondary, #475569);
215 + color: var(--color-text-muted);
216 line-height: 1.5;
217 }
218 @keyframes self-update-spin {
@@ -256,15 +298,6 @@ const model = {
298
299 onTagInput() {
300 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);
301 },
302
303 async searchTags({ openResults = true } = {}) {
@@ -275,7 +308,7 @@ const model = {
308 try {
309 const response = await API.callJsonApi("self_update_tags", {
310 branch: this.form.branch,
278 - query: this.form.tag,
311 + query: "",
312 });
313 if (!response?.success) {
314 throw new Error(response?.error || "Failed to fetch release tags.");
@@ -283,7 +316,9 @@ const model = {
316 if (requestId !== this._tagRequestId) {
317 return;
318 }
286 - this.tagSuggestions = Array.isArray(response?.tags) ? response.tags : [];
319 + this.tagSuggestions = Array.isArray(response?.tags)
320 + ? response.tags.filter((tag) => this.isSupportedSuggestionTag(tag))
321 + : [];
322 this.tagsError = response?.error || "";
323 this.tagDropdownOpen = openResults;
324 } catch (error) {
@@ -306,6 +341,37 @@ const model = {
341 this.tagDropdownOpen = false;
342 },
343
344 + parseSelectorTag(value) {
345 + const match = /^v(\d+)\.(\d+)\.(\d+)(?:\..+)?$/.exec((value || "").trim());
346 + if (!match) return null;
347 + return [
348 + Number.parseInt(match[1], 10),
349 + Number.parseInt(match[2], 10),
350 + Number.parseInt(match[3], 10),
351 + ];
352 + },
353 +
354 + isSupportedSuggestionTag(value) {
355 + const parsed = this.parseSelectorTag(value);
356 + if (!parsed) return false;
357 + for (let i = 0; i < MIN_SELECTOR_VERSION.length; i += 1) {
358 + if (parsed[i] > MIN_SELECTOR_VERSION[i]) return true;
359 + if (parsed[i] < MIN_SELECTOR_VERSION[i]) return false;
360 + }
361 + return true;
362 + },
363 +
364 + parseVersionTag(value) {
365 + const match = /^v(\d+)\.(\d+)\.(\d+)(?:\.(.+))?$/.exec((value || "").trim());
366 + if (!match) return null;
367 + return {
368 + epoch: Number.parseInt(match[1], 10),
369 + major: Number.parseInt(match[2], 10),
370 + minor: Number.parseInt(match[3], 10),
371 + rest: match[4] || "",
372 + };
373 + },
374 +
375 async scheduleUpdate() {
376 if (!this.form.branch?.trim()) {
377 this.error = "Choose a branch.";
@@ -317,6 +383,12 @@ const model = {
383 return;
384 }
385
386 + if (!this.parseSelectorTag(this.form.tag)) {
387 + this.error =
388 + "Release tag must use the format vX.Y.Z with optional extra segments such as .W or .W-suffix.";
389 + return;
390 + }
391 +
392 this.saving = true;
393 this.error = "";
394 try {