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() {