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