Template
Compare commits
20
Commits
v2.1.0
...
73d0ee277d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73d0ee277d | ||
|
|
9b4ef0f9f0 | ||
|
|
78e69d899a | ||
|
|
2d62758716 | ||
|
|
d26843a0d1 | ||
|
|
706d2c9c0d | ||
|
|
2746f981af | ||
|
|
4f5a1d7554 | ||
|
|
ff7cc5c592 | ||
|
|
2808a49fa5 | ||
|
|
2973b96713 | ||
|
|
5d63e5d6ef | ||
|
|
a5b8f26db1 | ||
|
|
599e0a487c | ||
|
|
77c6f0dab7 | ||
|
|
fd257a59f7 | ||
|
|
6206697e3b | ||
|
|
4c19099b04 | ||
|
|
80160d0ef4 | ||
|
|
ac76a0e540 |
+10
-3
@@ -2,11 +2,18 @@
|
||||
APP_PORT=5000
|
||||
SECRET_KEY=change-me-to-a-long-random-string
|
||||
|
||||
# PostgreSQL (used by docker-compose)
|
||||
# PostgreSQL (docker-compose — same password for db + panel via DATABASE_URL)
|
||||
POSTGRES_USER=amnezia
|
||||
POSTGRES_PASSWORD=amnezia
|
||||
POSTGRES_PASSWORD=change-me-strong-password
|
||||
POSTGRES_DB=amnezia_panel
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# Set once before first deploy. Changing POSTGRES_PASSWORD later requires resetting
|
||||
# the amnezia_pgdata volume or the panel cannot connect (Postgres keeps the old password).
|
||||
|
||||
# Full DSN (override if panel runs outside compose)
|
||||
# DATABASE_URL=postgresql://amnezia:amnezia@db:5432/amnezia_panel
|
||||
# DATABASE_URL=postgresql://amnezia:change-me-strong-password@db:5432/amnezia_panel
|
||||
|
||||
# Dokploy / reverse proxy (optional — set in compose for Docker)
|
||||
# PANEL_IN_DOCKER=1
|
||||
# PANEL_BEHIND_PROXY=1
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
+7
-4
@@ -5,12 +5,15 @@ FROM python:3.12-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
APP_PORT=5000 \
|
||||
PORT=5000 \
|
||||
PANEL_IN_DOCKER=1 \
|
||||
PANEL_BEHIND_PROXY=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& apt-get install -y --no-install-recommends curl postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
@@ -20,7 +23,7 @@ COPY . .
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD curl -fsS "http://127.0.0.1:${APP_PORT}/docs" >/dev/null || exit 1
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD curl -fsS "http://127.0.0.1:5000/health" >/dev/null || exit 1
|
||||
|
||||
CMD ["python3", "app.py"]
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000", "--proxy-headers", "--forwarded-allow-ips", "*"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Amnezia Web Panel
|
||||
|
||||
A modern, high-performance web interface for managing **AmneziaWG**, **Classic WireGuard**, **Xray (XTLS-Reality)**, **Hysteria 2**, **NaiveProxy**, **Telemt (Telegram MTProxy)**, **Cloudflare WARP**, **AmneziaDNS**, **AdGuard Home**, **SOCKS5**, and **NGINX + Let's Encrypt** services on remote Ubuntu servers — from a single dashboard. Designed to provide a premium user experience with robust administrative capabilities.
|
||||
A modern, high-performance web interface for managing **AmneziaWG**, **Classic WireGuard**, **Xray (XTLS-Reality)**, **Hysteria 2**, **NaiveProxy**, **Telemt (Telegram MTProxy)**, **Cloudflare WARP**, **NordVPN**, **AmneziaDNS**, **AdGuard Home**, **SOCKS5**, and **NGINX + Let's Encrypt** services on remote Ubuntu servers — from a single dashboard. Designed to provide a premium user experience with robust administrative capabilities.
|
||||
|
||||
> ### 🔄 Compatibility with Official Amnezia Client
|
||||
>
|
||||
@@ -66,6 +66,7 @@ Configuration panel for system parameters and preferences:
|
||||
* **NaiveProxy** (stable): HTTPS/HTTP2 camouflage proxy via [klzgrad/naiveproxy](https://github.com/klzgrad/naiveproxy) (Caddy + [klzgrad/forwardproxy](https://github.com/klzgrad/forwardproxy) naïve fork). ACME TLS on the domain, per-client basic auth, `naive+https://` share links. Requires free TCP **80** and **443**. **Use Karing** as the client — do **not** use v2rayN; other clients are untested.
|
||||
* **Telemt (Telegram MTProxy)**: High-performance Telegram MTProxy with TLS emulation and comprehensive management (quotas, IP limits, real-time session tracking). Robust install path that auto-configures Docker's official apt/yum repository when needed.
|
||||
* **Cloudflare WARP**: Add and manage WARP-powered connectivity from the panel for routing and network flexibility.
|
||||
* **NordVPN**: Connect/disconnect outbound NordVPN from Settings via the official CLI (optional country/city).
|
||||
* **🛠 Services**:
|
||||
* **AmneziaDNS**: Internal DNS resolver on a private docker network (`amnezia-dns-net`, IP `172.29.172.254`) to prevent DNS leaks and blockings.
|
||||
* **AdGuard Home**: DNS-based ad blocker with a web admin UI. Two install modes: **Replace AmneziaDNS** (takes its IP, all VPN clients use AdGuard immediately) or **Side-by-side** (parallel deployment on `172.29.172.253`, web UI accessible only over the VPN by default). Optional opt-in checkboxes to expose the web UI / DoT / DoH on the host.
|
||||
@@ -85,6 +86,11 @@ Configuration panel for system parameters and preferences:
|
||||
* **🌐 Internationalization (i18n)**:
|
||||
* Full support for **English**, **Russian**, **French**, **Chinese**, and **Persian**.
|
||||
* Native **RTL (Right-to-Left)** support for Persian language.
|
||||
* **🔄 Auto-update**:
|
||||
* Settings → About checks releases on [git.evilfox.cc](https://git.evilfox.cc/test2/Amnezia-Web-Panel-main) and can install the latest tag via git + restart (when the panel runs from a git checkout).
|
||||
* **🌍 Tunnels & outbound VPN**:
|
||||
* **Cloudflare Quick Tunnel** and **ngrok** expose the local panel to the internet.
|
||||
* **Cloudflare WARP** and **NordVPN** route the host outbound (no public panel URL).
|
||||
* **👥 Advanced User Management**:
|
||||
* Role-based access (Admin, Support, Regular User).
|
||||
* Traffic limits, status monitoring, and account expiration.
|
||||
@@ -182,22 +188,71 @@ Mac
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# set SECRET_KEY and strong POSTGRES_PASSWORD in .env
|
||||
docker network inspect dokploy-network >/dev/null 2>&1 || docker network create dokploy-network
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Panel: `http://localhost:5000` (or `APP_PORT` from `.env`).
|
||||
|
||||
### Dokploy (recommended)
|
||||
|
||||
The compose file connects the panel to Dokploy's Traefik network (`dokploy-network`) and exposes the app on **container port 5000** (not 80).
|
||||
|
||||
1. Deploy as **Docker Compose** from this repo (Dokploy creates `dokploy-network` automatically).
|
||||
2. In Dokploy → your app → **Environment** (set **before first deploy**):
|
||||
- `SECRET_KEY` — long random string
|
||||
- `POSTGRES_PASSWORD` — strong password (same value is used for `db` and `amnezia_panel`)
|
||||
3. In Dokploy → **Domains**:
|
||||
- **Service**: `amnezia_panel` (not `db`)
|
||||
- **Container port**: `5000`
|
||||
- **Path**: `/`
|
||||
4. **Deploy / Rebuild** (not Restart) after code or env changes.
|
||||
|
||||
**Verify on the server** (replace `PROJECT` and path with yours):
|
||||
|
||||
```bash
|
||||
COMPOSE_DIR=/etc/dokploy/compose/home-vpn-g6rfpd/code
|
||||
PROJECT=home-vpn-g6rfpd
|
||||
|
||||
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml exec amnezia_panel \
|
||||
curl -fsS http://127.0.0.1:5000/health
|
||||
# → {"status":"ok","version":"v2.6.0"}
|
||||
|
||||
docker inspect ${PROJECT}-amnezia_panel-1 \
|
||||
--format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}'
|
||||
# must include: dokploy-network
|
||||
```
|
||||
|
||||
**Full redeploy after git update:**
|
||||
|
||||
```bash
|
||||
cd $COMPOSE_DIR && git pull origin main
|
||||
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml down
|
||||
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml build --no-cache amnezia_panel
|
||||
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml up -d --force-recreate
|
||||
```
|
||||
|
||||
**Postgres `password authentication failed`:** the volume keeps the password from the **first** deploy. If you change `POSTGRES_PASSWORD` in Dokploy later, reset the DB volume (wipes panel DB data):
|
||||
|
||||
```bash
|
||||
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml down
|
||||
docker volume rm ${PROJECT}_amnezia_pgdata
|
||||
docker compose -p $PROJECT -f $COMPOSE_DIR/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
**Domain shows `404 page not found` (plain text):** Traefik has no route to the panel — check Domains (service `amnezia_panel`, port `5000`) and that the container is on `dokploy-network` (see verify commands above).
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `Dockerfile` | Production image (Python 3.12) |
|
||||
| `docker-compose.yml` | Panel + PostgreSQL with healthchecks |
|
||||
| `docker-compose.yml` | Panel + PostgreSQL with healthchecks, Dokploy/Traefik labels |
|
||||
| `.env.example` | Environment template |
|
||||
|
||||
**Environment**
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `APP_PORT` | `5000` | Host port / in-container listen port |
|
||||
| `APP_PORT` / `PORT` | `5000` | Host port / in-container listen port |
|
||||
| `SECRET_KEY` | (random) | Session signing key — set in production |
|
||||
| `DATABASE_URL` | compose DSN | PostgreSQL connection string |
|
||||
| `POSTGRES_*` | `amnezia` | DB credentials for the `db` service |
|
||||
@@ -222,6 +277,66 @@ GitHub Actions workflows in `.github/workflows/`:
|
||||
|
||||
## 📋 Fix / changelog (this fork)
|
||||
|
||||
### v2.6.2
|
||||
* **Panel backup → PostgreSQL dump** — Settings backup downloads `.sql` via `pg_dump`; restore accepts `.sql` or legacy `data.json`. JSON export kept as secondary link.
|
||||
|
||||
### v2.6.1
|
||||
* **Move connections between servers** — restored: select configs on a server page and move to another server (recreates peers, keeps user links). `ToggleConnectionRequest` kept intact.
|
||||
|
||||
### v2.6.0 — stable Dokploy / Docker release
|
||||
* **Dokploy-ready compose** — `dokploy-network`, Traefik labels, port **5000**, no fixed `container_name`.
|
||||
* **Docker startup** — `uvicorn` with proxy headers; SSL inside container disabled when `PANEL_IN_DOCKER` / `PANEL_BEHIND_PROXY`.
|
||||
* **Postgres healthcheck** — verifies password (catches `POSTGRES_PASSWORD` vs volume mismatch).
|
||||
* **Rollback move-connections** — removed server-to-server config move (v2.5.4); restored `ToggleConnectionRequest` (fixes crash loop on deploy).
|
||||
* **Docs** — full Dokploy deploy/redeploy/password-reset guide in README.
|
||||
|
||||
### v2.5.11
|
||||
* **Postgres healthcheck** — verifies DB password (surfaces `POSTGRES_PASSWORD` / volume mismatch before panel starts).
|
||||
|
||||
### v2.5.10
|
||||
* **Docker startup** — run `uvicorn` directly in `CMD` (no shell entrypoint); rebuild required after deploy.
|
||||
|
||||
### v2.5.9
|
||||
* **Remove move connections between servers** — feature rolled back; fixes startup crash (`ToggleConnectionRequest` was missing after v2.5.4).
|
||||
|
||||
### v2.5.8
|
||||
* **Docker/Dokploy: panel actually serves HTTP** — entrypoint waits for Postgres, runs `uvicorn` with proxy headers; SSL inside the container is forced off when `PANEL_IN_DOCKER` / `PANEL_BEHIND_PROXY` is set (fixes empty replies on port 5000 behind Traefik).
|
||||
|
||||
### v2.5.7
|
||||
* **Dokploy / Traefik 404 fix** — `docker-compose.yml` joins `dokploy-network`, Traefik labels point to port **5000**; removed fixed `container_name`; README Dokploy domain setup.
|
||||
|
||||
### v2.5.6
|
||||
* **Fix 404 after in-panel update (no tunnel)** — safer Docker restart, validate update before restart, friendly 404 page with panel link.
|
||||
|
||||
### v2.5.5
|
||||
* **Fix 404 after panel update** — correct tunnel/local port when `APP_PORT` is set (Docker), health check before reload, auto-restart quick tunnels after reboot, reliable archive download URLs.
|
||||
|
||||
### v2.5.3
|
||||
* **Fix gray Update panel button** — enabled by default; Docker installs allow in-container archive updates when `/app` is writable.
|
||||
|
||||
### v2.5.2
|
||||
* **Tunnels UI redesign** — Settings → Tunnels: grouped layout (public access / outbound VPN), provider cards, status pills, responsive grid.
|
||||
|
||||
### 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).
|
||||
|
||||
### v2.4.0
|
||||
* **Auto-update from Gitea**: Settings → About checks https://git.evilfox.cc/test2/Amnezia-Web-Panel-main releases and can install the latest tag via `git fetch` + checkout, then restart (git install only).
|
||||
|
||||
### v2.3.0
|
||||
* **Cascade removed** — double-VPN feature dropped from the panel (manager, API, UI, i18n).
|
||||
|
||||
### v2.2.1
|
||||
* **Cascade egress verify fix**: bind curl to ENTRY `awg0`/`wg0` IP (client-subnet policy route), not cascade peer Address.
|
||||
* Soft-warn (no rollback) when policy route via `cascade` is OK but public-IP echo services fail.
|
||||
|
||||
### v2.2.0
|
||||
* **Cascade (double VPN) restored** — later removed in **v2.3.0**.
|
||||
* Inspired by [ryderams/amneziawg-cascade](https://github.com/ryderams/amneziawg-cascade); logic ran over panel SSH (**no remote curl|bash**).
|
||||
|
||||
### v2.1.0
|
||||
* **NaiveProxy — stable**: production-ready install (Caddy + klzgrad/forwardproxy), share links always include `:443`.
|
||||
* **Client notes (important)**:
|
||||
@@ -253,7 +368,7 @@ GitHub Actions workflows in `.github/workflows/`:
|
||||
### v1.6.0
|
||||
* **3x-ui multi-panel**: register several 3x-ui servers in Settings; pick a panel and load VLESS inbounds over its API when creating users/invites (share link comes from 3x-ui).
|
||||
* **Docker / CI**: refreshed `Dockerfile` + compose, `.env.example`, CI checks, GHCR image workflow.
|
||||
* **Cascade removed** for now (pending redesign — showed active while internet often did not work).
|
||||
* **Cascade** was temporarily removed in this release (briefly restored in v2.2.x, removed again in **v2.3.0**).
|
||||
* **WG/AWG backups**: ZIP export of client `.conf` + restore with loading feedback.
|
||||
* **API performance**: in-memory data cache, faster server check/stats.
|
||||
* **Share / guest**: one-tap “Copy key”.
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
"""Database package for Amnezia Web Panel (PostgreSQL 17)."""
|
||||
|
||||
from .backup import backup_filename, export_database_sql, restore_database_sql
|
||||
from .connection import close_pool, get_database_url, init_schema
|
||||
from .store import (
|
||||
clear_tunnel_state,
|
||||
ensure_db_ready,
|
||||
export_data_dict,
|
||||
import_from_json_file,
|
||||
invalidate_data_cache,
|
||||
load_data,
|
||||
load_tunnel_state,
|
||||
normalize_import_data,
|
||||
save_data,
|
||||
save_tunnel_state,
|
||||
update_tunnel_state,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'backup_filename',
|
||||
'clear_tunnel_state',
|
||||
'close_pool',
|
||||
'ensure_db_ready',
|
||||
'export_data_dict',
|
||||
'export_database_sql',
|
||||
'get_database_url',
|
||||
'import_from_json_file',
|
||||
'init_schema',
|
||||
'invalidate_data_cache',
|
||||
'load_data',
|
||||
'load_tunnel_state',
|
||||
'normalize_import_data',
|
||||
'restore_database_sql',
|
||||
'save_data',
|
||||
'save_tunnel_state',
|
||||
'update_tunnel_state',
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""PostgreSQL backup/restore for the panel database."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .connection import get_database_url
|
||||
from .store import invalidate_data_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def backup_filename() -> str:
|
||||
stamp = datetime.now(timezone.utc).strftime('%Y-%m-%d_%H%M%S')
|
||||
return f'amnezia_panel_backup_{stamp}.sql'
|
||||
|
||||
|
||||
def export_database_sql() -> bytes:
|
||||
"""Create a plain SQL dump of the panel PostgreSQL database."""
|
||||
url = get_database_url()
|
||||
proc = subprocess.run(
|
||||
[
|
||||
'pg_dump',
|
||||
'--dbname', url,
|
||||
'--no-owner',
|
||||
'--no-acl',
|
||||
'--clean',
|
||||
'--if-exists',
|
||||
],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
err = proc.stderr.decode('utf-8', errors='replace').strip()
|
||||
raise RuntimeError(err or 'pg_dump failed')
|
||||
if not proc.stdout:
|
||||
raise RuntimeError('pg_dump returned empty dump')
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def restore_database_sql(data: bytes) -> None:
|
||||
"""Restore panel data from a plain SQL dump produced by pg_dump."""
|
||||
if not data or not data.strip():
|
||||
raise ValueError('Empty backup file')
|
||||
url = get_database_url()
|
||||
proc = subprocess.run(
|
||||
['psql', '--dbname', url, '-v', 'ON_ERROR_STOP=1', '-q'],
|
||||
input=data,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
err = proc.stderr.decode('utf-8', errors='replace').strip()
|
||||
out = proc.stdout.decode('utf-8', errors='replace').strip()
|
||||
raise RuntimeError(err or out or 'psql restore failed')
|
||||
invalidate_data_cache()
|
||||
logger.info('PostgreSQL backup restored successfully')
|
||||
+150
-7
@@ -10,8 +10,10 @@ import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
@@ -83,6 +85,147 @@ def _as_uuid(value: Any) -> UUID:
|
||||
return UUID(str(value))
|
||||
|
||||
|
||||
def _is_valid_uuid(value: Any) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
try:
|
||||
UUID(str(value))
|
||||
return True
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _coerce_uuid(value: Any, *, default: Optional[str] = None) -> str:
|
||||
if _is_valid_uuid(value):
|
||||
return str(UUID(str(value)))
|
||||
if default is not None:
|
||||
return default
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def _as_list(value: Any) -> list:
|
||||
return list(value) if isinstance(value, list) else []
|
||||
|
||||
|
||||
def normalize_import_data(data: dict) -> dict:
|
||||
"""Prepare legacy data.json (or partial exports) for PostgreSQL constraints."""
|
||||
data = copy.deepcopy(data or {})
|
||||
data['servers'] = [s for s in _as_list(data.get('servers')) if isinstance(s, dict)]
|
||||
data['users'] = [u for u in _as_list(data.get('users')) if isinstance(u, dict)]
|
||||
data['user_connections'] = [c for c in _as_list(data.get('user_connections')) if isinstance(c, dict)]
|
||||
data['api_tokens'] = [t for t in _as_list(data.get('api_tokens')) if isinstance(t, dict)]
|
||||
data['invite_links'] = [l for l in _as_list(data.get('invite_links')) if isinstance(l, dict)]
|
||||
if not isinstance(data.get('settings'), dict):
|
||||
data['settings'] = {}
|
||||
|
||||
id_map: dict[str, str] = {}
|
||||
|
||||
def map_user_id(value: Any) -> Optional[str]:
|
||||
if value is None or value == '':
|
||||
return None
|
||||
key = str(value)
|
||||
if _is_valid_uuid(key):
|
||||
resolved = str(UUID(key))
|
||||
id_map.setdefault(key, resolved)
|
||||
return resolved
|
||||
if key not in id_map:
|
||||
id_map[key] = str(uuid.uuid4())
|
||||
return id_map[key]
|
||||
|
||||
used_usernames: set[str] = set()
|
||||
for index, user in enumerate(data['users']):
|
||||
if not isinstance(user, dict):
|
||||
continue
|
||||
old_id = user.get('id')
|
||||
if old_id is None or old_id == '':
|
||||
user['id'] = str(uuid.uuid4())
|
||||
else:
|
||||
key = str(old_id)
|
||||
if _is_valid_uuid(key):
|
||||
user['id'] = str(UUID(key))
|
||||
id_map.setdefault(key, user['id'])
|
||||
else:
|
||||
user['id'] = map_user_id(key) or str(uuid.uuid4())
|
||||
|
||||
username = (user.get('username') or '').strip() or f'user{index + 1}'
|
||||
base = username
|
||||
suffix = 2
|
||||
while username.lower() in used_usernames:
|
||||
username = f'{base}_{suffix}'
|
||||
suffix += 1
|
||||
user['username'] = username
|
||||
used_usernames.add(username.lower())
|
||||
|
||||
user.setdefault('password_hash', '')
|
||||
user.setdefault('role', 'user')
|
||||
user.setdefault('enabled', True)
|
||||
|
||||
valid_user_ids = {str(u['id']) for u in data['users'] if isinstance(u, dict) and u.get('id')}
|
||||
|
||||
for server in data['servers']:
|
||||
if not isinstance(server, dict):
|
||||
continue
|
||||
server['server_info'] = _as_dict(server.get('server_info'))
|
||||
server['protocols'] = _as_dict(server.get('protocols'))
|
||||
|
||||
for conn in data['user_connections']:
|
||||
if not isinstance(conn, dict):
|
||||
continue
|
||||
conn['id'] = _coerce_uuid(conn.get('id'))
|
||||
mapped_uid = map_user_id(conn.get('user_id'))
|
||||
if mapped_uid:
|
||||
conn['user_id'] = mapped_uid
|
||||
|
||||
data['user_connections'] = [
|
||||
c for c in data['user_connections']
|
||||
if isinstance(c, dict) and str(c.get('user_id')) in valid_user_ids
|
||||
]
|
||||
|
||||
seen_hashes: set[str] = set()
|
||||
normalized_tokens = []
|
||||
for token in data['api_tokens']:
|
||||
if not isinstance(token, dict):
|
||||
continue
|
||||
mapped_uid = map_user_id(token.get('user_id'))
|
||||
if not mapped_uid or mapped_uid not in valid_user_ids:
|
||||
continue
|
||||
token['id'] = _coerce_uuid(token.get('id'))
|
||||
token['user_id'] = mapped_uid
|
||||
token_hash = (token.get('token_hash') or '').strip()
|
||||
if not token_hash or token_hash in seen_hashes:
|
||||
token_hash = secrets.token_hex(32)
|
||||
token['token_hash'] = token_hash
|
||||
seen_hashes.add(token_hash)
|
||||
token.setdefault('token_prefix', (token_hash[:8] if token_hash else ''))
|
||||
normalized_tokens.append(token)
|
||||
data['api_tokens'] = normalized_tokens
|
||||
|
||||
seen_invite_tokens: set[str] = set()
|
||||
for link in data['invite_links']:
|
||||
if not isinstance(link, dict):
|
||||
continue
|
||||
link['id'] = _coerce_uuid(link.get('id'))
|
||||
mapped_uid = map_user_id(link.get('user_id'))
|
||||
link['user_id'] = mapped_uid if mapped_uid in valid_user_ids else ''
|
||||
token = (link.get('token') or '').strip()
|
||||
if not token or token in seen_invite_tokens:
|
||||
token = secrets.token_urlsafe(16)
|
||||
while token in seen_invite_tokens:
|
||||
token = secrets.token_urlsafe(16)
|
||||
link['token'] = token
|
||||
seen_invite_tokens.add(token)
|
||||
|
||||
guest = data['settings'].get('guest')
|
||||
if isinstance(guest, dict):
|
||||
mapped_guest = map_user_id(guest.get('user_id'))
|
||||
if mapped_guest in valid_user_ids:
|
||||
guest['user_id'] = mapped_guest
|
||||
elif guest.get('user_id'):
|
||||
guest['user_id'] = ''
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _merge_settings(raw: Optional[dict]) -> dict:
|
||||
settings = json.loads(json.dumps(DEFAULT_SETTINGS))
|
||||
if not isinstance(raw, dict):
|
||||
@@ -293,6 +436,7 @@ def load_data() -> dict:
|
||||
def save_data(data: dict) -> None:
|
||||
"""Replace panel state in a single transaction (same semantics as rewriting data.json)."""
|
||||
global _DATA_CACHE
|
||||
data = normalize_import_data(data)
|
||||
init_schema()
|
||||
servers = data.get('servers') or []
|
||||
users = data.get('users') or []
|
||||
@@ -484,12 +628,7 @@ def import_from_json_file(path: str | os.PathLike, *, backup: bool = True) -> bo
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f'Invalid data.json: expected object, got {type(data).__name__}')
|
||||
|
||||
data.setdefault('servers', [])
|
||||
data.setdefault('users', [])
|
||||
data.setdefault('user_connections', [])
|
||||
data.setdefault('api_tokens', [])
|
||||
data.setdefault('invite_links', [])
|
||||
data.setdefault('settings', {})
|
||||
data = normalize_import_data(data)
|
||||
|
||||
save_data(data)
|
||||
|
||||
@@ -555,4 +694,8 @@ def ensure_db_ready(legacy_data_file: Optional[str] = None) -> None:
|
||||
init_schema()
|
||||
if legacy_data_file and is_database_empty() and os.path.exists(legacy_data_file):
|
||||
logger.info('Empty database — importing legacy %s', legacy_data_file)
|
||||
import_from_json_file(legacy_data_file)
|
||||
try:
|
||||
import_from_json_file(legacy_data_file)
|
||||
except Exception:
|
||||
logger.exception('Legacy data.json import failed — panel will start with empty DB')
|
||||
invalidate_data_cache()
|
||||
|
||||
+27
-7
@@ -1,18 +1,21 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:17
|
||||
container_name: amnezia_panel_db
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-amnezia}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-amnezia}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-amnezia_panel}
|
||||
volumes:
|
||||
- amnezia_pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-amnezia} -d ${POSTGRES_DB:-amnezia_panel}"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} && PGPASSWORD=$${POSTGRES_PASSWORD} psql -U $${POSTGRES_USER} -d $${POSTGRES_DB} -c 'SELECT 1' >/dev/null",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
@@ -23,7 +26,6 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: amnezia-panel:local
|
||||
container_name: amnezia_panel
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -33,15 +35,33 @@ services:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-amnezia}:${POSTGRES_PASSWORD:-amnezia}@db:5432/${POSTGRES_DB:-amnezia_panel}
|
||||
SECRET_KEY: ${SECRET_KEY:-}
|
||||
APP_PORT: "5000"
|
||||
PORT: "5000"
|
||||
PANEL_IN_DOCKER: "1"
|
||||
PANEL_BEHIND_PROXY: "1"
|
||||
volumes:
|
||||
- amnezia_data:/app/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
- dokploy-network
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=dokploy-network
|
||||
- traefik.http.services.amnezia_panel.loadbalancer.server.port=5000
|
||||
expose:
|
||||
- "5000"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5000/docs >/dev/null || exit 1"]
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:5000/health >/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
start_period: 60s
|
||||
|
||||
networks:
|
||||
internal:
|
||||
dokploy-network:
|
||||
external: true
|
||||
name: dokploy-network
|
||||
|
||||
volumes:
|
||||
amnezia_data:
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
"""Panel self-update from the EvilFox Gitea repository (git or release archive)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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__)
|
||||
|
||||
DEFAULT_REPO_URL = 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main'
|
||||
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()
|
||||
|
||||
|
||||
def repo_url() -> str:
|
||||
return _env('PANEL_UPDATE_REPO_URL', DEFAULT_REPO_URL).rstrip('/')
|
||||
|
||||
|
||||
def git_url() -> str:
|
||||
return _env('PANEL_UPDATE_GIT_URL', DEFAULT_GIT_URL)
|
||||
|
||||
|
||||
def api_latest_url() -> str:
|
||||
return _env('PANEL_UPDATE_API_LATEST', DEFAULT_API_LATEST)
|
||||
|
||||
|
||||
def parse_version(value: str) -> tuple:
|
||||
"""Parse semver-ish tags like v2.3.0 / 2.3.0-beta into a comparable tuple."""
|
||||
raw = (value or '').strip()
|
||||
if not raw:
|
||||
return (0, 0, 0, 1, '')
|
||||
raw = raw.lstrip('vV')
|
||||
main, _, pre = raw.partition('-')
|
||||
parts = []
|
||||
for piece in main.split('.'):
|
||||
if piece.isdigit():
|
||||
parts.append(int(piece))
|
||||
else:
|
||||
m = re.match(r'(\d+)', piece or '')
|
||||
parts.append(int(m.group(1)) if m else 0)
|
||||
while len(parts) < 3:
|
||||
parts.append(0)
|
||||
pre_rank = 0 if not pre else 1
|
||||
return (parts[0], parts[1], parts[2], pre_rank, pre)
|
||||
|
||||
|
||||
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(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
encoding='utf-8',
|
||||
errors='replace',
|
||||
)
|
||||
return proc.returncode, (proc.stdout or '').strip(), (proc.stderr or '').strip()
|
||||
except FileNotFoundError:
|
||||
return 127, '', f'Command not found: {cmd[0]}'
|
||||
except subprocess.TimeoutExpired:
|
||||
return 124, '', f'Timeout running: {" ".join(cmd)}'
|
||||
|
||||
|
||||
class UpdateManager:
|
||||
def __init__(self, app_root: str, current_version: str):
|
||||
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:
|
||||
if os.environ.get('PANEL_UPDATE_DISABLE_DOCKER', '').strip().lower() in ('1', 'true', 'yes'):
|
||||
return False
|
||||
# In-container archive updates are allowed by default when /app is writable.
|
||||
return True
|
||||
|
||||
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 = 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
|
||||
|
||||
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': 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(),
|
||||
}
|
||||
|
||||
def _ensure_update_remote(self) -> Optional[str]:
|
||||
code, out, err = _run(['git', 'remote'], self.app_root, timeout=15)
|
||||
if code != 0:
|
||||
return err or out or 'git remote failed'
|
||||
remotes = set((out or '').split())
|
||||
target = git_url()
|
||||
if UPDATE_REMOTE in remotes:
|
||||
code, _, err = _run(
|
||||
['git', 'remote', 'set-url', UPDATE_REMOTE, target],
|
||||
self.app_root,
|
||||
timeout=15,
|
||||
)
|
||||
else:
|
||||
code, _, err = _run(
|
||||
['git', 'remote', 'add', UPDATE_REMOTE, target],
|
||||
self.app_root,
|
||||
timeout=15,
|
||||
)
|
||||
if code != 0:
|
||||
return err or 'Failed to configure update remote'
|
||||
return None
|
||||
|
||||
def working_tree_dirty(self) -> bool:
|
||||
code, out, _ = _run(['git', 'status', '--porcelain'], self.app_root, timeout=30)
|
||||
return code == 0 and bool(out.strip())
|
||||
|
||||
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. Confirm update anyway to force checkout, '
|
||||
'or commit/stash changes first.'
|
||||
),
|
||||
'needs_confirm_dirty': True,
|
||||
}
|
||||
|
||||
err = self._ensure_update_remote()
|
||||
if err:
|
||||
return {'status': 'error', 'message': err}
|
||||
|
||||
code, out, stderr = _run(
|
||||
['git', 'fetch', '--tags', '--force', UPDATE_REMOTE],
|
||||
self.app_root,
|
||||
timeout=180,
|
||||
)
|
||||
if code != 0:
|
||||
return {'status': 'error', 'message': f'git fetch failed: {stderr or out or code}'}
|
||||
|
||||
# Ensure tag is present locally
|
||||
code, _, _ = _run(
|
||||
['git', 'rev-parse', '-q', '--verify', f'refs/tags/{target}'],
|
||||
self.app_root,
|
||||
timeout=15,
|
||||
)
|
||||
if code != 0:
|
||||
_run(
|
||||
['git', 'fetch', '--depth', '1', UPDATE_REMOTE, f'refs/tags/{target}:refs/tags/{target}'],
|
||||
self.app_root,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
checkout_ref = f'tags/{target}'
|
||||
code, out, stderr = _run(
|
||||
['git', 'checkout', '--force', checkout_ref],
|
||||
self.app_root,
|
||||
timeout=60,
|
||||
)
|
||||
if code != 0:
|
||||
code_m, out_m, err_m = _run(
|
||||
['git', 'checkout', '--force', '-B', 'main', f'{UPDATE_REMOTE}/main'],
|
||||
self.app_root,
|
||||
timeout=60,
|
||||
)
|
||||
if code_m != 0:
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': f'git checkout {target} failed: {stderr or out or err_m or out_m}',
|
||||
}
|
||||
checkout_ref = f'{UPDATE_REMOTE}/main'
|
||||
|
||||
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} 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]:
|
||||
urls: list[str] = []
|
||||
api_base = api_latest_url().rsplit('/releases/latest', 1)[0]
|
||||
for endpoint in (f'{api_base}/releases/tags/{tag}', api_latest_url()):
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
endpoint,
|
||||
headers={'Accept': 'application/json', 'User-Agent': 'Amnezia-Web-Panel-Updater'},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
payload = json.loads(resp.read().decode('utf-8', errors='replace') or '{}')
|
||||
zipball = (payload.get('zipball_url') or '').strip()
|
||||
if zipball:
|
||||
urls.append(zipball)
|
||||
except Exception:
|
||||
pass
|
||||
base = repo_url()
|
||||
urls.extend([
|
||||
f'{base}/archive/{tag}.zip',
|
||||
f'{api_base}/archive/{tag}.zip',
|
||||
])
|
||||
seen = set()
|
||||
ordered: list[str] = []
|
||||
for url in urls:
|
||||
if url and url not in seen:
|
||||
seen.add(url)
|
||||
ordered.append(url)
|
||||
return ordered
|
||||
|
||||
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 _verify_tree(self, root: str) -> Optional[str]:
|
||||
app_py = os.path.join(root, 'app.py')
|
||||
if not os.path.isfile(app_py):
|
||||
return 'Release archive is missing app.py'
|
||||
code, _, err = _run([sys.executable, '-m', 'py_compile', app_py], root, timeout=60)
|
||||
if code != 0:
|
||||
return f'Updated app.py failed validation: {err or code}'
|
||||
return None
|
||||
|
||||
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'}
|
||||
verify_err = self._verify_tree(src_root)
|
||||
if verify_err:
|
||||
return {'status': 'error', 'message': verify_err, 'method': 'archive'}
|
||||
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(app_root: str, delay_sec: float = 1.5) -> None:
|
||||
in_docker = os.path.exists('/.dockerenv') or os.environ.get('PANEL_IN_DOCKER') == '1'
|
||||
|
||||
def _restart():
|
||||
time.sleep(max(0.5, float(delay_sec)))
|
||||
cwd = os.path.abspath(app_root or os.getcwd())
|
||||
argv = [sys.executable] + sys.argv
|
||||
logger.info('Restarting panel after update: %s (cwd=%s, docker=%s)', argv, cwd, in_docker)
|
||||
if in_docker:
|
||||
# Let Docker restart policy bring the container back with updated /app files.
|
||||
os._exit(0)
|
||||
try:
|
||||
os.chdir(cwd)
|
||||
proc = subprocess.Popen(
|
||||
argv,
|
||||
cwd=cwd,
|
||||
start_new_session=True,
|
||||
close_fds=(os.name != 'nt'),
|
||||
)
|
||||
time.sleep(2.0)
|
||||
if proc.poll() is None:
|
||||
logger.info('New panel process started (pid=%s), stopping old process', proc.pid)
|
||||
os._exit(0)
|
||||
except Exception:
|
||||
logger.exception('subprocess restart failed; trying execv')
|
||||
try:
|
||||
os.chdir(cwd)
|
||||
os.execv(sys.executable, argv)
|
||||
except Exception:
|
||||
logger.exception('Failed to restart after update')
|
||||
os._exit(1)
|
||||
|
||||
threading.Thread(target=_restart, name='panel-update-restart', daemon=False).start()
|
||||
@@ -1282,6 +1282,7 @@ a:hover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -1294,6 +1295,45 @@ a:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
.conn-select-wrap {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conn-select {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.connections-bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.connections-select-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.connections-selected-count {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.client-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1623,6 +1663,319 @@ a:hover {
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
/* ===== Tunnels (Settings) ===== */
|
||||
.tunnels-card .tunnels-header {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.tunnels-card .tunnels-header .card-title {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.tunnels-card .tunnels-subtitle {
|
||||
margin: var(--space-xs) 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.tunnels-local {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
margin-bottom: var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-focus);
|
||||
background: linear-gradient(135deg, rgba(139, 92, 246, 0.08), rgba(59, 130, 246, 0.06));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.tunnels-local-icon {
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-gradient);
|
||||
font-size: 1.25rem;
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
.tunnels-local-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tunnels-local-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.tunnels-local-url {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.tunnels-local-url code {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tunnels-section {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.tunnels-section:last-of-type {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.tunnels-section-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 var(--space-sm) var(--space-xs);
|
||||
}
|
||||
|
||||
.tunnels-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.tunnel-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
transition: border-color var(--transition-base), box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.tunnel-card:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.tunnel-card--running {
|
||||
border-color: var(--success-border);
|
||||
box-shadow: 0 0 0 1px var(--success-border), inset 0 1px 0 rgba(34, 197, 94, 0.06);
|
||||
}
|
||||
|
||||
.tunnel-card--connected {
|
||||
border-color: var(--success-border);
|
||||
box-shadow: 0 0 0 1px var(--success-border);
|
||||
}
|
||||
|
||||
.tunnel-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.tunnel-card-icon {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tunnel-card-icon--cloudflare { background: linear-gradient(135deg, #f38020, #faae40); }
|
||||
.tunnel-card-icon--ngrok { background: linear-gradient(135deg, #1f2d3d, #3d5a80); }
|
||||
.tunnel-card-icon--warp { background: linear-gradient(135deg, #f38020, #e85d04); }
|
||||
.tunnel-card-icon--nordvpn { background: linear-gradient(135deg, #4687ff, #2b4eff); }
|
||||
.tunnel-card-icon--local { background: var(--accent-gradient); }
|
||||
|
||||
.tunnel-card-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tunnel-card-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.tunnel-card-desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
margin: var(--space-xs) 0 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tunnel-status-pill {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
white-space: nowrap;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tunnel-status-pill--ok {
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
border-color: var(--success-border);
|
||||
}
|
||||
|
||||
.tunnel-status-pill--info {
|
||||
background: var(--info-bg);
|
||||
color: var(--info);
|
||||
border-color: rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.tunnel-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.tunnel-url-box {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px dashed var(--border-color);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.tunnel-url-box--empty {
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tunnel-url-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.tunnel-url-row:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.tunnel-url-row code {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-accent);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tunnel-status-line {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.tunnel-status-line--server {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-accent);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.tunnel-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tunnel-card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.tunnel-card-actions .btn {
|
||||
flex: 1;
|
||||
min-width: 7rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tunnel-card-actions--triple .btn {
|
||||
min-width: 5.5rem;
|
||||
}
|
||||
|
||||
.tunnel-nordvpn-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.tunnels-footnote {
|
||||
margin: var(--space-md) 0 0;
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
border-left: 3px solid var(--accent);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tunnels-local {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tunnels-local-url {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tunnels-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tunnel-nordvpn-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.mt-md {
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
data-username="{{ server.username }}" data-auth="{{ 'key' if server.private_key else 'password' }}"
|
||||
data-ssl-domain="{{ (server.server_info or {}).get('ssl_domain', '') }}"
|
||||
data-ssl-email="{{ (server.server_info or {}).get('ssl_email', '') }}"
|
||||
data-connect-domain="{{ (server.server_info or {}).get('connect_domain', '') }}"
|
||||
draggable="true">
|
||||
<div class="server-meta">
|
||||
<div class="server-icon">{{ icon('server') }}</div>
|
||||
@@ -32,6 +33,10 @@
|
||||
<span class="ping-ms text-muted" id="ping-ms-{{ loop.index0 }}"></span>
|
||||
</div>
|
||||
<div class="server-host">{{ server.host }}:{{ server.ssh_port }}</div>
|
||||
{% set connect_domain = (server.server_info or {}).get('connect_domain') %}
|
||||
{% if connect_domain and connect_domain != server.host %}
|
||||
<div class="server-host text-muted" title="{{ _('server_connect_domain_hint') }}">→ {{ connect_domain }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -139,6 +144,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: var(--space-md)">
|
||||
<label class="form-label">{{ _('server_connect_domain') }}</label>
|
||||
<input class="form-input" type="text" id="editServerConnectDomain" placeholder="vpn.example.com">
|
||||
<div class="form-hint">{{ _('server_connect_domain_hint') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: var(--space-md)">
|
||||
<label class="form-label">{{ _('server_ssl_domain') }}</label>
|
||||
<input class="form-input" type="text" id="editServerSslDomain" placeholder="vpn.example.com">
|
||||
@@ -214,6 +225,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: var(--space-md)">
|
||||
<label class="form-label">{{ _('server_connect_domain') }}</label>
|
||||
<input class="form-input" type="text" id="serverConnectDomain" placeholder="vpn.example.com">
|
||||
<div class="form-hint">{{ _('server_connect_domain_hint') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: var(--space-md)">
|
||||
<label class="form-label">{{ _('server_ssl_domain') }}</label>
|
||||
<input class="form-input" type="text" id="serverSslDomain" placeholder="vpn.example.com">
|
||||
@@ -268,6 +285,7 @@
|
||||
private_key: document.getElementById('serverKey').value,
|
||||
ssl_domain: document.getElementById('serverSslDomain').value.trim(),
|
||||
ssl_email: document.getElementById('serverSslEmail').value.trim(),
|
||||
connect_domain: document.getElementById('serverConnectDomain').value.trim(),
|
||||
};
|
||||
|
||||
const result = await apiCall('/api/servers/add', 'POST', data);
|
||||
@@ -318,6 +336,7 @@
|
||||
document.getElementById('editServerKey').value = '';
|
||||
document.getElementById('editServerSslDomain').value = card.dataset.sslDomain || '';
|
||||
document.getElementById('editServerSslEmail').value = card.dataset.sslEmail || '';
|
||||
document.getElementById('editServerConnectDomain').value = card.dataset.connectDomain || '';
|
||||
|
||||
// Pre-select the tab matching the server's current auth method.
|
||||
const authIsKey = card.dataset.auth === 'key';
|
||||
@@ -349,6 +368,7 @@
|
||||
private_key: document.getElementById('editServerKey').value || null,
|
||||
ssl_domain: document.getElementById('editServerSslDomain').value.trim(),
|
||||
ssl_email: document.getElementById('editServerSslEmail').value.trim(),
|
||||
connect_domain: document.getElementById('editServerConnectDomain').value.trim(),
|
||||
};
|
||||
await apiCall(`/api/servers/${idx}/edit`, 'POST', body);
|
||||
showToast(_('server_saved'), 'success');
|
||||
|
||||
+256
-1
@@ -1,4 +1,4 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/icons.html" import icon %}
|
||||
|
||||
{% block title_extra %} — {{ server.name }}{% endblock %}
|
||||
@@ -155,6 +155,25 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="connectDomainSection" class="hidden" style="margin-top: var(--space-md);">
|
||||
<div class="divider"></div>
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label class="form-label">{{ _('server_connect_domain') }}</label>
|
||||
<div class="flex gap-sm" style="flex-wrap: wrap; align-items: flex-end;">
|
||||
<div style="flex: 0 0 200px; min-width: 160px;">
|
||||
<select class="form-input" id="connectDomainProto" onchange="refreshConnectDomainFields()"></select>
|
||||
</div>
|
||||
<div style="flex: 1; min-width: 200px;">
|
||||
<input class="form-input" type="text" id="connectDomainInput" placeholder="vpn.example.com">
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="connectDomainSaveBtn" onclick="saveConnectDomain()">
|
||||
{{ _('save') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-hint" style="margin-top: var(--space-xs);">{{ _('server_connect_domain_awg_hint') }}</div>
|
||||
<div class="text-muted text-sm" id="connectDomainEffective" style="margin-top: var(--space-xs);"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Installed Applications Section -->
|
||||
@@ -544,12 +563,23 @@
|
||||
<option value="telemt">Telemt</option>
|
||||
<option value="wireguard">WireGuard</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary btn-sm hidden" id="moveConnectionsBtn" onclick="openMoveConnectionsModal()">
|
||||
<span>{{ icon('share') }}</span> {{ _('move_connections') }}
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="openAddConnectionModal()">
|
||||
<span>{{ icon('plus') }}</span> {{ _('add') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="connectionsBulkBar" class="connections-bulk-bar hidden">
|
||||
<label class="connections-select-all">
|
||||
<input type="checkbox" id="connSelectAll" onchange="toggleSelectAllConnections(this.checked)">
|
||||
<span>{{ _('select_all') }}</span>
|
||||
</label>
|
||||
<span class="connections-selected-count" id="connSelectedCount">0</span>
|
||||
</div>
|
||||
|
||||
<div id="connectionsList">
|
||||
<div class="loading-overlay" id="connectionsLoading" style="display:none;">
|
||||
<div class="loading-spinner"></div>
|
||||
@@ -969,6 +999,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Move Connections Modal ===== -->
|
||||
<div class="modal-backdrop" id="moveConnectionsModal">
|
||||
<div class="modal" style="max-width: 520px;">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">{{ _('move_connections_title') }}</h2>
|
||||
<button class="modal-close" onclick="closeModal('moveConnectionsModal')">×</button>
|
||||
</div>
|
||||
<p class="form-hint">{{ _('move_connections_hint') }}</p>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="moveTargetServer">{{ _('move_connections_target') }}</label>
|
||||
<select class="form-select" id="moveTargetServer"></select>
|
||||
</div>
|
||||
<label class="form-check" style="display:flex; align-items:center; gap:var(--space-sm); margin-bottom: var(--space-md);">
|
||||
<input type="checkbox" id="moveDeleteSource" checked>
|
||||
<span>{{ _('move_connections_delete_source') }}</span>
|
||||
</label>
|
||||
<div class="form-hint" style="border-left: 3px solid var(--warning); padding-left: var(--space-sm);">
|
||||
{{ _('move_connections_warning') }}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('moveConnectionsModal')">{{ _('cancel') }}</button>
|
||||
<button type="button" class="btn btn-primary" onclick="moveSelectedConnections()" id="moveConnectionsSubmitBtn">
|
||||
<span id="moveConnectionsSubmitText">{{ _('move_connections') }}</span>
|
||||
<div class="spinner hidden" id="moveConnectionsSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Connection Config Modal (with Tabs) ===== -->
|
||||
<div class="modal-backdrop" id="configModal">
|
||||
<div class="modal" style="max-width: 600px;">
|
||||
@@ -1090,7 +1149,9 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/htmlmixed/htmlmixed.min.js"></script>
|
||||
<script>
|
||||
const SERVER_ID = {{ server_id }};
|
||||
const ALL_SERVERS = {{ servers_for_move | tojson }};
|
||||
const SERVER_HOST = "{{ server.host }}";
|
||||
const SERVER_CONNECT_DOMAIN = {{ ((server.server_info or {}).get('connect_domain') or '') | tojson }};
|
||||
const SERVER_SSL_DOMAIN = {{ ((server.server_info or {}).get('ssl_domain') or '') | tojson }};
|
||||
const SERVER_SSL_EMAIL = {{ ((server.server_info or {}).get('ssl_email') or '') | tojson }};
|
||||
const MARKETPLACE_APPS = [
|
||||
@@ -1112,6 +1173,8 @@
|
||||
{ id: 'services', icon: 'wrench', titleKey: 'services' },
|
||||
{ id: 'web_servers', icon: 'globe', titleKey: 'web_servers' },
|
||||
];
|
||||
const WG_AWG_BASES = new Set(['awg', 'awg2', 'awg_legacy', 'wireguard']);
|
||||
let connectDomainEffective = {};
|
||||
let currentInstallProto = 'awg';
|
||||
let currentInstallAnother = false;
|
||||
let currentConfig = '';
|
||||
@@ -1171,6 +1234,94 @@
|
||||
return Object.keys(installedProtocols).some(key => protoBase(key) === base && installedProtocols[key]);
|
||||
}
|
||||
|
||||
function hasWgAwgInstalled() {
|
||||
return Object.entries(installedProtocols || {}).some(([key, installed]) => installed && WG_AWG_BASES.has(protoBase(key)));
|
||||
}
|
||||
|
||||
function listInstalledWgAwgProtocols() {
|
||||
const protos = new Set();
|
||||
Object.entries(installedProtocols || {}).forEach(([key, installed]) => {
|
||||
if (installed && WG_AWG_BASES.has(protoBase(key))) protos.add(key);
|
||||
});
|
||||
return Array.from(protos).sort((a, b) => {
|
||||
const ao = MARKETPLACE_APPS.findIndex(app => app.proto === protoBase(a));
|
||||
const bo = MARKETPLACE_APPS.findIndex(app => app.proto === protoBase(b));
|
||||
if (ao !== bo) return ao - bo;
|
||||
return protoInstance(a) - protoInstance(b);
|
||||
});
|
||||
}
|
||||
|
||||
function updateConnectDomainSection() {
|
||||
const section = document.getElementById('connectDomainSection');
|
||||
if (!section) return;
|
||||
const show = hasWgAwgInstalled();
|
||||
section.classList.toggle('hidden', !show);
|
||||
if (!show) return;
|
||||
const select = document.getElementById('connectDomainProto');
|
||||
if (!select) return;
|
||||
const prev = select.value;
|
||||
const installed = listInstalledWgAwgProtocols();
|
||||
select.innerHTML = `<option value="">${_('server_connect_domain_all')}</option>` +
|
||||
installed.map(p => `<option value="${p}">${getProtoTitle(p)}</option>`).join('');
|
||||
if (prev && [...select.options].some(opt => opt.value === prev)) {
|
||||
select.value = prev;
|
||||
}
|
||||
refreshConnectDomainFields();
|
||||
}
|
||||
|
||||
async function refreshConnectDomainFields() {
|
||||
const proto = document.getElementById('connectDomainProto')?.value || '';
|
||||
const input = document.getElementById('connectDomainInput');
|
||||
const eff = document.getElementById('connectDomainEffective');
|
||||
try {
|
||||
const query = proto ? `?protocol=${encodeURIComponent(proto)}` : '';
|
||||
const data = await apiCall(`/api/servers/${SERVER_ID}/connect-domain${query}`, 'GET');
|
||||
connectDomainEffective = data;
|
||||
if (input) {
|
||||
input.value = proto ? (data.protocol_connect_domain || '') : (data.connect_domain || '');
|
||||
}
|
||||
if (eff) {
|
||||
if (data.effective_host && data.effective_host !== data.ssh_host) {
|
||||
eff.textContent = `${_('server_connect_domain_current')}: ${data.effective_host} (${_('server_connect_domain_ssh')}: ${data.ssh_host})`;
|
||||
} else {
|
||||
eff.textContent = `${_('server_connect_domain_current')}: ${data.ssh_host || SERVER_HOST}`;
|
||||
}
|
||||
}
|
||||
Object.entries(currentProtocolStatus || {}).forEach(([p, info]) => {
|
||||
if (WG_AWG_BASES.has(protoBase(p)) && isAppInstalled(info)) {
|
||||
updateProtocolCard(p, info);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
if (input && !proto) input.value = SERVER_CONNECT_DOMAIN || '';
|
||||
if (eff) eff.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConnectDomain() {
|
||||
const btn = document.getElementById('connectDomainSaveBtn');
|
||||
const proto = document.getElementById('connectDomainProto')?.value || '';
|
||||
const domain = document.getElementById('connectDomainInput')?.value.trim() || '';
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
await apiCall(`/api/servers/${SERVER_ID}/connect-domain`, 'POST', {
|
||||
connect_domain: domain,
|
||||
protocol: proto || null,
|
||||
});
|
||||
showToast(_('server_connect_domain_saved'), 'success');
|
||||
await refreshConnectDomainFields();
|
||||
Object.entries(currentProtocolStatus || {}).forEach(([p, info]) => {
|
||||
if (WG_AWG_BASES.has(protoBase(p)) && isAppInstalled(info)) {
|
||||
updateProtocolCard(p, info);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function switchCategory(cat) {
|
||||
if (cat === 'management') {
|
||||
openManagementModal();
|
||||
@@ -1329,6 +1480,7 @@
|
||||
if (empty) empty.classList.toggle('hidden', installedAppsLoading || installedCount > 0);
|
||||
const loading = document.getElementById('installedAppsLoading');
|
||||
if (loading) loading.classList.toggle('hidden', !installedAppsLoading);
|
||||
updateConnectDomainSection();
|
||||
}
|
||||
|
||||
function getProtoTitle(proto) {
|
||||
@@ -1631,6 +1783,12 @@
|
||||
grid = buildServiceInfoGrid(proto, info);
|
||||
} else if (info.port) {
|
||||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('port')}</span><span class="protocol-info-value">${info.port}/${(['xray', 'telemt', 'naiveproxy'].includes(protoBase(proto))) ? 'TCP' : 'UDP'}</span></div>`;
|
||||
if (WG_AWG_BASES.has(protoBase(proto))) {
|
||||
const endpoint = (connectDomainEffective.effective_host && connectDomainEffective.effective_host !== SERVER_HOST)
|
||||
? connectDomainEffective.effective_host
|
||||
: SERVER_HOST;
|
||||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('server_endpoint_label')}</span><span class="protocol-info-value">${endpoint}</span></div>`;
|
||||
}
|
||||
if (protoBase(proto) === 'hysteria' && info.domain) {
|
||||
grid += `<div class="protocol-info-item"><span class="protocol-info-label">${_('hysteria_domain')}</span><span class="protocol-info-value">${info.domain}</span></div>`;
|
||||
}
|
||||
@@ -2608,6 +2766,95 @@
|
||||
document.getElementById('connectionsSection').scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
const selectedConnectionIds = new Set();
|
||||
|
||||
function updateMoveSelection() {
|
||||
selectedConnectionIds.clear();
|
||||
document.querySelectorAll('.conn-select:checked').forEach((el) => {
|
||||
if (el.dataset.clientId) selectedConnectionIds.add(el.dataset.clientId);
|
||||
});
|
||||
const count = selectedConnectionIds.size;
|
||||
const bulkBar = document.getElementById('connectionsBulkBar');
|
||||
const moveBtn = document.getElementById('moveConnectionsBtn');
|
||||
const countEl = document.getElementById('connSelectedCount');
|
||||
const selectAll = document.getElementById('connSelectAll');
|
||||
const boxes = document.querySelectorAll('.conn-select');
|
||||
if (bulkBar) bulkBar.classList.toggle('hidden', boxes.length === 0);
|
||||
if (moveBtn) moveBtn.classList.toggle('hidden', boxes.length === 0);
|
||||
if (countEl) countEl.textContent = _('connections_selected_count').replace('{}', String(count));
|
||||
if (selectAll) {
|
||||
selectAll.indeterminate = count > 0 && count < boxes.length;
|
||||
selectAll.checked = boxes.length > 0 && count === boxes.length;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAllConnections(checked) {
|
||||
document.querySelectorAll('.conn-select').forEach((el) => { el.checked = checked; });
|
||||
updateMoveSelection();
|
||||
}
|
||||
|
||||
function openMoveConnectionsModal() {
|
||||
if (!selectedConnectionIds.size) {
|
||||
showToast(_('move_connections_none_selected'), 'error');
|
||||
return;
|
||||
}
|
||||
const select = document.getElementById('moveTargetServer');
|
||||
select.innerHTML = '';
|
||||
(ALL_SERVERS || []).forEach((srv) => {
|
||||
if (srv.id === SERVER_ID) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(srv.id);
|
||||
opt.textContent = `${srv.name} (${srv.host})`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
if (!select.options.length) {
|
||||
showToast(_('move_connections_no_targets'), 'error');
|
||||
return;
|
||||
}
|
||||
openModal('moveConnectionsModal');
|
||||
}
|
||||
|
||||
async function moveSelectedConnections() {
|
||||
const targetServerId = parseInt(document.getElementById('moveTargetServer').value, 10);
|
||||
const deleteSource = document.getElementById('moveDeleteSource').checked;
|
||||
const proto = document.getElementById('connProtoSelect').value;
|
||||
const clientIds = Array.from(selectedConnectionIds);
|
||||
if (!clientIds.length || Number.isNaN(targetServerId)) {
|
||||
showToast(_('move_connections_none_selected'), 'error');
|
||||
return;
|
||||
}
|
||||
if (!confirm(_('move_connections_confirm').replace('{}', String(clientIds.length)))) return;
|
||||
|
||||
const btn = document.getElementById('moveConnectionsSubmitBtn');
|
||||
const text = document.getElementById('moveConnectionsSubmitText');
|
||||
const spinner = document.getElementById('moveConnectionsSpinner');
|
||||
btn.disabled = true;
|
||||
spinner.classList.remove('hidden');
|
||||
text.textContent = _('loading');
|
||||
try {
|
||||
const data = await apiCall(`/api/servers/${SERVER_ID}/connections/move`, 'POST', {
|
||||
protocol: proto,
|
||||
target_server_id: targetServerId,
|
||||
client_ids: clientIds,
|
||||
delete_source: deleteSource,
|
||||
});
|
||||
const moved = (data.moved || []).length;
|
||||
const failed = (data.failed || []).length;
|
||||
let msg = _('move_connections_success').replace('{}', String(moved));
|
||||
if (failed) msg += '. ' + _('move_connections_partial_fail').replace('{}', String(failed));
|
||||
showToast(msg, failed && !moved ? 'error' : 'success');
|
||||
closeModal('moveConnectionsModal');
|
||||
selectedConnectionIds.clear();
|
||||
loadConnections();
|
||||
} catch (err) {
|
||||
showToast(_('error') + ': ' + err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
spinner.classList.add('hidden');
|
||||
text.textContent = _('move_connections');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConnections() {
|
||||
const proto = document.getElementById('connProtoSelect').value;
|
||||
const loading = document.getElementById('connectionsLoading');
|
||||
@@ -2622,11 +2869,14 @@
|
||||
loading.style.display = '';
|
||||
emptyEl.classList.add('hidden');
|
||||
listEl.innerHTML = '';
|
||||
selectedConnectionIds.clear();
|
||||
updateMoveSelection();
|
||||
try {
|
||||
const data = await apiCall(`/api/servers/${SERVER_ID}/connections?protocol=${proto}`);
|
||||
loading.style.display = 'none';
|
||||
if (!data.clients || data.clients.length === 0) {
|
||||
emptyEl.classList.remove('hidden');
|
||||
updateMoveSelection();
|
||||
return;
|
||||
}
|
||||
window.connectionsStore = {};
|
||||
@@ -2670,6 +2920,9 @@
|
||||
|
||||
listEl.innerHTML += `
|
||||
<div class="client-item" style="${disabledStyle}">
|
||||
<label class="conn-select-wrap">
|
||||
<input type="checkbox" class="conn-select" data-client-id="${escapeJs(client.clientId)}" onchange="updateMoveSelection()">
|
||||
</label>
|
||||
<div class="client-info">
|
||||
<div class="client-avatar">${initial}</div>
|
||||
<div>
|
||||
@@ -2686,6 +2939,7 @@
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
updateMoveSelection();
|
||||
} catch (err) {
|
||||
loading.style.display = 'none';
|
||||
showToast(_('connections_load_error') + ': ' + err.message, 'error');
|
||||
@@ -2965,6 +3219,7 @@
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ========== Init ==========
|
||||
applyInstalledAppsVisibility();
|
||||
checkServer();
|
||||
|
||||
+399
-98
@@ -204,88 +204,147 @@
|
||||
</div>
|
||||
|
||||
<!-- BLOCK Tunnels -->
|
||||
<div class="card">
|
||||
<h3 class="card-title" style="margin-bottom: var(--space-lg);">🌍 {{ _('tunnels_title') }}</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: var(--space-md);">
|
||||
<div style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
|
||||
<strong>{{ _('local_server') }}</strong>
|
||||
<span class="badge badge-info">{{ _('active') }}</span>
|
||||
</div>
|
||||
<div style="display:flex; gap:var(--space-sm); align-items:center;">
|
||||
<code id="localServerUrl" style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color: var(--text-secondary);">{{ request.url.scheme }}://{{ request.url.netloc }}</code>
|
||||
<div class="card tunnels-card">
|
||||
<div class="tunnels-header">
|
||||
<h3 class="card-title">🌍 {{ _('tunnels_title') }}</h3>
|
||||
<p class="tunnels-subtitle">{{ _('tunnels_subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="tunnels-local">
|
||||
<div class="tunnels-local-icon" aria-hidden="true">🏠</div>
|
||||
<div class="tunnels-local-body">
|
||||
<div class="tunnels-local-label">{{ _('local_server') }}</div>
|
||||
<div class="tunnels-local-url">
|
||||
<code id="localServerUrl">{{ request.url.scheme }}://{{ request.url.netloc }}</code>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('localServerUrl')">{{ _('copy') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="tunnel-status-pill tunnel-status-pill--ok">{{ _('active') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="tunnel-row" data-provider="cloudflare" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
|
||||
<strong>Cloudflare Quick Tunnel</strong>
|
||||
<span class="badge badge-secondary" id="cloudflareInstallBadge">{{ _('not_installed') }}</span>
|
||||
</div>
|
||||
<div id="cloudflarePublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div>
|
||||
<div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm);">
|
||||
<button type="button" class="btn btn-primary" id="cloudflareTunnelBtn" onclick="enableTunnel('cloudflare')">
|
||||
<span id="cloudflareTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
|
||||
<div class="spinner hidden" id="cloudflareTunnelSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary hidden" id="cloudflareStopBtn" onclick="stopTunnel('cloudflare')">
|
||||
<span id="cloudflareStopBtnText">{{ _('tunnel_stop') }}</span>
|
||||
<div class="spinner hidden" id="cloudflareStopSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger hidden" id="cloudflareDeleteBtn" onclick="deleteTunnel('cloudflare')">
|
||||
<span id="cloudflareDeleteBtnText">{{ _('tunnel_delete') }}</span>
|
||||
<div class="spinner hidden" id="cloudflareDeleteSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tunnels-section">
|
||||
<h4 class="tunnels-section-title">{{ _('tunnels_section_inbound') }}</h4>
|
||||
<div class="tunnels-grid">
|
||||
<article class="tunnel-card tunnel-row" data-provider="cloudflare" id="tunnelCard-cloudflare">
|
||||
<div class="tunnel-card-head">
|
||||
<div class="tunnel-card-icon tunnel-card-icon--cloudflare" aria-hidden="true">☁</div>
|
||||
<div class="tunnel-card-meta">
|
||||
<h5 class="tunnel-card-title">Cloudflare Quick Tunnel</h5>
|
||||
<p class="tunnel-card-desc">{{ _('tunnel_cloudflare_desc') }}</p>
|
||||
</div>
|
||||
<span class="tunnel-status-pill" id="cloudflareInstallBadge">{{ _('not_installed') }}</span>
|
||||
</div>
|
||||
<div class="tunnel-card-body">
|
||||
<div id="cloudflarePublicUrls" class="tunnel-url-box tunnel-url-box--empty">{{ _('tunnel_no_public_url') }}</div>
|
||||
</div>
|
||||
<div class="tunnel-card-actions tunnel-card-actions--triple">
|
||||
<button type="button" class="btn btn-primary btn-sm" id="cloudflareTunnelBtn" onclick="enableTunnel('cloudflare')">
|
||||
<span id="cloudflareTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
|
||||
<div class="spinner hidden" id="cloudflareTunnelSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm hidden" id="cloudflareStopBtn" onclick="stopTunnel('cloudflare')">
|
||||
<span id="cloudflareStopBtnText">{{ _('tunnel_stop') }}</span>
|
||||
<div class="spinner hidden" id="cloudflareStopSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger btn-sm hidden" id="cloudflareDeleteBtn" onclick="deleteTunnel('cloudflare')">
|
||||
<span id="cloudflareDeleteBtnText">{{ _('tunnel_delete') }}</span>
|
||||
<div class="spinner hidden" id="cloudflareDeleteSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="tunnel-row" data-provider="ngrok" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
|
||||
<strong>ngrok Tunnel + Authtoken</strong>
|
||||
<span class="badge badge-secondary" id="ngrokInstallBadge">{{ _('not_installed') }}</span>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: var(--space-sm);">
|
||||
<input type="password" class="form-input" id="ngrokAuthtoken" placeholder="{{ _('ngrok_authtoken_placeholder') }}" autocomplete="off">
|
||||
</div>
|
||||
<div id="ngrokPublicUrls" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('tunnel_no_public_url') }}</div>
|
||||
<div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-sm);">
|
||||
<button type="button" class="btn btn-primary" id="ngrokTunnelBtn" onclick="enableTunnel('ngrok')">
|
||||
<span id="ngrokTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
|
||||
<div class="spinner hidden" id="ngrokTunnelSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary hidden" id="ngrokStopBtn" onclick="stopTunnel('ngrok')">
|
||||
<span id="ngrokStopBtnText">{{ _('tunnel_stop') }}</span>
|
||||
<div class="spinner hidden" id="ngrokStopSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger hidden" id="ngrokDeleteBtn" onclick="deleteTunnel('ngrok')">
|
||||
<span id="ngrokDeleteBtnText">{{ _('tunnel_delete') }}</span>
|
||||
<div class="spinner hidden" id="ngrokDeleteSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="tunnel-row" data-provider="warp" style="padding: var(--space-md); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--bg-primary);">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:var(--space-md); margin-bottom: var(--space-sm);">
|
||||
<strong>Cloudflare WARP</strong>
|
||||
<span class="badge badge-secondary" id="warpInstallBadge">{{ _('not_installed') }}</span>
|
||||
</div>
|
||||
<div id="warpStatusText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('warp_status_unknown') }}</div>
|
||||
<div id="warpHintText" class="form-hint" style="margin-bottom: var(--space-sm);">{{ _('warp_hint') }}</div>
|
||||
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: var(--space-sm);">
|
||||
<button type="button" class="btn btn-primary" id="warpConnectBtn" onclick="connectWarp()">
|
||||
<span id="warpConnectBtnText">{{ _('warp_connect') }}</span>
|
||||
<div class="spinner hidden" id="warpConnectSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary hidden" id="warpDisconnectBtn" onclick="disconnectWarp()">
|
||||
<span id="warpDisconnectBtnText">{{ _('warp_disconnect') }}</span>
|
||||
<div class="spinner hidden" id="warpDisconnectSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
<article class="tunnel-card tunnel-row" data-provider="ngrok" id="tunnelCard-ngrok">
|
||||
<div class="tunnel-card-head">
|
||||
<div class="tunnel-card-icon tunnel-card-icon--ngrok" aria-hidden="true">N</div>
|
||||
<div class="tunnel-card-meta">
|
||||
<h5 class="tunnel-card-title">ngrok</h5>
|
||||
<p class="tunnel-card-desc">{{ _('tunnel_ngrok_desc') }}</p>
|
||||
</div>
|
||||
<span class="tunnel-status-pill" id="ngrokInstallBadge">{{ _('not_installed') }}</span>
|
||||
</div>
|
||||
<div class="tunnel-card-body">
|
||||
<input type="password" class="form-input" id="ngrokAuthtoken" placeholder="{{ _('ngrok_authtoken_placeholder') }}" autocomplete="off">
|
||||
<div id="ngrokPublicUrls" class="tunnel-url-box tunnel-url-box--empty">{{ _('tunnel_no_public_url') }}</div>
|
||||
</div>
|
||||
<div class="tunnel-card-actions tunnel-card-actions--triple">
|
||||
<button type="button" class="btn btn-primary btn-sm" id="ngrokTunnelBtn" onclick="enableTunnel('ngrok')">
|
||||
<span id="ngrokTunnelBtnText">{{ _('tunnel_install_enable') }}</span>
|
||||
<div class="spinner hidden" id="ngrokTunnelSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm hidden" id="ngrokStopBtn" onclick="stopTunnel('ngrok')">
|
||||
<span id="ngrokStopBtnText">{{ _('tunnel_stop') }}</span>
|
||||
<div class="spinner hidden" id="ngrokStopSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger btn-sm hidden" id="ngrokDeleteBtn" onclick="deleteTunnel('ngrok')">
|
||||
<span id="ngrokDeleteBtnText">{{ _('tunnel_delete') }}</span>
|
||||
<div class="spinner hidden" id="ngrokDeleteSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<p class="form-hint" style="margin-top: var(--space-md);">{{ _('tunnels_hint') }}</p>
|
||||
|
||||
<div class="tunnels-section">
|
||||
<h4 class="tunnels-section-title">{{ _('tunnels_section_outbound') }}</h4>
|
||||
<div class="tunnels-grid">
|
||||
<article class="tunnel-card tunnel-row" data-provider="warp" id="tunnelCard-warp">
|
||||
<div class="tunnel-card-head">
|
||||
<div class="tunnel-card-icon tunnel-card-icon--warp" aria-hidden="true">W</div>
|
||||
<div class="tunnel-card-meta">
|
||||
<h5 class="tunnel-card-title">Cloudflare WARP</h5>
|
||||
<p class="tunnel-card-desc">{{ _('tunnel_warp_desc') }}</p>
|
||||
</div>
|
||||
<span class="tunnel-status-pill" id="warpInstallBadge">{{ _('not_installed') }}</span>
|
||||
</div>
|
||||
<div class="tunnel-card-body">
|
||||
<p id="warpStatusText" class="tunnel-status-line">{{ _('warp_status_unknown') }}</p>
|
||||
<p id="warpHintText" class="tunnel-hint">{{ _('warp_hint') }}</p>
|
||||
</div>
|
||||
<div class="tunnel-card-actions">
|
||||
<button type="button" class="btn btn-primary btn-sm" id="warpConnectBtn" onclick="connectWarp()">
|
||||
<span id="warpConnectBtnText">{{ _('warp_connect') }}</span>
|
||||
<div class="spinner hidden" id="warpConnectSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm hidden" id="warpDisconnectBtn" onclick="disconnectWarp()">
|
||||
<span id="warpDisconnectBtnText">{{ _('warp_disconnect') }}</span>
|
||||
<div class="spinner hidden" id="warpDisconnectSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="tunnel-card tunnel-row" data-provider="nordvpn" id="tunnelCard-nordvpn">
|
||||
<div class="tunnel-card-head">
|
||||
<div class="tunnel-card-icon tunnel-card-icon--nordvpn" aria-hidden="true">N</div>
|
||||
<div class="tunnel-card-meta">
|
||||
<h5 class="tunnel-card-title">NordVPN</h5>
|
||||
<p class="tunnel-card-desc">{{ _('tunnel_nordvpn_desc') }}</p>
|
||||
</div>
|
||||
<span class="tunnel-status-pill" id="nordvpnInstallBadge">{{ _('not_installed') }}</span>
|
||||
</div>
|
||||
<div class="tunnel-card-body">
|
||||
<p id="nordvpnStatusText" class="tunnel-status-line">{{ _('nordvpn_status_unknown') }}</p>
|
||||
<p id="nordvpnServerText" class="tunnel-status-line tunnel-status-line--server hidden"></p>
|
||||
<p id="nordvpnHintText" class="tunnel-hint">{{ _('nordvpn_hint') }}</p>
|
||||
<div class="tunnel-nordvpn-fields">
|
||||
<input type="text" class="form-input" id="nordvpnCountry" placeholder="{{ _('nordvpn_country_placeholder') }}" autocomplete="off">
|
||||
<input type="text" class="form-input" id="nordvpnCity" placeholder="{{ _('nordvpn_city_placeholder') }}" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="tunnel-card-actions">
|
||||
<button type="button" class="btn btn-primary btn-sm" id="nordvpnConnectBtn" onclick="connectNordvpn()">
|
||||
<span id="nordvpnConnectBtnText">{{ _('nordvpn_connect') }}</span>
|
||||
<div class="spinner hidden" id="nordvpnConnectSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm hidden" id="nordvpnDisconnectBtn" onclick="disconnectNordvpn()">
|
||||
<span id="nordvpnDisconnectBtnText">{{ _('nordvpn_disconnect') }}</span>
|
||||
<div class="spinner hidden" id="nordvpnDisconnectSpinner" style="width:14px; height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="tunnels-footnote">{{ _('tunnels_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- BLOCK SSL/HTTPS -->
|
||||
@@ -545,15 +604,19 @@
|
||||
<div class="card" style="margin-top: var(--space-lg);">
|
||||
<h3 class="card-title" style="margin-bottom: var(--space-lg);">📤 {{ _('backup_title') }}</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: var(--space-md);">
|
||||
<div style="display: flex; gap: var(--space-sm);">
|
||||
<div style="display: flex; gap: var(--space-sm); flex-wrap: wrap;">
|
||||
<a href="/api/settings/backup/download" class="btn btn-secondary"
|
||||
style="flex:1; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
|
||||
style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
|
||||
<span>⬇️</span> {{ _('download_backup') }}
|
||||
</a>
|
||||
<a href="/api/settings/backup/download/json" class="btn btn-secondary"
|
||||
style="flex:1; min-width:200px; text-decoration:none; display:flex; align-items:center; justify-content:center; gap:var(--space-sm);">
|
||||
<span>📄</span> {{ _('download_backup_json') }}
|
||||
</a>
|
||||
</div>
|
||||
<div style="border-top: 1px solid var(--border-color); padding-top: var(--space-md);">
|
||||
<div style="display: flex; flex-direction: column; gap: var(--space-sm);">
|
||||
<input type="file" id="backupFile" accept=".json" style="display: none;"
|
||||
<input type="file" id="backupFile" accept=".sql,.json" style="display: none;"
|
||||
onchange="handleRestore(event)">
|
||||
<button type="button" class="btn btn-secondary"
|
||||
onclick="document.getElementById('backupFile').click()" id="restoreBtn"
|
||||
@@ -564,6 +627,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="form-hint" style="margin-top: var(--space-sm);">
|
||||
{{ _('backup_hint') }}
|
||||
</p>
|
||||
<p class="form-hint" style="margin-top: var(--space-xs);">
|
||||
{{ _('restore_confirm') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -597,15 +663,24 @@
|
||||
<!-- Updated via JS -->
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-sm);">
|
||||
<button type="button" class="btn btn-secondary" onclick="checkForUpdates()" id="checkUpdateBtn" style="flex:1;">
|
||||
<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>
|
||||
<a href="https://git.evilfox.cc/test2/Amnezia-Web-Panel-main/releases" target="_blank" class="btn btn-primary hidden" id="downloadUpdateBtn" style="flex:1; text-decoration: none; justify-content: center;">
|
||||
<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>
|
||||
<a href="https://git.evilfox.cc/test2/Amnezia-Web-Panel-main/releases" target="_blank" class="btn btn-secondary hidden" id="downloadUpdateBtn" style="flex:1; min-width:140px; text-decoration: none; justify-content: center;">
|
||||
⬇️ {{ _('download_update') }}
|
||||
</a>
|
||||
</div>
|
||||
<p class="form-hint" id="updateModeHint" style="margin:0;">{{ _('auto_update_hint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -916,31 +991,45 @@
|
||||
const el = document.getElementById(`${provider}PublicUrls`);
|
||||
if (!el) return;
|
||||
if (!urls || !urls.length) {
|
||||
el.className = 'tunnel-url-box tunnel-url-box--empty';
|
||||
el.textContent = _('tunnel_no_public_url');
|
||||
return;
|
||||
}
|
||||
el.className = 'tunnel-url-box';
|
||||
el.innerHTML = urls.map((url, idx) => `
|
||||
<div style="display:flex; gap:var(--space-sm); align-items:center; margin-top:${idx ? 'var(--space-xs)' : '0'};">
|
||||
<code id="${provider}PublicUrl${idx}" style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color: var(--text-secondary);">${escapeHTML(url)}</code>
|
||||
<div class="tunnel-url-row">
|
||||
<code id="${provider}PublicUrl${idx}">${escapeHTML(url)}</code>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="copyElementText('${provider}PublicUrl${idx}')">${_('copy')}</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function setTunnelStatusPill(badge, { ok, info, text }) {
|
||||
if (!badge) return;
|
||||
badge.className = 'tunnel-status-pill' + (ok ? ' tunnel-status-pill--ok' : (info ? ' tunnel-status-pill--info' : ''));
|
||||
badge.textContent = text;
|
||||
}
|
||||
|
||||
function updateTunnelProvider(provider, status) {
|
||||
const badge = document.getElementById(`${provider}InstallBadge`);
|
||||
const text = document.getElementById(`${provider}TunnelBtnText`);
|
||||
const startBtn = document.getElementById(`${provider}TunnelBtn`);
|
||||
const stopBtn = document.getElementById(`${provider}StopBtn`);
|
||||
const deleteBtn = document.getElementById(`${provider}DeleteBtn`);
|
||||
const card = document.getElementById(`tunnelCard-${provider}`);
|
||||
if (!badge || !text) return;
|
||||
|
||||
badge.className = status.installed ? 'badge badge-success' : 'badge badge-secondary';
|
||||
badge.textContent = status.installed ? _('installed') : _('not_installed');
|
||||
text.textContent = status.running ? _('tunnel_running') : (status.installed ? _('tunnel_enable') : _('tunnel_install_enable'));
|
||||
if (startBtn) startBtn.disabled = !!status.running;
|
||||
if (stopBtn) stopBtn.classList.toggle('hidden', !status.running);
|
||||
const running = !!status.running;
|
||||
setTunnelStatusPill(badge, {
|
||||
ok: running,
|
||||
info: !running && status.installed,
|
||||
text: running ? _('tunnel_running') : (status.installed ? _('installed') : _('not_installed')),
|
||||
});
|
||||
text.textContent = running ? _('tunnel_running') : (status.installed ? _('tunnel_enable') : _('tunnel_install_enable'));
|
||||
if (startBtn) startBtn.disabled = running;
|
||||
if (stopBtn) stopBtn.classList.toggle('hidden', !running);
|
||||
if (deleteBtn) deleteBtn.classList.toggle('hidden', !status.installed);
|
||||
if (card) card.classList.toggle('tunnel-card--running', running);
|
||||
renderTunnelUrls(provider, status.public_urls || (status.public_url ? [status.public_url] : []));
|
||||
}
|
||||
|
||||
@@ -951,6 +1040,7 @@
|
||||
updateTunnelProvider('cloudflare', data.cloudflare || {});
|
||||
updateTunnelProvider('ngrok', data.ngrok || {});
|
||||
updateWarpStatus(data.warp || {});
|
||||
updateNordvpnStatus(data.nordvpn || {});
|
||||
} catch (err) {
|
||||
showToast(`${_('error')}: ` + err.message, 'error');
|
||||
}
|
||||
@@ -963,12 +1053,16 @@
|
||||
const connectBtn = document.getElementById('warpConnectBtn');
|
||||
const disconnectBtn = document.getElementById('warpDisconnectBtn');
|
||||
const connectText = document.getElementById('warpConnectBtnText');
|
||||
const card = document.getElementById('tunnelCard-warp');
|
||||
if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return;
|
||||
|
||||
const installed = !!status.installed;
|
||||
const connected = !!status.connected || status.status === 'connected';
|
||||
badge.className = connected ? 'badge badge-success' : (installed ? 'badge badge-info' : 'badge badge-secondary');
|
||||
badge.textContent = connected ? _('warp_connected') : (installed ? _('installed') : _('not_installed'));
|
||||
setTunnelStatusPill(badge, {
|
||||
ok: connected,
|
||||
info: !connected && installed,
|
||||
text: connected ? _('warp_connected') : (installed ? _('installed') : _('not_installed')),
|
||||
});
|
||||
|
||||
const statusKey = `warp_status_${status.status || 'unknown'}`;
|
||||
statusText.textContent = _(statusKey) || status.status || _('warp_status_unknown');
|
||||
@@ -976,6 +1070,7 @@
|
||||
connectText.textContent = installed ? _('warp_connect') : _('warp_install_required');
|
||||
connectBtn.disabled = connected;
|
||||
disconnectBtn.classList.toggle('hidden', !connected);
|
||||
if (card) card.classList.toggle('tunnel-card--connected', connected);
|
||||
}
|
||||
|
||||
async function connectWarp() {
|
||||
@@ -1020,6 +1115,86 @@
|
||||
}
|
||||
}
|
||||
|
||||
function updateNordvpnStatus(status) {
|
||||
const badge = document.getElementById('nordvpnInstallBadge');
|
||||
const statusText = document.getElementById('nordvpnStatusText');
|
||||
const serverText = document.getElementById('nordvpnServerText');
|
||||
const hintText = document.getElementById('nordvpnHintText');
|
||||
const connectBtn = document.getElementById('nordvpnConnectBtn');
|
||||
const disconnectBtn = document.getElementById('nordvpnDisconnectBtn');
|
||||
const connectText = document.getElementById('nordvpnConnectBtnText');
|
||||
const card = document.getElementById('tunnelCard-nordvpn');
|
||||
if (!badge || !statusText || !connectBtn || !disconnectBtn || !connectText) return;
|
||||
|
||||
const installed = !!status.installed;
|
||||
const connected = !!status.connected || status.status === 'connected';
|
||||
setTunnelStatusPill(badge, {
|
||||
ok: connected,
|
||||
info: !connected && installed,
|
||||
text: connected ? _('nordvpn_connected') : (installed ? _('installed') : _('not_installed')),
|
||||
});
|
||||
|
||||
const statusKey = `nordvpn_status_${status.status || 'unknown'}`;
|
||||
statusText.textContent = _(statusKey) || status.status || _('nordvpn_status_unknown');
|
||||
if (serverText) {
|
||||
const serverLine = status.server || '';
|
||||
serverText.textContent = serverLine;
|
||||
serverText.classList.toggle('hidden', !serverLine);
|
||||
}
|
||||
hintText.textContent = status.last_error || (installed ? _('nordvpn_hint') : (status.install_hint || _('nordvpn_install_hint')));
|
||||
connectText.textContent = installed ? _('nordvpn_connect') : _('nordvpn_install_required');
|
||||
connectBtn.disabled = connected;
|
||||
disconnectBtn.classList.toggle('hidden', !connected);
|
||||
if (card) card.classList.toggle('tunnel-card--connected', connected);
|
||||
}
|
||||
|
||||
async function connectNordvpn() {
|
||||
const btn = document.getElementById('nordvpnConnectBtn');
|
||||
const spinner = document.getElementById('nordvpnConnectSpinner');
|
||||
const text = document.getElementById('nordvpnConnectBtnText');
|
||||
btn.disabled = true;
|
||||
spinner.classList.remove('hidden');
|
||||
text.textContent = _('loading');
|
||||
|
||||
try {
|
||||
const country = (document.getElementById('nordvpnCountry') || {}).value || '';
|
||||
const city = (document.getElementById('nordvpnCity') || {}).value || '';
|
||||
const status = await apiCall('/api/settings/nordvpn/connect', 'POST', {
|
||||
country: country.trim(),
|
||||
city: city.trim(),
|
||||
});
|
||||
updateNordvpnStatus(status);
|
||||
showToast(_('nordvpn_connected'), 'success');
|
||||
} catch (err) {
|
||||
showToast(`${_('error')}: ` + err.message, 'error');
|
||||
await loadTunnelStatus();
|
||||
} finally {
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectNordvpn() {
|
||||
const btn = document.getElementById('nordvpnDisconnectBtn');
|
||||
const spinner = document.getElementById('nordvpnDisconnectSpinner');
|
||||
const text = document.getElementById('nordvpnDisconnectBtnText');
|
||||
btn.disabled = true;
|
||||
spinner.classList.remove('hidden');
|
||||
text.textContent = _('loading');
|
||||
|
||||
try {
|
||||
const status = await apiCall('/api/settings/nordvpn/disconnect', 'POST');
|
||||
updateNordvpnStatus(status);
|
||||
showToast(_('nordvpn_disconnected'), 'success');
|
||||
} catch (err) {
|
||||
showToast(`${_('error')}: ` + err.message, 'error');
|
||||
await loadTunnelStatus();
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
text.textContent = _('nordvpn_disconnect');
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function enableTunnel(provider) {
|
||||
const btn = document.getElementById(`${provider}TunnelBtn`);
|
||||
const spinner = document.getElementById(`${provider}TunnelSpinner`);
|
||||
@@ -1482,42 +1657,168 @@
|
||||
updateProtocolsForSync();
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
let latestUpdateInfo = null;
|
||||
|
||||
async function checkForUpdates(silent) {
|
||||
const btn = document.getElementById('checkUpdateBtn');
|
||||
const text = document.getElementById('checkUpdateBtnText');
|
||||
const spinner = document.getElementById('checkUpdateSpinner');
|
||||
const statusDiv = document.getElementById('updateStatus');
|
||||
const downloadBtn = document.getElementById('downloadUpdateBtn');
|
||||
const applyBtn = document.getElementById('applyUpdateBtn');
|
||||
const modeHint = document.getElementById('updateModeHint');
|
||||
|
||||
btn.disabled = true;
|
||||
text.textContent = "{{ _('checking_updates')|default('Checking...') }}";
|
||||
spinner.classList.remove('hidden');
|
||||
if (!silent) {
|
||||
btn.disabled = true;
|
||||
text.textContent = "{{ _('checking_updates')|default('Checking...') }}";
|
||||
spinner.classList.remove('hidden');
|
||||
}
|
||||
statusDiv.classList.add('hidden');
|
||||
downloadBtn.classList.add('hidden');
|
||||
applyBtn.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const data = await apiCall('/api/settings/check_updates');
|
||||
latestUpdateInfo = data;
|
||||
const latestVersion = data.latest_version || '';
|
||||
statusDiv.classList.remove('hidden');
|
||||
|
||||
if (modeHint) {
|
||||
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_ready')|default('Git auto-update is available for this install.') }}";
|
||||
}
|
||||
}
|
||||
|
||||
const upgradeBtn = document.getElementById('upgradePanelBtn');
|
||||
if (upgradeBtn) {
|
||||
upgradeBtn.disabled = false;
|
||||
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)';
|
||||
downloadBtn.href = data.html_url || data.releases_url || 'https://git.evilfox.cc/test2/Amnezia-Web-Panel-main/releases';
|
||||
downloadBtn.classList.remove('hidden');
|
||||
applyBtn.classList.remove('hidden');
|
||||
} else {
|
||||
statusDiv.innerHTML = `<span style="color: var(--text-muted);">{{ _('up_to_date')|default('Up to date') }}</span>`;
|
||||
statusDiv.style.border = '1px solid var(--border-color)';
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Failed to check for updates: ' + err.message, 'error');
|
||||
if (!silent) showToast('Failed to check for updates: ' + err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
text.textContent = "🔄 {{ _('check_updates')|default('Check for updates') }}";
|
||||
spinner.classList.add('hidden');
|
||||
if (!silent) {
|
||||
btn.disabled = false;
|
||||
text.textContent = "🔄 {{ _('check_updates')|default('Check for updates') }}";
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPanelRestart(timeoutMs = 120000, panelUrl) {
|
||||
const base = String(panelUrl || window.location.origin || '').replace(/\/$/, '');
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch(`${base}/api/health`, { cache: 'no-store' });
|
||||
if (res.ok) {
|
||||
window.location.href = base + '/';
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
const hint = base ? ` ${base}` : '';
|
||||
showToast("{{ _('update_restart_timeout')|default('Panel did not come back in time. Refresh the page manually.') }}" + hint, 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
async function applyPanelUpdate() {
|
||||
const applyBtn = document.getElementById('applyUpdateBtn');
|
||||
const applyText = document.getElementById('applyUpdateBtnText');
|
||||
const applySpinner = document.getElementById('applyUpdateSpinner');
|
||||
const target = (latestUpdateInfo && latestUpdateInfo.latest_version) || '';
|
||||
if (!target) {
|
||||
showToast("{{ _('update_no_target')|default('No target version. Check for updates first.') }}", 'error');
|
||||
return;
|
||||
}
|
||||
if (!confirm("{{ _('apply_update_confirm')|default('Install update from git.evilfox.cc and restart the panel?') }} " + target)) {
|
||||
return;
|
||||
}
|
||||
applyBtn.disabled = true;
|
||||
applyText.textContent = "{{ _('applying_update')|default('Updating…') }}";
|
||||
applySpinner.classList.remove('hidden');
|
||||
let restarting = false;
|
||||
try {
|
||||
const data = await apiCall('/api/settings/apply_update', 'POST', {
|
||||
target_version: target,
|
||||
allow_dirty: true,
|
||||
});
|
||||
showToast(data.message || "{{ _('update_restarting')|default('Updated. Restarting…') }}", 'success');
|
||||
restarting = true;
|
||||
applyText.textContent = "{{ _('update_waiting_restart')|default('Waiting for panel…') }}";
|
||||
await waitForPanelRestart(120000, data.panel_url);
|
||||
} catch (err) {
|
||||
showToast(_('error') + ': ' + err.message, 'error');
|
||||
} finally {
|
||||
if (!restarting) {
|
||||
applyBtn.disabled = false;
|
||||
applyText.textContent = "⬆ {{ _('apply_update')|default('Install update') }}";
|
||||
applySpinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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…') }}";
|
||||
let restarting = false;
|
||||
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');
|
||||
restarting = true;
|
||||
text.textContent = "{{ _('update_waiting_restart')|default('Waiting for panel…') }}";
|
||||
await waitForPanelRestart(120000, data.panel_url);
|
||||
} catch (err) {
|
||||
showToast(_('error') + ': ' + err.message, 'error');
|
||||
await checkForUpdates(true);
|
||||
} finally {
|
||||
if (!restarting) {
|
||||
btn.disabled = false;
|
||||
spinner.classList.add('hidden');
|
||||
text.textContent = "⬆ {{ _('upgrade_panel')|default('Update panel') }}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-check once on Settings load
|
||||
setTimeout(() => checkForUpdates(true), 800);
|
||||
|
||||
/* ========== API Tokens ========== */
|
||||
|
||||
function fmtTokenDate(iso) {
|
||||
|
||||
+641
-577
File diff suppressed because it is too large
Load Diff
+584
-520
File diff suppressed because it is too large
Load Diff
+584
-520
File diff suppressed because it is too large
Load Diff
+641
-577
File diff suppressed because it is too large
Load Diff
+584
-520
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user