Enable true one-click panel update from Settings (v2.5.1).

Download release ZIP from git.evilfox.cc when git is unavailable, add upgrade_panel API, and show a primary Update panel button.
This commit is contained in:
orohi
2026-07-28 09:41:51 +03:00
parent 77c6f0dab7
commit 599e0a487c
9 changed files with 350 additions and 83 deletions
+3
View File
@@ -228,6 +228,9 @@ GitHub Actions workflows in `.github/workflows/`:
## 📋 Fix / changelog (this fork)
### v2.5.1
* **One-click panel update** — Settings → About → **Update panel**: downloads release ZIP from Gitea (or `git checkout` when `.git` exists), runs `pip install`, restarts.
### v2.5.0
* **NordVPN in Tunnels** — connect/disconnect outbound NordVPN from Settings (official `nordvpn` CLI; optional country/city).
+57 -14
View File
@@ -102,7 +102,7 @@ else:
application_path = os.path.dirname(__file__)
DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export
CURRENT_VERSION = "v2.5.0"
CURRENT_VERSION = "v2.5.1"
RELEASES_REPO_URL = repo_url()
RELEASES_API_LATEST = api_latest_url()
BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin'))
@@ -5682,7 +5682,7 @@ async def api_check_updates(request: Request):
'releases_url': f'{RELEASES_REPO_URL}/releases',
'body': body,
'can_auto_update': bool(mode.get('can_auto_update')),
'update_mode': mode.get('mode'),
'update_mode': mode.get('update_method') or mode.get('mode'),
'mode': mode,
}
except Exception as e:
@@ -5690,26 +5690,28 @@ async def api_check_updates(request: Request):
return JSONResponse({'error': str(e)}, status_code=502)
async def _fetch_latest_release_tag() -> str:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
resp = await client.get(RELEASES_API_LATEST, headers={'Accept': 'application/json'})
if resp.status_code >= 400:
raise RuntimeError(f'Release API returned HTTP {resp.status_code}')
data = resp.json() if resp.content else {}
return (data.get('tag_name') or data.get('name') or '').strip()
@app.post('/api/settings/apply_update', tags=["Settings"])
async def api_apply_update(request: Request, req: ApplyUpdateRequest):
"""Pull the selected release tag from git.evilfox.cc and restart the panel."""
"""Install a release tag (git checkout or archive download) and restart the panel."""
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
updater = UpdateManager(application_path, CURRENT_VERSION)
target = (req.target_version or '').strip()
if not target:
# Resolve latest if UI did not pass a tag
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
resp = await client.get(
RELEASES_API_LATEST,
headers={'Accept': 'application/json'},
)
if resp.status_code < 400 and resp.content:
payload = resp.json()
target = (payload.get('tag_name') or payload.get('name') or '').strip()
except Exception:
target = await _fetch_latest_release_tag()
except Exception as e:
logger.exception('Failed to resolve latest tag for apply_update')
return JSONResponse({'error': str(e)}, status_code=502)
if not target:
return JSONResponse({'error': 'No target version specified'}, status_code=400)
@@ -5719,13 +5721,54 @@ async def api_apply_update(request: Request, req: ApplyUpdateRequest):
bool(req.allow_dirty),
)
if result.get('status') != 'success':
return JSONResponse({'error': result.get('message') or 'Update failed', **result}, status_code=500)
status_code = 409 if result.get('needs_confirm_dirty') else 500
return JSONResponse({'error': result.get('message') or 'Update failed', **result}, status_code=status_code)
if result.get('restart'):
UpdateManager.schedule_restart(1.5)
return result
@app.post('/api/settings/upgrade_panel', tags=["Settings"])
async def api_upgrade_panel(request: Request):
"""One-click: fetch latest release, install if newer, restart."""
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
updater = UpdateManager(application_path, CURRENT_VERSION)
mode = updater.detect_mode()
if not mode.get('can_auto_update'):
return JSONResponse({
'error': (
'In-panel update is not available for this install. '
'Use git clone / writable app directory, or rebuild Docker on the host.'
),
'mode': mode,
}, status_code=400)
try:
latest = await _fetch_latest_release_tag()
except Exception as e:
return JSONResponse({'error': str(e)}, status_code=502)
if not latest:
return JSONResponse({'error': 'Could not resolve latest release tag'}, status_code=502)
if not version_gt(latest, CURRENT_VERSION):
return {
'status': 'success',
'up_to_date': True,
'current_version': CURRENT_VERSION,
'latest_version': latest,
'message': 'Already on the latest version',
}
result = await asyncio.to_thread(updater.apply_update, latest, True)
if result.get('status') != 'success':
return JSONResponse({'error': result.get('message') or 'Update failed', **result}, status_code=500)
if result.get('restart'):
UpdateManager.schedule_restart(1.5)
result['up_to_date'] = False
result['previous_version'] = CURRENT_VERSION
return result
@app.get('/api/settings/tunnels/status', tags=["Settings"])
async def api_tunnels_status(request: Request):
if not _check_admin(request):
+200 -55
View File
@@ -1,14 +1,19 @@
"""Panel self-update from the EvilFox Gitea repository."""
"""Panel self-update from the EvilFox Gitea repository (git or release archive)."""
from __future__ import annotations
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.request
import zipfile
from typing import Optional
logger = logging.getLogger(__name__)
@@ -18,6 +23,13 @@ DEFAULT_GIT_URL = 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main.git'
DEFAULT_API_LATEST = 'https://git.evilfox.cc/api/v1/repos/test2/Amnezia-Web-Panel-main/releases/latest'
UPDATE_REMOTE = 'panel-update'
ARCHIVE_SKIP_DIRS = frozenset({
'__pycache__', '.git', 'bin', 'data', 'node_modules', '.venv', 'venv', '.cursor',
})
ARCHIVE_SKIP_FILES = frozenset({
'.env', 'data.json', 'tunnels_state.json',
})
def _env(name: str, default: str) -> str:
return (os.environ.get(name) or default).strip()
@@ -51,7 +63,6 @@ def parse_version(value: str) -> tuple:
parts.append(int(m.group(1)) if m else 0)
while len(parts) < 3:
parts.append(0)
# release > prerelease: empty pre sorts higher
pre_rank = 0 if not pre else 1
return (parts[0], parts[1], parts[2], pre_rank, pre)
@@ -60,6 +71,13 @@ def version_gt(a: str, b: str) -> bool:
return parse_version(a) > parse_version(b)
def normalize_tag(value: str) -> str:
tag = (value or '').strip()
if not tag:
return ''
return tag if tag.startswith('v') else f'v{tag}'
def _run(cmd: list, cwd: str, timeout: int = 120) -> tuple[int, str, str]:
try:
proc = subprocess.run(
@@ -83,23 +101,62 @@ class UpdateManager:
self.app_root = os.path.abspath(app_root)
self.current_version = current_version
def _in_docker(self) -> bool:
return os.path.exists('/.dockerenv') or os.environ.get('PANEL_IN_DOCKER') == '1'
def _docker_update_allowed(self) -> bool:
return os.environ.get('PANEL_UPDATE_ALLOW_DOCKER', '').strip().lower() in ('1', 'true', 'yes')
def _app_root_writable(self) -> bool:
probe = os.path.join(self.app_root, '.panel_update_write_probe')
try:
with open(probe, 'w', encoding='utf-8') as fh:
fh.write('ok')
os.remove(probe)
return True
except OSError:
return False
def detect_mode(self) -> dict:
frozen = bool(getattr(sys, 'frozen', False))
in_docker = os.path.exists('/.dockerenv') or os.environ.get('PANEL_IN_DOCKER') == '1'
in_docker = self._in_docker()
git_dir = os.path.join(self.app_root, '.git')
is_git = os.path.isdir(git_dir)
git_ok = False
if is_git and not frozen:
code, _, _ = _run(['git', '--version'], self.app_root, timeout=10)
git_ok = code == 0
can_auto = bool(git_ok and not frozen)
mode = 'git' if can_auto else ('docker' if in_docker else ('binary' if frozen else 'manual'))
writable = self._app_root_writable()
can_git = bool(git_ok and is_git and not frozen)
can_archive = bool(
writable
and not frozen
and (not in_docker or self._docker_update_allowed())
)
can_auto = can_git or can_archive
if can_git:
method = 'git'
elif can_archive:
method = 'archive'
elif in_docker:
method = 'docker'
elif frozen:
method = 'binary'
else:
method = 'manual'
return {
'mode': mode,
'mode': method,
'update_method': method,
'can_auto_update': can_auto,
'can_git_update': can_git,
'can_archive_update': can_archive,
'is_git': is_git,
'frozen': frozen,
'in_docker': in_docker,
'writable': writable,
'app_root': self.app_root,
'repo_url': repo_url(),
'git_url': git_url(),
@@ -131,31 +188,27 @@ class UpdateManager:
code, out, _ = _run(['git', 'status', '--porcelain'], self.app_root, timeout=30)
return code == 0 and bool(out.strip())
def apply_update(self, target_version: str, allow_dirty: bool = False) -> dict:
mode = self.detect_mode()
if not mode['can_auto_update']:
return {
'status': 'error',
'message': (
'Auto-update requires a git checkout of the panel sources. '
'Docker/binary installs: pull a new image or download a release manually.'
),
'mode': mode,
}
target = (target_version or '').strip()
if not re.match(r'^v?\d+(\.\d+){0,3}([.-][\w.]+)?$', target):
return {'status': 'error', 'message': f'Invalid target version: {target_version}'}
if not target.startswith('v'):
target = 'v' + target
def _pip_install(self) -> tuple[bool, str]:
req = os.path.join(self.app_root, 'requirements.txt')
if not os.path.isfile(req):
return True, ''
code_p, out_p, err_p = _run(
[sys.executable, '-m', 'pip', 'install', '-r', req],
self.app_root,
timeout=300,
)
log = (out_p or err_p or '')[:1500]
return code_p == 0, log
def _apply_git_update(self, target: str, allow_dirty: bool) -> dict:
if not allow_dirty and self.working_tree_dirty():
return {
'status': 'error',
'message': (
'Working tree has local changes. Commit/stash them or set allow_dirty, '
'then retry auto-update.'
'Working tree has local changes. Confirm update anyway to force checkout, '
'or commit/stash changes first.'
),
'needs_confirm_dirty': True,
}
err = self._ensure_update_remote()
@@ -168,35 +221,28 @@ class UpdateManager:
timeout=180,
)
if code != 0:
return {
'status': 'error',
'message': f'git fetch failed: {stderr or out or code}',
}
return {'status': 'error', 'message': f'git fetch failed: {stderr or out or code}'}
# Prefer annotated/lightweight tag; fall back to remote main tip if tag missing
# Ensure tag is present locally
code, _, _ = _run(
['git', 'rev-parse', '-q', '--verify', f'refs/tags/{target}'],
self.app_root,
timeout=15,
)
if code == 0:
checkout_ref = target
else:
# tag might only exist as FETCH_HEAD / remote tag
code2, _, _ = _run(
['git', 'rev-parse', '-q', '--verify', f'refs/tags/{target}^{{}}'],
if code != 0:
_run(
['git', 'fetch', '--depth', '1', UPDATE_REMOTE, f'refs/tags/{target}:refs/tags/{target}'],
self.app_root,
timeout=15,
timeout=120,
)
checkout_ref = target if code2 == 0 else f'tags/{target}'
checkout_ref = f'tags/{target}'
code, out, stderr = _run(
['git', 'checkout', '--force', checkout_ref],
self.app_root,
timeout=60,
)
if code != 0:
# last resort: checkout remote main
code_m, out_m, err_m = _run(
['git', 'checkout', '--force', '-B', 'main', f'{UPDATE_REMOTE}/main'],
self.app_root,
@@ -209,31 +255,130 @@ class UpdateManager:
}
checkout_ref = f'{UPDATE_REMOTE}/main'
req = os.path.join(self.app_root, 'requirements.txt')
pip_log = ''
if os.path.isfile(req):
code_p, out_p, err_p = _run(
[sys.executable, '-m', 'pip', 'install', '-r', req],
self.app_root,
timeout=300,
)
pip_log = (out_p or err_p or '')[:1500]
if code_p != 0:
return {
'status': 'error',
'message': f'pip install failed after checkout: {err_p or out_p or code_p}',
'checkout': checkout_ref,
}
ok, pip_log = self._pip_install()
if not ok:
return {
'status': 'error',
'message': f'pip install failed after checkout: {pip_log[:500]}',
'checkout': checkout_ref,
'method': 'git',
}
return {
'status': 'success',
'message': f'Updated to {target}. Panel will restart.',
'message': f'Updated to {target} via git. Panel will restart.',
'target_version': target,
'checkout': checkout_ref,
'pip': pip_log[:500],
'method': 'git',
'restart': True,
}
def _archive_urls(self, tag: str) -> list[str]:
base = repo_url()
return [
f'{base}/archive/{tag}.zip',
f'{base}/archive/{tag.lstrip("v")}.zip',
f'{api_latest_url().rsplit("/releases/latest", 1)[0]}/archive/{tag}.zip',
]
def _download_archive(self, tag: str, dest_path: str) -> None:
last_err = 'unknown error'
for url in self._archive_urls(tag):
try:
req = urllib.request.Request(url, headers={'User-Agent': 'Amnezia-Web-Panel-Updater'})
with urllib.request.urlopen(req, timeout=180) as resp:
with open(dest_path, 'wb') as out:
shutil.copyfileobj(resp, out)
if os.path.getsize(dest_path) > 1024:
return
last_err = f'empty archive from {url}'
except urllib.error.HTTPError as e:
last_err = f'HTTP {e.code} for {url}'
except Exception as e:
last_err = str(e)
raise RuntimeError(f'Failed to download release archive: {last_err}')
@staticmethod
def _extract_zip_root(extract_dir: str) -> str:
entries = [e for e in os.listdir(extract_dir) if e not in ('.', '..')]
if len(entries) == 1:
only = os.path.join(extract_dir, entries[0])
if os.path.isdir(only):
return only
return extract_dir
def _sync_tree(self, src_root: str) -> None:
for root, dirs, files in os.walk(src_root):
dirs[:] = [d for d in dirs if d not in ARCHIVE_SKIP_DIRS]
rel = os.path.relpath(root, src_root)
dest_root = self.app_root if rel in ('.', '') else os.path.join(self.app_root, rel)
os.makedirs(dest_root, exist_ok=True)
for fname in files:
if fname in ARCHIVE_SKIP_FILES:
continue
src_file = os.path.join(root, fname)
dest_file = os.path.join(dest_root, fname)
shutil.copy2(src_file, dest_file)
def _apply_archive_update(self, target: str) -> dict:
tmpdir = tempfile.mkdtemp(prefix='panel-update-')
zip_path = os.path.join(tmpdir, f'{target}.zip')
extract_dir = os.path.join(tmpdir, 'extract')
try:
self._download_archive(target, zip_path)
os.makedirs(extract_dir, exist_ok=True)
with zipfile.ZipFile(zip_path, 'r') as zf:
zf.extractall(extract_dir)
src_root = self._extract_zip_root(extract_dir)
if not os.path.isdir(src_root):
return {'status': 'error', 'message': 'Invalid release archive layout'}
self._sync_tree(src_root)
except Exception as e:
return {'status': 'error', 'message': str(e), 'method': 'archive'}
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
ok, pip_log = self._pip_install()
if not ok:
return {
'status': 'error',
'message': f'Files updated but pip install failed: {pip_log[:500]}',
'method': 'archive',
}
return {
'status': 'success',
'message': f'Updated to {target} from release archive. Panel will restart.',
'target_version': target,
'pip': pip_log[:500],
'method': 'archive',
'restart': True,
}
def apply_update(self, target_version: str, allow_dirty: bool = False) -> dict:
mode = self.detect_mode()
target = normalize_tag(target_version)
if not target or not re.match(r'^v\d+(\.\d+){0,3}([.-][\w.]+)?$', target):
return {'status': 'error', 'message': f'Invalid target version: {target_version}'}
if mode.get('can_git_update'):
return self._apply_git_update(target, allow_dirty)
if mode.get('can_archive_update'):
return self._apply_archive_update(target)
hint = (
'Cannot update from inside this install. '
'Use a git clone or writable source directory, or rebuild the Docker image on the host.'
)
if mode.get('in_docker'):
hint = (
'Docker image install: on the host run '
'`git pull && docker compose up -d --build` '
'(or set PANEL_UPDATE_ALLOW_DOCKER=1 if /app is bind-mounted).'
)
return {'status': 'error', 'message': hint, 'mode': mode}
@staticmethod
def schedule_restart(delay_sec: float = 1.5) -> None:
def _restart():
+55 -9
View File
@@ -622,11 +622,15 @@
</div>
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-sm); flex-wrap: wrap;">
<button type="button" class="btn btn-primary" onclick="upgradePanel()" id="upgradePanelBtn" style="flex:1; min-width:180px;">
<span id="upgradePanelBtnText">⬆ {{ _('upgrade_panel') }}</span>
<div class="spinner hidden" id="upgradePanelSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-secondary" onclick="checkForUpdates()" id="checkUpdateBtn" style="flex:1; min-width:140px;">
<span id="checkUpdateBtnText">🔄 {{ _('check_updates') }}</span>
<div class="spinner hidden" id="checkUpdateSpinner" style="width:14px; height:14px;"></div>
</button>
<button type="button" class="btn btn-primary hidden" onclick="applyPanelUpdate()" id="applyUpdateBtn" style="flex:1; min-width:140px;">
<button type="button" class="btn btn-secondary hidden" onclick="applyPanelUpdate()" id="applyUpdateBtn" style="flex:1; min-width:140px;">
<span id="applyUpdateBtnText">⬆ {{ _('apply_update') }}</span>
<div class="spinner hidden" id="applyUpdateSpinner" style="width:14px; height:14px;"></div>
</button>
@@ -1614,16 +1618,26 @@
statusDiv.classList.remove('hidden');
if (modeHint) {
const mode = data.update_mode || (data.mode && data.mode.mode) || '';
if (data.can_auto_update) {
modeHint.textContent = "{{ _('auto_update_ready')|default('Git auto-update is available for this install.') }}";
} else if (mode === 'docker') {
modeHint.textContent = "{{ _('auto_update_docker')|default('Docker install: rebuild/pull image, or use a git checkout for one-click update.') }}";
const method = data.update_mode || (data.mode && data.mode.update_method) || (data.mode && data.mode.mode) || '';
if (!data.can_auto_update) {
if (method === 'docker') {
modeHint.textContent = "{{ _('auto_update_docker')|default('Docker: on the host run git pull && docker compose up -d --build.') }}";
} else {
modeHint.textContent = "{{ _('auto_update_manual')|default('One-click update needs a writable install directory or git clone.') }}";
}
} else if (method === 'archive') {
modeHint.textContent = "{{ _('auto_update_archive')|default('One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.') }}";
} else {
modeHint.textContent = "{{ _('auto_update_manual')|default('One-click update needs a git clone. You can still download a release.') }}";
modeHint.textContent = "{{ _('auto_update_ready')|default('Git auto-update is available for this install.') }}";
}
}
const upgradeBtn = document.getElementById('upgradePanelBtn');
if (upgradeBtn) {
upgradeBtn.disabled = !data.can_auto_update;
upgradeBtn.title = data.can_auto_update ? '' : (modeHint ? modeHint.textContent : '');
}
if (data.update_available && latestVersion) {
statusDiv.innerHTML = `<span style="color: var(--success); font-weight: bold;">{{ _('update_available')|default('Update available') }}: ${latestVersion}</span>`;
statusDiv.style.border = '1px solid var(--success)';
@@ -1656,7 +1670,7 @@
showToast("{{ _('update_no_target')|default('No target version. Check for updates first.') }}", 'error');
return;
}
if (!confirm("{{ _('apply_update_confirm')|default('Install update from git and restart the panel?') }} " + target)) {
if (!confirm("{{ _('apply_update_confirm')|default('Install update from git.evilfox.cc and restart the panel?') }} " + target)) {
return;
}
applyBtn.disabled = true;
@@ -1665,7 +1679,7 @@
try {
const data = await apiCall('/api/settings/apply_update', 'POST', {
target_version: target,
allow_dirty: false,
allow_dirty: true,
});
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
setTimeout(() => { window.location.reload(); }, 3500);
@@ -1677,6 +1691,38 @@
}
}
async function upgradePanel() {
const btn = document.getElementById('upgradePanelBtn');
const text = document.getElementById('upgradePanelBtnText');
const spinner = document.getElementById('upgradePanelSpinner');
const latest = (latestUpdateInfo && latestUpdateInfo.latest_version) || '';
const confirmMsg = latest
? ("{{ _('upgrade_panel_confirm')|default('Download and install') }} " + latest + "?")
: "{{ _('upgrade_panel_confirm_latest')|default('Check for updates and install the latest release?') }}";
if (!confirm(confirmMsg)) return;
btn.disabled = true;
spinner.classList.remove('hidden');
text.textContent = "{{ _('upgrade_panel_working')|default('Updating panel…') }}";
try {
const data = await apiCall('/api/settings/upgrade_panel', 'POST', {});
if (data.up_to_date) {
showToast("{{ _('upgrade_panel_up_to_date')|default('Already on the latest version') }}", 'info');
await checkForUpdates(true);
return;
}
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
setTimeout(() => { window.location.reload(); }, 4000);
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
await checkForUpdates(true);
} finally {
btn.disabled = false;
spinner.classList.add('hidden');
text.textContent = "⬆ {{ _('upgrade_panel')|default('Update panel') }}";
}
}
// Auto-check once on Settings load
setTimeout(() => checkForUpdates(true), 800);
+7 -1
View File
@@ -489,7 +489,13 @@
"auto_update_hint": "Auto-update pulls the release tag from git.evilfox.cc (git checkout required).",
"auto_update_ready": "Git auto-update is available for this install.",
"auto_update_docker": "Docker install: rebuild/pull image, or use a git checkout for one-click update.",
"auto_update_manual": "One-click update needs a git clone. You can still download a release.",
"auto_update_manual": "One-click update needs a writable install directory or git clone. You can still download a release.",
"auto_update_archive": "One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.",
"upgrade_panel": "Update panel",
"upgrade_panel_confirm": "Download and install",
"upgrade_panel_confirm_latest": "Check for updates and install the latest release?",
"upgrade_panel_working": "Updating panel…",
"upgrade_panel_up_to_date": "Already on the latest version",
"warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",
"warp_install_hint": "Install the official Cloudflare WARP client first, then restart the panel.",
"warp_install_required": "Install WARP first",
+7 -1
View File
@@ -552,5 +552,11 @@
"auto_update_hint": "Auto-update pulls the release tag from git.evilfox.cc (git checkout required).",
"auto_update_ready": "Git auto-update is available for this install.",
"auto_update_docker": "Docker install: rebuild/pull image, or use a git checkout for one-click update.",
"auto_update_manual": "One-click update needs a git clone. You can still download a release."
"auto_update_manual": "One-click update needs a git clone. You can still download a release.",
"auto_update_archive": "One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.",
"upgrade_panel": "Update panel",
"upgrade_panel_confirm": "Download and install",
"upgrade_panel_confirm_latest": "Check for updates and install the latest release?",
"upgrade_panel_working": "Updating panel…",
"upgrade_panel_up_to_date": "Already on the latest version"
}
+7 -1
View File
@@ -552,5 +552,11 @@
"auto_update_hint": "Auto-update pulls the release tag from git.evilfox.cc (git checkout required).",
"auto_update_ready": "Git auto-update is available for this install.",
"auto_update_docker": "Docker install: rebuild/pull image, or use a git checkout for one-click update.",
"auto_update_manual": "One-click update needs a git clone. You can still download a release."
"auto_update_manual": "One-click update needs a git clone. You can still download a release.",
"auto_update_archive": "One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.",
"upgrade_panel": "Update panel",
"upgrade_panel_confirm": "Download and install",
"upgrade_panel_confirm_latest": "Check for updates and install the latest release?",
"upgrade_panel_working": "Updating panel…",
"upgrade_panel_up_to_date": "Already on the latest version"
}
+7 -1
View File
@@ -489,7 +489,13 @@
"auto_update_hint": "Автообновление тянет тег релиза с git.evilfox.cc (нужен git clone панели).",
"auto_update_ready": "Для этой установки доступно автообновление через git.",
"auto_update_docker": "Docker: пересоберите/подтяните образ или используйте git clone для обновления в один клик.",
"auto_update_manual": "Обновление в один клик требует git clone. Можно скачать релиз вручную.",
"auto_update_manual": "Обновление в один клик требует git clone или каталог с правами записи. Можно скачать релиз вручную.",
"auto_update_archive": "Обновление в один клик: скачивает ZIP релиза с git.evilfox.cc и перезапускает панель.",
"upgrade_panel": "Обновить панель",
"upgrade_panel_confirm": "Скачать и установить",
"upgrade_panel_confirm_latest": "Проверить обновления и установить последний релиз?",
"upgrade_panel_working": "Обновление панели…",
"upgrade_panel_up_to_date": "Уже установлена последняя версия",
"warp_hint": "WARP направляет исходящий трафик этого хоста через Cloudflare. Он не создаёт публичный URL панели как Quick Tunnel или ngrok.",
"warp_install_hint": "Сначала установите официальный клиент Cloudflare WARP, затем перезапустите панель.",
"warp_install_required": "Сначала установите WARP",
+7 -1
View File
@@ -552,5 +552,11 @@
"auto_update_hint": "Auto-update pulls the release tag from git.evilfox.cc (git checkout required).",
"auto_update_ready": "Git auto-update is available for this install.",
"auto_update_docker": "Docker install: rebuild/pull image, or use a git checkout for one-click update.",
"auto_update_manual": "One-click update needs a git clone. You can still download a release."
"auto_update_manual": "One-click update needs a git clone. You can still download a release.",
"auto_update_archive": "One-click update downloads the release ZIP from git.evilfox.cc and restarts the panel.",
"upgrade_panel": "Update panel",
"upgrade_panel_confirm": "Download and install",
"upgrade_panel_confirm_latest": "Check for updates and install the latest release?",
"upgrade_panel_working": "Updating panel…",
"upgrade_panel_up_to_date": "Already on the latest version"
}