Replace hardcoded SUPPORTED_BRANCHES with dynamic branch discovery from remote repository

- Add _get_remote_branch_names helper to fetch available branches via git ls-remote with caching - Add _get_local_origin_branch_names fallback for offline scenarios - Add get_available_branch_values and get_available_branches to expose filtered branch list - Add _is_excluded_self_update_branch helper to filter out HEAD, PR branches - Add _sort_branch_names to deduplicate and sort branches with main first - Add

frdel committed Mar 26, 2026 at 11:30 UTC ffa6ac54338c4acada93e6c351366115e25bef41
5 files changed +404 -48
api/self_update_tags.py
+9 -1
@@ -8,7 +8,15 @@ class SelfUpdateTags(ApiHandler):
8 async def process(self, input: dict, request: Request) -> dict | Response:
9 branch = str(input.get("branch", "")).strip().lower()
10 current_branch = self_update.get_repo_version_info().get("branch", "").strip().lower()
11 - default_branch = current_branch if current_branch in self_update.SUPPORTED_BRANCHES else "main"
11 + available_branch_values = self_update.get_available_branch_values()
12 + if current_branch in available_branch_values:
13 + default_branch = current_branch
14 + elif "main" in available_branch_values:
15 + default_branch = "main"
16 + elif available_branch_values:
17 + default_branch = available_branch_values[0]
18 + else:
19 + default_branch = "main"
20 resolved_branch = branch or default_branch
21
22 try:
helpers/self_update.py
+170 -9
@@ -2,6 +2,7 @@ from __future__ import annotations
2
3 import os
4 import re
5 +import shutil
6 import subprocess
7 import tempfile
8 import time
@@ -16,6 +17,7 @@ OFFICIAL_REPO_AUTHOR = "agent0ai"
17 OFFICIAL_REPO_NAME = "agent-zero"
18 BRANCH_OPTIONS = [
19 {"value": "main", "label": "main"},
20 + {"value": "ready", "label": "ready"},
21 {"value": "testing", "label": "testing"},
22 {"value": "development", "label": "development"},
23 ]
@@ -23,17 +25,20 @@ SUPPORTED_BRANCHES = {option["value"] for option in BRANCH_OPTIONS}
25 BACKUP_CONFLICT_POLICIES = {"rename", "overwrite", "fail"}
26 MIN_SELECTOR_VERSION = (1, 0)
27 REMOTE_BRANCH_TAG_CACHE_TTL_SECONDS = 60.0
28 +REMOTE_BRANCH_LIST_CACHE_TTL_SECONDS = 60.0
29
30 UPDATE_FILE_PATH = Path("/exe/a0-self-update.yaml")
31 STATUS_FILE_PATH = Path("/exe/a0-self-update-status.yaml")
32 LOG_FILE_PATH = Path("/exe/a0-self-update.log")
33 +DURABLE_EXE_DIR = UPDATE_FILE_PATH.parent
34
35 _remote_branch_tag_cache: dict[str, tuple[float, set[str]]] = {}
36 _remote_branch_head_cache: dict[str, tuple[float, dict[str, str]]] = {}
37 +_remote_branch_list_cache: tuple[float, list[str]] | None = None
38
39
40 class PendingUpdateConfig(TypedDict):
36 - branch: Literal["main", "testing", "development"]
41 + branch: str
42 tag: str
43 source_version: str
44 source_describe: str
@@ -84,6 +89,10 @@ def get_log_file_path() -> Path:
89 return LOG_FILE_PATH
90
91
92 +def get_durable_exe_dir() -> Path:
93 + return DURABLE_EXE_DIR
94 +
95 +
96 def _load_yaml(path: Path) -> dict[str, Any] | None:
97 if not path.exists():
98 return None
@@ -123,10 +132,27 @@ def get_repo_dir(repo_dir: str | Path | None = None) -> Path:
132 return Path(__file__).resolve().parents[1]
133
134
135 +def get_self_update_runtime_source_dir(
136 + repo_dir: str | Path | None = None,
137 +) -> Path:
138 + return get_repo_dir(repo_dir) / "docker" / "run" / "fs" / "exe"
139 +
140 +
141 def _get_official_remote_url() -> str:
142 return f"https://github.com/{OFFICIAL_REPO_AUTHOR}/{OFFICIAL_REPO_NAME}.git"
143
144
145 +def _run_git_raw(*args: str) -> str:
146 + completed = subprocess.run(
147 + ["git", *args],
148 + check=True,
149 + text=True,
150 + capture_output=True,
151 + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
152 + )
153 + return completed.stdout.strip()
154 +
155 +
156 def _run_git(repo_dir: str | Path, *args: str) -> str:
157 completed = subprocess.run(
158 ["git", "-C", str(get_repo_dir(repo_dir)), *args],
@@ -211,16 +237,139 @@ def _resolve_backup_path(
237 return path.resolve()
238
239
240 +def _is_excluded_self_update_branch(branch: str) -> bool:
241 + normalized = branch.strip().lower()
242 + return (
243 + not normalized
244 + or normalized == "head"
245 + or normalized.startswith("pr/")
246 + or normalized.startswith("pr-")
247 + or normalized.startswith("pull/")
248 + )
249 +
250 +
251 +def _sort_branch_names(branches: list[str]) -> list[str]:
252 + unique_branches: list[str] = []
253 + seen: set[str] = set()
254 + for branch in branches:
255 + normalized = branch.strip().lower()
256 + if _is_excluded_self_update_branch(normalized) or normalized in seen:
257 + continue
258 + seen.add(normalized)
259 + unique_branches.append(normalized)
260 + return sorted(unique_branches, key=lambda branch: (branch != "main", branch))
261 +
262 +
263 +def _get_remote_branch_names() -> list[str]:
264 + global _remote_branch_list_cache
265 +
266 + now = time.monotonic()
267 + if (
268 + _remote_branch_list_cache
269 + and now - _remote_branch_list_cache[0] <= REMOTE_BRANCH_LIST_CACHE_TTL_SECONDS
270 + ):
271 + return list(_remote_branch_list_cache[1])
272 +
273 + output = _run_git_raw("ls-remote", "--heads", _get_official_remote_url())
274 + branches: list[str] = []
275 + prefix = "refs/heads/"
276 + for line in output.splitlines():
277 + parts = line.strip().split()
278 + if len(parts) != 2:
279 + continue
280 + ref_name = parts[1]
281 + if not ref_name.startswith(prefix):
282 + continue
283 + branches.append(ref_name[len(prefix):])
284 +
285 + sorted_branches = _sort_branch_names(branches)
286 + _remote_branch_list_cache = (now, sorted_branches)
287 + return list(sorted_branches)
288 +
289 +
290 +def _get_local_origin_branch_names(
291 + repo_dir: str | Path | None = None,
292 +) -> list[str]:
293 + repository = get_repo_dir(repo_dir)
294 + try:
295 + output = _run_git(
296 + repository,
297 + "for-each-ref",
298 + "--format=%(refname:short)",
299 + "refs/remotes/origin",
300 + )
301 + except Exception:
302 + return []
303 +
304 + branches: list[str] = []
305 + prefix = "origin/"
306 + for line in output.splitlines():
307 + ref_name = line.strip()
308 + if not ref_name.startswith(prefix):
309 + continue
310 + branches.append(ref_name[len(prefix):])
311 + return _sort_branch_names(branches)
312 +
313 +
314 +def get_available_branch_values(
315 + repo_dir: str | Path | None = None,
316 +) -> list[str]:
317 + try:
318 + remote_branches = _get_remote_branch_names()
319 + if remote_branches:
320 + return remote_branches
321 + except Exception:
322 + pass
323 +
324 + local_origin_branches = _get_local_origin_branch_names(repo_dir=repo_dir)
325 + if local_origin_branches:
326 + return local_origin_branches
327 +
328 + return _sort_branch_names([option["value"] for option in BRANCH_OPTIONS])
329 +
330 +
331 +def get_available_branches(
332 + repo_dir: str | Path | None = None,
333 +) -> list[dict[str, str]]:
334 + return [
335 + {"value": branch, "label": branch}
336 + for branch in get_available_branch_values(repo_dir=repo_dir)
337 + ]
338 +
339 +
340 +def sync_self_update_runtime_files(
341 + repo_dir: str | Path | None = None,
342 +) -> list[str]:
343 + source_dir = get_self_update_runtime_source_dir(repo_dir)
344 + durable_dir = get_durable_exe_dir()
345 + synced_files: list[str] = []
346 + runtime_files = ["self_update_manager.py", "run_A0.sh"]
347 +
348 + for filename in runtime_files:
349 + source_path = source_dir / filename
350 + if not source_path.exists():
351 + raise FileNotFoundError(
352 + f"Required self-update runtime file is missing: {source_path}"
353 + )
354 + destination_path = durable_dir / filename
355 + destination_path.parent.mkdir(parents=True, exist_ok=True)
356 + shutil.copyfile(source_path, destination_path)
357 + shutil.copymode(source_path, destination_path)
358 + synced_files.append(str(destination_path))
359 +
360 + return synced_files
361 +
362 +
363 def _get_branch_reference_names(branch: str) -> list[str]:
364 normalized_branch = branch.strip().lower()
216 - if normalized_branch not in SUPPORTED_BRANCHES:
365 + if _is_excluded_self_update_branch(normalized_branch):
366 return []
367 return [f"origin/{normalized_branch}", normalized_branch]
368
369
370 def _get_remote_branch_merged_tags(branch: str) -> set[str]:
371 normalized_branch = branch.strip().lower()
223 - if normalized_branch not in SUPPORTED_BRANCHES:
372 + if _is_excluded_self_update_branch(normalized_branch):
373 return set()
374
375 cached = _remote_branch_tag_cache.get(normalized_branch)
@@ -250,7 +399,7 @@ def _get_remote_branch_merged_tags(branch: str) -> set[str]:
399
400 def _get_remote_branch_head_info(branch: str) -> dict[str, str]:
401 normalized_branch = branch.strip().lower()
253 - if normalized_branch not in SUPPORTED_BRANCHES:
402 + if _is_excluded_self_update_branch(normalized_branch):
403 return {"describe": "", "short_tag": "", "commit": ""}
404
405 cached = _remote_branch_head_cache.get(normalized_branch)
@@ -404,7 +553,8 @@ def get_current_branch_latest_info(
553 ) -> dict[str, Any]:
554 repository = get_repo_dir(repo_dir)
555 normalized_branch = current_branch.strip().lower()
407 - if normalized_branch not in SUPPORTED_BRANCHES:
556 + available_branches = set(get_available_branch_values(repo_dir=repository))
557 + if normalized_branch not in available_branches:
558 return {
559 "branch": current_branch.strip(),
560 "supported": False,
@@ -511,7 +661,16 @@ def get_update_info(repo_dir: str | Path | None = None) -> dict[str, Any]:
661 version_info = get_repo_version_info(repository)
662 current_version = version_info["short_tag"]
663 current_branch = version_info.get("branch", "").strip().lower()
514 - default_branch = current_branch if current_branch in SUPPORTED_BRANCHES else "main"
664 + available_branches = get_available_branches(repo_dir=repository)
665 + available_branch_values = [branch["value"] for branch in available_branches]
666 + if current_branch in available_branch_values:
667 + default_branch = current_branch
668 + elif "main" in available_branch_values:
669 + default_branch = "main"
670 + elif available_branch_values:
671 + default_branch = available_branch_values[0]
672 + else:
673 + default_branch = "main"
674 tag_options, higher_major_versions, tags_error = get_selector_tag_options(
675 default_branch,
676 repo_dir=repository,
@@ -526,7 +685,7 @@ def get_update_info(repo_dir: str | Path | None = None) -> dict[str, Any]:
685 ),
686 "pending": load_pending_update(),
687 "last_status": load_last_status(),
529 - "branches": BRANCH_OPTIONS,
688 + "branches": available_branches,
689 "available_tags": [option["value"] for option in tag_options],
690 "available_tag_options": tag_options,
691 "available_tags_error": tags_error,
@@ -561,8 +720,9 @@ def schedule_update(
720 version_info = get_repo_version_info(repository)
721
722 normalized_branch = branch.strip().lower()
564 - if normalized_branch not in SUPPORTED_BRANCHES:
565 - raise ValueError("Branch must be one of: main, testing, development.")
723 + available_branch_values = set(get_available_branch_values(repo_dir=repository))
724 + if normalized_branch not in available_branch_values:
725 + raise ValueError("Branch must be one of the available remote branches.")
726
727 normalized_tag = tag.strip()
728 if not normalized_tag:
@@ -613,5 +773,6 @@ def schedule_update(
773 "backup_conflict_policy": normalized_policy, # type: ignore[assignment]
774 }
775
776 + sync_self_update_runtime_files(repository)
777 _write_yaml(get_update_file_path(), payload)
778 return payload
tests/test_self_update_tag_filter.py
+122
@@ -66,6 +66,85 @@ def test_self_update_branch_filter_prefers_remote_branch_tags(monkeypatch):
66 assert tags == ["v1.1", "v1.0"]
67
68
69 +def test_self_update_available_branch_values_filter_prs_and_pin_main_first(monkeypatch):
70 + monkeypatch.setattr(self_update, "_remote_branch_list_cache", None)
71 + monkeypatch.setattr(
72 + self_update,
73 + "_run_git_raw",
74 + lambda *args: "\n".join(
75 + [
76 + "111 refs/heads/testing",
77 + "222 refs/heads/ready",
78 + "333 refs/heads/pr/123",
79 + "444 refs/heads/development",
80 + "555 refs/heads/main",
81 + "666 refs/heads/pr-999",
82 + ]
83 + ),
84 + )
85 +
86 + branches = self_update.get_available_branch_values()
87 +
88 + assert branches == ["main", "development", "ready", "testing"]
89 +
90 +
91 +def test_self_update_available_branch_values_fallback_to_local_origin(monkeypatch):
92 + monkeypatch.setattr(self_update, "_remote_branch_list_cache", None)
93 + monkeypatch.setattr(
94 + self_update,
95 + "_run_git_raw",
96 + lambda *args: (_ for _ in ()).throw(RuntimeError("offline")),
97 + )
98 + monkeypatch.setattr(
99 + self_update,
100 + "_run_git",
101 + lambda repo_dir, *args: "\n".join(
102 + [
103 + "origin/HEAD",
104 + "origin/ready",
105 + "origin/testing",
106 + "origin/pr/999",
107 + "origin/main",
108 + ]
109 + ),
110 + )
111 +
112 + branches = self_update.get_available_branch_values()
113 +
114 + assert branches == ["main", "ready", "testing"]
115 +
116 +
117 +def test_self_update_runtime_files_are_synced_to_durable_exe(monkeypatch, tmp_path):
118 + source_dir = tmp_path / "source-exe"
119 + durable_dir = tmp_path / "durable-exe"
120 + source_dir.mkdir()
121 + durable_dir.mkdir()
122 + manager_source = source_dir / "self_update_manager.py"
123 + launcher_source = source_dir / "run_A0.sh"
124 + manager_source.write_text("# manager\n", encoding="utf-8")
125 + launcher_source.write_text("#!/bin/bash\necho hi\n", encoding="utf-8")
126 +
127 + monkeypatch.setattr(
128 + self_update,
129 + "get_self_update_runtime_source_dir",
130 + lambda repo_dir=None: source_dir,
131 + )
132 + monkeypatch.setattr(
133 + self_update,
134 + "get_durable_exe_dir",
135 + lambda: durable_dir,
136 + )
137 +
138 + synced_files = self_update.sync_self_update_runtime_files()
139 +
140 + assert synced_files == [
141 + str(durable_dir / "self_update_manager.py"),
142 + str(durable_dir / "run_A0.sh"),
143 + ]
144 + assert (durable_dir / "self_update_manager.py").read_text(encoding="utf-8") == "# manager\n"
145 + assert (durable_dir / "run_A0.sh").read_text(encoding="utf-8") == "#!/bin/bash\necho hi\n"
146 +
147 +
148 def test_self_update_selector_tag_options_filter_to_current_major(monkeypatch):
149 monkeypatch.setattr(
150 self_update,
@@ -111,6 +190,20 @@ def test_self_update_update_info_uses_current_branch_for_latest_version(monkeypa
190 "short_commit": "abc1234",
191 },
192 )
193 + monkeypatch.setattr(
194 + self_update,
195 + "get_available_branches",
196 + lambda repo_dir=None: [
197 + {"value": "main", "label": "main"},
198 + {"value": "ready", "label": "ready"},
199 + {"value": "testing", "label": "testing"},
200 + ],
201 + )
202 + monkeypatch.setattr(
203 + self_update,
204 + "get_available_branch_values",
205 + lambda repo_dir=None: ["main", "ready", "testing"],
206 + )
207 monkeypatch.setattr(
208 self_update,
209 "get_selector_tag_options",
@@ -172,14 +265,21 @@ def test_self_update_frontend_uses_preloaded_select():
265 assert "response.available_higher_major_versions" in content
266 assert "response.tag_options" in content
267 assert "response.higher_major_versions" in content
268 + assert "response.pending || {" in content
269 + assert "tag: \"\"," in content
270 assert "await this.fetchTags();" in content
271 assert "Release tag must use the format vX.Y." in content
272 assert "Release tag must be v1.0 or newer." in content
273 assert "isLatestSelectorTag(value)" in content
274 assert "this.isSelectableTag(this.form.tag)" in content
275 + assert "getLastStatusBadgeClass(status)" in content
276 + assert "status-pill-error" in content
277 + assert "status-pill-success" in content
278 assert "this.info?.defaults?.branch ||" in content
279 assert "Version ${this.trimmedTag} does not exist on branch" in content
280 assert "this.selectedTagExistsOnBranch" in content
281 + assert "this.form.tag = \"\";" in content
282 + assert "this.availableTags[0]" not in content
283 assert 'const response = await fetch("/api/health"' in content
284 assert "if (response.ok && observedBackendUnavailable)" in content
285 assert "window.location.reload();" in content
@@ -210,6 +310,8 @@ def test_self_update_modal_uses_standard_select_and_manual_backup():
310 assert "current_branch_latest?.display_version" in content
311 assert "tagOption.label" in content
312 assert 'data-bs-target="#self-update-last-attempt-collapse"' in content
313 + assert "self-update-header-status" in content
314 + assert "getLastStatusLabel($store.selfUpdateStore.info?.last_status?.status)" in content
315 assert "Latest version" in content
316 assert "Docker update guide" in content
317 assert "https://www.agent-zero.ai/p/docs/get-started/" in content
@@ -240,6 +342,16 @@ def test_self_update_schedule_rejects_missing_tag_on_branch(monkeypatch, tmp_pat
342 "",
343 ),
344 )
345 + monkeypatch.setattr(
346 + self_update,
347 + "get_available_branch_values",
348 + lambda repo_dir=None: ["development", "ready", "testing", "main"],
349 + )
350 + monkeypatch.setattr(
351 + self_update,
352 + "sync_self_update_runtime_files",
353 + lambda repo_dir=None: [],
354 + )
355 monkeypatch.setattr(self_update, "_write_yaml", lambda path, payload: None)
356
357 with pytest.raises(ValueError, match=r"Version v1\.1 does not exist on branch development\."):
@@ -276,6 +388,16 @@ def test_self_update_schedule_accepts_latest_when_selector_exposes_it(monkeypatc
388 "",
389 ),
390 )
391 + monkeypatch.setattr(
392 + self_update,
393 + "get_available_branch_values",
394 + lambda repo_dir=None: ["development", "ready", "testing", "main"],
395 + )
396 + monkeypatch.setattr(
397 + self_update,
398 + "sync_self_update_runtime_files",
399 + lambda repo_dir=None: [],
400 + )
401 monkeypatch.setattr(
402 self_update,
403 "_write_yaml",
webui/components/settings/external/self-update-modal.html
+73 -32
@@ -14,36 +14,6 @@
14 x-destroy="$store.selfUpdateStore.cleanup()"
15 class="self-update-modal"
16 >
17 - <div class="self-update-panel">
18 - <button
19 - type="button"
20 - class="self-update-panel-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-panel-toggle-icon">expand_more</span>
28 - </button>
29 - <div class="collapse" id="self-update-howto-collapse">
30 - <div class="self-update-panel-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 version target 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>
45 - </div>
46 -
17 <div class="self-update-version-grid">
18 <div class="self-update-summary-card">
19 <div class="summary-label">Current version</div>
@@ -107,6 +77,36 @@
77 </div>
78 </template>
79
80 + <div class="self-update-panel">
81 + <button
82 + type="button"
83 + class="self-update-panel-toggle"
84 + data-bs-toggle="collapse"
85 + data-bs-target="#self-update-howto-collapse"
86 + aria-expanded="false"
87 + aria-controls="self-update-howto-collapse"
88 + >
89 + <span>How it works?</span>
90 + <span class="material-symbols-outlined self-update-panel-toggle-icon">expand_more</span>
91 + </button>
92 + <div class="collapse" id="self-update-howto-collapse">
93 + <div class="self-update-panel-body self-update-copy">
94 + <p>
95 + Agent Zero saves this request into
96 + <code x-text="$store.selfUpdateStore.info?.paths?.update_file || '/exe/a0-self-update.yaml'"></code>,
97 + restarts once, applies the requested branch and version target before the UI
98 + starts again, then reloads this page when <code>/api/health</code> is healthy.
99 + </p>
100 + <p>
101 + If the updated UI does not become healthy within 2 minutes, the bootstrap
102 + manager in <code>/exe</code> restores the previous checkout and starts that
103 + version again, so even an older downgraded <code>/a0</code> can be upgraded back
104 + by creating the YAML file manually.
105 + </p>
106 + </div>
107 + </div>
108 + </div>
109 +
110 <template x-if="$store.selfUpdateStore.info?.last_status">
111 <div class="self-update-panel">
112 <button
@@ -118,11 +118,17 @@
118 aria-controls="self-update-last-attempt-collapse"
119 >
120 <span>Last Attempt</span>
121 - <span class="material-symbols-outlined self-update-panel-toggle-icon">expand_more</span>
121 + <span class="self-update-panel-toggle-trailing">
122 + <span
123 + class="status-pill self-update-header-status"
124 + :class="$store.selfUpdateStore.getLastStatusBadgeClass($store.selfUpdateStore.info?.last_status?.status)"
125 + x-text="$store.selfUpdateStore.getLastStatusLabel($store.selfUpdateStore.info?.last_status?.status)"
126 + ></span>
127 + <span class="material-symbols-outlined self-update-panel-toggle-icon">expand_more</span>
128 + </span>
129 </button>
130 <div class="collapse" id="self-update-last-attempt-collapse">
131 <div class="self-update-panel-body">
125 - <div class="status-pill" x-text="$store.selfUpdateStore.info?.last_status?.status || 'unknown'"></div>
132 <div class="status-message" x-text="$store.selfUpdateStore.info?.last_status?.message || ''"></div>
133 <div class="summary-meta">
134 Trigger:
@@ -399,6 +405,13 @@
405 transition: transform 0.2s ease;
406 }
407
408 + .self-update-panel-toggle-trailing {
409 + display: inline-flex;
410 + align-items: center;
411 + gap: 0.75rem;
412 + flex: 0 0 auto;
413 + }
414 +
415 .self-update-panel-toggle[aria-expanded="true"] .self-update-panel-toggle-icon {
416 transform: rotate(180deg);
417 }
@@ -537,6 +550,34 @@
550 letter-spacing: 0.04em;
551 }
552
553 + .self-update-header-status {
554 + margin-top: 0;
555 + }
556 +
557 + .status-pill-success {
558 + border-color: color-mix(in srgb, var(--color-success, #16a34a) 55%, var(--color-border));
559 + background: color-mix(in srgb, var(--color-success, #16a34a) 18%, transparent);
560 + color: var(--color-success, #16a34a);
561 + }
562 +
563 + .status-pill-error {
564 + border-color: color-mix(in srgb, var(--color-error, #dc2626) 55%, var(--color-border));
565 + background: color-mix(in srgb, var(--color-error, #dc2626) 18%, transparent);
566 + color: var(--color-error, #dc2626);
567 + }
568 +
569 + .status-pill-warning {
570 + border-color: color-mix(in srgb, var(--color-warning, #d97706) 55%, var(--color-border));
571 + background: color-mix(in srgb, var(--color-warning, #d97706) 18%, transparent);
572 + color: var(--color-warning, #d97706);
573 + }
574 +
575 + .status-pill-neutral {
576 + border-color: var(--color-border);
577 + background: transparent;
578 + color: var(--color-text);
579 + }
580 +
581 .status-message {
582 margin-top: 0.7rem;
583 line-height: 1.5;
webui/components/settings/external/self-update-store.js
+30 -6
@@ -134,6 +134,29 @@ const model = {
134 return `${branch || "main"} / ${tag || "None"}`;
135 },
136
137 + normalizeLastStatus(status) {
138 + return (status || "unknown").trim().toLowerCase();
139 + },
140 +
141 + getLastStatusLabel(status) {
142 + const normalizedStatus = this.normalizeLastStatus(status);
143 + return normalizedStatus ? normalizedStatus.replace(/_/g, " ") : "unknown";
144 + },
145 +
146 + getLastStatusBadgeClass(status) {
147 + const normalizedStatus = this.normalizeLastStatus(status);
148 + if (normalizedStatus === "success") {
149 + return "status-pill-success";
150 + }
151 + if (normalizedStatus === "failed" || normalizedStatus === "rollback_failed") {
152 + return "status-pill-error";
153 + }
154 + if (normalizedStatus === "rolled_back") {
155 + return "status-pill-warning";
156 + }
157 + return "status-pill-neutral";
158 + },
159 +
160 getProgressOverlay() {
161 return document.getElementById(SELF_UPDATE_OVERLAY_ID);
162 },
@@ -255,7 +278,12 @@ const model = {
278 throw new Error(response?.error || "Failed to load self-update info.");
279 }
280 this.info = response;
258 - this.applyFormState(response.pending || response.defaults || {});
281 + this.applyFormState(
282 + response.pending || {
283 + ...(response.defaults || {}),
284 + tag: "",
285 + },
286 + );
287 this.applyAvailableTags({
288 options: response.available_tag_options,
289 higherMajorVersions: response.available_higher_major_versions,
@@ -304,11 +332,7 @@ const model = {
332 return;
333 }
334
307 - const defaultTag = (this.info?.defaults?.tag || "").trim();
308 - this.form.tag =
309 - defaultTag && this.availableTags.includes(defaultTag)
310 - ? defaultTag
311 - : this.availableTags[0];
335 + this.form.tag = "";
336 },
337
338 async openModal() {