Add password validation with i18n support and GitHub button improvements
- Add password field to UserUpdate schema for password change on edit - Implement password validation in update_user endpoint - Add i18n translations for password validation errors (ru/en) - Update SecurityService.validate_password to support language parameter - Add GitHub icon and repository name 'pvemanager' to header button - Fix internal server error when updating user password - Clean up hardcoded translations in frontend
This commit is contained in:
@@ -4,3 +4,9 @@ POSTGRES_PASSWORD=serverpanel_secure_password
|
|||||||
# Timezone
|
# Timezone
|
||||||
TZ=Asia/Tashkent
|
TZ=Asia/Tashkent
|
||||||
TIMEZONE=Asia/Tashkent
|
TIMEZONE=Asia/Tashkent
|
||||||
|
|
||||||
|
# Update Check Configuration (for private repositories)
|
||||||
|
# Set to true to disable update checks
|
||||||
|
DISABLE_UPDATE_CHECK=true
|
||||||
|
# GitHub token for private repositories (optional, get at: https://github.com/settings/tokens)
|
||||||
|
GITHUB_TOKEN=
|
||||||
|
|||||||
61
PRIVATE_REPO_FIX.md
Normal file
61
PRIVATE_REPO_FIX.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Решение проблемы с проверкой обновлений для приватных репозиториев
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
При работе с приватным GitHub репозиторием система выдает ошибку:
|
||||||
|
```
|
||||||
|
Failed to fetch VERSION file: HTTP 404
|
||||||
|
```
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Есть два способа решения этой проблемы:
|
||||||
|
|
||||||
|
### Способ 1: Отключить проверку обновлений (Рекомендуется)
|
||||||
|
|
||||||
|
Добавьте в файл `.env` в корне проекта:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DISABLE_UPDATE_CHECK=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Затем перезапустите контейнер:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Способ 2: Использовать GitHub Token
|
||||||
|
|
||||||
|
1. Создайте Personal Access Token на GitHub:
|
||||||
|
- Перейдите на https://github.com/settings/tokens
|
||||||
|
- Нажмите "Generate new token (classic)"
|
||||||
|
- Выберите scope: `repo` (Full control of private repositories)
|
||||||
|
- Скопируйте созданный токен
|
||||||
|
|
||||||
|
2. Добавьте токен в файл `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DISABLE_UPDATE_CHECK=false
|
||||||
|
GITHUB_TOKEN=your_github_personal_access_token_here
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Перезапустите контейнер:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
После применения изменений:
|
||||||
|
|
||||||
|
1. Откройте панель: http://localhost:8000
|
||||||
|
2. Перейдите в **Settings** → **System Updates**
|
||||||
|
3. Проверьте, что ошибка исчезла
|
||||||
|
|
||||||
|
## Дополнительная информация
|
||||||
|
|
||||||
|
- Настройки находятся в файле: `backend/app/services/update_service.py`
|
||||||
|
- Переменные окружения: `DISABLE_UPDATE_CHECK`, `GITHUB_TOKEN`
|
||||||
|
- Документация: [README.md](README.md)
|
||||||
14
README.md
14
README.md
@@ -88,6 +88,20 @@ POSTGRES_PASSWORD=your_secure_password
|
|||||||
TZ=Your/Timezone
|
TZ=Your/Timezone
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Private Repository Configuration
|
||||||
|
|
||||||
|
For private repositories, update checks need to be configured:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Disable update checks completely
|
||||||
|
DISABLE_UPDATE_CHECK=true
|
||||||
|
|
||||||
|
# OR use GitHub token for access
|
||||||
|
GITHUB_TOKEN=your_github_personal_access_token
|
||||||
|
```
|
||||||
|
|
||||||
|
To create GitHub token: https://github.com/settings/tokens (needs 'repo' scope)
|
||||||
|
|
||||||
### Notifications (SMTP and Telegram)
|
### Notifications (SMTP and Telegram)
|
||||||
|
|
||||||
Email and Telegram notification settings are now managed via web interface:
|
Email and Telegram notification settings are now managed via web interface:
|
||||||
|
|||||||
@@ -28,3 +28,10 @@ DEFAULT_SSH_PORT=22
|
|||||||
# Security
|
# Security
|
||||||
CORS_ORIGINS=*
|
CORS_ORIGINS=*
|
||||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
|
|
||||||
|
# Update Check Configuration
|
||||||
|
# Set to true to disable update checks for private repositories
|
||||||
|
DISABLE_UPDATE_CHECK=false
|
||||||
|
# GitHub token for private repositories (optional)
|
||||||
|
# Get token at: https://github.com/settings/tokens (needs 'repo' scope)
|
||||||
|
GITHUB_TOKEN=
|
||||||
|
|||||||
@@ -317,7 +317,8 @@ def register_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Validate password
|
# Validate password
|
||||||
is_valid, errors = SecurityService.validate_password(db, user_data.password)
|
lang = request.cookies.get("language", "en")
|
||||||
|
is_valid, errors = SecurityService.validate_password(db, user_data.password, lang)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
@@ -446,7 +447,8 @@ def change_password(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Validate new password
|
# Validate new password
|
||||||
is_valid, errors = SecurityService.validate_password(db, new_password)
|
lang = request.cookies.get("language", "en")
|
||||||
|
is_valid, errors = SecurityService.validate_password(db, new_password, lang)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ class UserCreate(BaseModel):
|
|||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
email: Optional[EmailStr] = None
|
email: Optional[EmailStr] = None
|
||||||
full_name: Optional[str] = Field(None, max_length=100)
|
full_name: Optional[str] = Field(None, max_length=100)
|
||||||
|
password: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
role_id: Optional[int] = None
|
role_id: Optional[int] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
is_admin: Optional[bool] = None
|
is_admin: Optional[bool] = None
|
||||||
@@ -383,7 +384,8 @@ async def create_user(
|
|||||||
raise HTTPException(status_code=400, detail="Email already exists")
|
raise HTTPException(status_code=400, detail="Email already exists")
|
||||||
|
|
||||||
# Validate password
|
# Validate password
|
||||||
is_valid, errors = SecurityService.validate_password(db, user_data.password)
|
lang = request.cookies.get("language", "en")
|
||||||
|
is_valid, errors = SecurityService.validate_password(db, user_data.password, lang)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
raise HTTPException(status_code=400, detail="; ".join(errors))
|
raise HTTPException(status_code=400, detail="; ".join(errors))
|
||||||
|
|
||||||
@@ -467,6 +469,15 @@ async def update_user(
|
|||||||
if user_data.full_name is not None:
|
if user_data.full_name is not None:
|
||||||
user.full_name = user_data.full_name
|
user.full_name = user_data.full_name
|
||||||
|
|
||||||
|
if user_data.password is not None:
|
||||||
|
# Validate password
|
||||||
|
lang = request.cookies.get("language", "en")
|
||||||
|
is_valid, errors = SecurityService.validate_password(db, user_data.password, lang)
|
||||||
|
if not is_valid:
|
||||||
|
raise HTTPException(status_code=400, detail="; ".join(errors))
|
||||||
|
user.hashed_password = get_password_hash(user_data.password)
|
||||||
|
user.last_password_change = utcnow()
|
||||||
|
|
||||||
if user_data.role_id is not None:
|
if user_data.role_id is not None:
|
||||||
role = db.query(Role).filter(Role.id == user_data.role_id).first()
|
role = db.query(Role).filter(Role.id == user_data.role_id).first()
|
||||||
if not role:
|
if not role:
|
||||||
@@ -588,7 +599,8 @@ async def reset_user_password(
|
|||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
# Validate password
|
# Validate password
|
||||||
is_valid, errors = SecurityService.validate_password(db, password_data.new_password)
|
lang = request.cookies.get("language", "en")
|
||||||
|
is_valid, errors = SecurityService.validate_password(db, password_data.new_password, lang)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
raise HTTPException(status_code=400, detail="; ".join(errors))
|
raise HTTPException(status_code=400, detail="; ".join(errors))
|
||||||
|
|
||||||
|
|||||||
@@ -3940,6 +3940,28 @@ class I18nService:
|
|||||||
"en": "Require Special Characters"
|
"en": "Require Special Characters"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# Password validation errors
|
||||||
|
"password_error_min_length": {
|
||||||
|
"ru": "Пароль должен быть не менее {min_length} символов",
|
||||||
|
"en": "Password must be at least {min_length} characters"
|
||||||
|
},
|
||||||
|
"password_error_uppercase": {
|
||||||
|
"ru": "Пароль должен содержать хотя бы одну заглавную букву",
|
||||||
|
"en": "Password must contain at least one uppercase letter"
|
||||||
|
},
|
||||||
|
"password_error_lowercase": {
|
||||||
|
"ru": "Пароль должен содержать хотя бы одну строчную букву",
|
||||||
|
"en": "Password must contain at least one lowercase letter"
|
||||||
|
},
|
||||||
|
"password_error_number": {
|
||||||
|
"ru": "Пароль должен содержать хотя бы одну цифру",
|
||||||
|
"en": "Password must contain at least one number"
|
||||||
|
},
|
||||||
|
"password_error_special": {
|
||||||
|
"ru": "Пароль должен содержать хотя бы один спецсимвол",
|
||||||
|
"en": "Password must contain at least one special character"
|
||||||
|
},
|
||||||
|
|
||||||
# IP Blacklist
|
# IP Blacklist
|
||||||
"ip_blacklist": {
|
"ip_blacklist": {
|
||||||
"ru": "Чёрный список IP",
|
"ru": "Чёрный список IP",
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ class UserUpdate(BaseModel):
|
|||||||
full_name: Optional[str] = Field(None, max_length=100)
|
full_name: Optional[str] = Field(None, max_length=100)
|
||||||
password: Optional[str] = Field(None, min_length=6, max_length=100)
|
password: Optional[str] = Field(None, min_length=6, max_length=100)
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
is_admin: Optional[bool] = None
|
||||||
|
role_id: Optional[int] = None
|
||||||
|
require_password_change: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(UserBase):
|
class UserResponse(UserBase):
|
||||||
|
|||||||
@@ -568,11 +568,13 @@ class SecurityService:
|
|||||||
# ==================== Password Validation ====================
|
# ==================== Password Validation ====================
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_password(cls, db: Session, password: str) -> Tuple[bool, list]:
|
def validate_password(cls, db: Session, password: str, lang: str = "en") -> Tuple[bool, list]:
|
||||||
"""
|
"""
|
||||||
Validate password against security requirements.
|
Validate password against security requirements.
|
||||||
Returns (is_valid, list_of_errors)
|
Returns (is_valid, list_of_errors)
|
||||||
"""
|
"""
|
||||||
|
from ..i18n import t
|
||||||
|
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
min_length = cls.get_setting_int(db, "password_min_length", 8)
|
min_length = cls.get_setting_int(db, "password_min_length", 8)
|
||||||
@@ -582,18 +584,18 @@ class SecurityService:
|
|||||||
require_special = cls.get_setting_bool(db, "password_require_special", False)
|
require_special = cls.get_setting_bool(db, "password_require_special", False)
|
||||||
|
|
||||||
if len(password) < min_length:
|
if len(password) < min_length:
|
||||||
errors.append(f"Password must be at least {min_length} characters")
|
errors.append(t("password_error_min_length", lang, min_length=min_length))
|
||||||
|
|
||||||
if require_upper and not any(c.isupper() for c in password):
|
if require_upper and not any(c.isupper() for c in password):
|
||||||
errors.append("Password must contain at least one uppercase letter")
|
errors.append(t("password_error_uppercase", lang))
|
||||||
|
|
||||||
if require_lower and not any(c.islower() for c in password):
|
if require_lower and not any(c.islower() for c in password):
|
||||||
errors.append("Password must contain at least one lowercase letter")
|
errors.append(t("password_error_lowercase", lang))
|
||||||
|
|
||||||
if require_numbers and not any(c.isdigit() for c in password):
|
if require_numbers and not any(c.isdigit() for c in password):
|
||||||
errors.append("Password must contain at least one number")
|
errors.append(t("password_error_number", lang))
|
||||||
|
|
||||||
if require_special and not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in password):
|
if require_special and not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in password):
|
||||||
errors.append("Password must contain at least one special character")
|
errors.append(t("password_error_special", lang))
|
||||||
|
|
||||||
return len(errors) == 0, errors
|
return len(errors) == 0, errors
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ VERSION_FILE = os.path.join(PROJECT_ROOT, "VERSION")
|
|||||||
# Для fallback - путь внутри контейнера где может быть VERSION
|
# Для fallback - путь внутри контейнера где может быть VERSION
|
||||||
CONTAINER_VERSION_FILE = "/app/VERSION"
|
CONTAINER_VERSION_FILE = "/app/VERSION"
|
||||||
|
|
||||||
|
# GitHub token для доступа к приватным репозиториям (опционально)
|
||||||
|
GITHUB_TOKEN = os.environ.get('GITHUB_TOKEN', None)
|
||||||
|
|
||||||
|
# Отключить проверку обновлений (для приватных репозиториев без токена)
|
||||||
|
DISABLE_UPDATE_CHECK = os.environ.get('DISABLE_UPDATE_CHECK', 'false').lower() == 'true'
|
||||||
|
|
||||||
# Статус обновления (in-memory)
|
# Статус обновления (in-memory)
|
||||||
update_status = {
|
update_status = {
|
||||||
"is_updating": False,
|
"is_updating": False,
|
||||||
@@ -139,9 +145,16 @@ async def check_for_updates() -> Dict[str, Any]:
|
|||||||
"changelog": None,
|
"changelog": None,
|
||||||
"error": None,
|
"error": None,
|
||||||
"git_available": is_git_available(),
|
"git_available": is_git_available(),
|
||||||
"project_mounted": is_project_mounted()
|
"project_mounted": is_project_mounted(),
|
||||||
|
"disabled": DISABLE_UPDATE_CHECK
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Если проверка обновлений отключена
|
||||||
|
if DISABLE_UPDATE_CHECK:
|
||||||
|
result["error"] = "Update check is disabled"
|
||||||
|
result["latest_version"] = current_version
|
||||||
|
return result
|
||||||
|
|
||||||
# Получаем URL репозитория
|
# Получаем URL репозитория
|
||||||
repo_url = None
|
repo_url = None
|
||||||
if result["project_mounted"]:
|
if result["project_mounted"]:
|
||||||
@@ -175,25 +188,45 @@ async def check_for_updates() -> Dict[str, Any]:
|
|||||||
owner, repo = "markmorado", "pvemanager"
|
owner, repo = "markmorado", "pvemanager"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Подготавливаем заголовки для запроса
|
||||||
|
headers = {}
|
||||||
|
if GITHUB_TOKEN:
|
||||||
|
headers["Authorization"] = f"token {GITHUB_TOKEN}"
|
||||||
|
|
||||||
# Используем GitHub API для получения файлов
|
# Используем GitHub API для получения файлов
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
# Получить VERSION из main ветки
|
# Пробуем оба варианта: main и master
|
||||||
version_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/VERSION"
|
version_url = None
|
||||||
version_response = await client.get(version_url)
|
version_response = None
|
||||||
|
|
||||||
if version_response.status_code == 200:
|
for branch in ["main", "master"]:
|
||||||
|
test_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/VERSION"
|
||||||
|
test_response = await client.get(test_url, headers=headers)
|
||||||
|
|
||||||
|
if test_response.status_code == 200:
|
||||||
|
version_url = test_url
|
||||||
|
version_response = test_response
|
||||||
|
break
|
||||||
|
|
||||||
|
if version_response and version_response.status_code == 200:
|
||||||
result["latest_version"] = version_response.text.strip()
|
result["latest_version"] = version_response.text.strip()
|
||||||
|
elif version_response and version_response.status_code == 404:
|
||||||
|
result["error"] = "Repository is private or not accessible. Set DISABLE_UPDATE_CHECK=true to hide this error."
|
||||||
|
result["latest_version"] = current_version
|
||||||
|
return result
|
||||||
else:
|
else:
|
||||||
result["error"] = f"Failed to fetch VERSION file: HTTP {version_response.status_code}"
|
result["error"] = f"Failed to fetch VERSION file: HTTP {version_response.status_code if version_response else 'unknown'}"
|
||||||
|
result["latest_version"] = current_version
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Сравнить версии
|
# Сравнить версии
|
||||||
if result["latest_version"] != current_version:
|
if result["latest_version"] != current_version:
|
||||||
result["update_available"] = True
|
result["update_available"] = True
|
||||||
|
|
||||||
# Получить CHANGELOG.md
|
# Получить CHANGELOG.md (используем ту же ветку, что и для VERSION)
|
||||||
changelog_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/CHANGELOG.md"
|
branch = version_url.split("/")[-2] if version_url else "main"
|
||||||
changelog_response = await client.get(changelog_url)
|
changelog_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/CHANGELOG.md"
|
||||||
|
changelog_response = await client.get(changelog_url, headers=headers)
|
||||||
|
|
||||||
if changelog_response.status_code == 200:
|
if changelog_response.status_code == 200:
|
||||||
# Извлекаем только последнюю версию из changelog
|
# Извлекаем только последнюю версию из changelog
|
||||||
|
|||||||
@@ -549,6 +549,55 @@ header h2 {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* GitHub Button */
|
||||||
|
.github-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-btn:hover {
|
||||||
|
background: var(--bg-surface-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--text-muted);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-star-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-btn:hover .github-star-icon {
|
||||||
|
opacity: 1;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-text {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-stars {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 30px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 1px solid var(--border-default);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
Main Content
|
Main Content
|
||||||
============================================ */
|
============================================ */
|
||||||
@@ -1625,6 +1674,21 @@ table tr:hover {
|
|||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact GitHub button on very small screens */
|
||||||
|
.github-btn {
|
||||||
|
padding: 7px 10px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-stars {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding-left: 6px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ const translations = {
|
|||||||
language_changed: "Язык изменён. Страница будет перезагружена...",
|
language_changed: "Язык изменён. Страница будет перезагружена...",
|
||||||
error_occurred: "Произошла ошибка",
|
error_occurred: "Произошла ошибка",
|
||||||
confirm_delete: "Вы уверены, что хотите удалить?",
|
confirm_delete: "Вы уверены, что хотите удалить?",
|
||||||
|
|
||||||
|
// Updates
|
||||||
|
update_check_disabled: "Проверка обновлений отключена",
|
||||||
|
checking_updates: "Проверка обновлений...",
|
||||||
|
check_updates: "Проверить обновления",
|
||||||
|
update_available: "Доступно обновление",
|
||||||
|
no_updates: "Установлена последняя версия",
|
||||||
|
update_warning: "Панель будет недоступна во время обновления",
|
||||||
|
updating_system: "Обновление системы...",
|
||||||
},
|
},
|
||||||
|
|
||||||
en: {
|
en: {
|
||||||
@@ -62,6 +71,15 @@ const translations = {
|
|||||||
language_changed: "Language changed. Page will reload...",
|
language_changed: "Language changed. Page will reload...",
|
||||||
error_occurred: "An error occurred",
|
error_occurred: "An error occurred",
|
||||||
confirm_delete: "Are you sure you want to delete?",
|
confirm_delete: "Are you sure you want to delete?",
|
||||||
|
|
||||||
|
// Updates
|
||||||
|
update_check_disabled: "Update check is disabled",
|
||||||
|
checking_updates: "Checking for updates...",
|
||||||
|
check_updates: "Check for Updates",
|
||||||
|
update_available: "Update available",
|
||||||
|
no_updates: "You have the latest version",
|
||||||
|
update_warning: "Panel will be unavailable during update",
|
||||||
|
updating_system: "Updating system...",
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -610,6 +610,16 @@ body.update-in-progress .layout {
|
|||||||
</button>
|
</button>
|
||||||
<h2>{{ page_title or title or t('nav_dashboard') }}</h2>
|
<h2>{{ page_title or title or t('nav_dashboard') }}</h2>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<!-- GitHub Stars Button -->
|
||||||
|
<a href="https://github.com/markmorado/pvemanager" target="_blank" rel="noopener noreferrer" class="github-btn" title="Star us on GitHub">
|
||||||
|
<i class="fa-brands fa-github"></i>
|
||||||
|
<span class="github-text">pvemanager</span>
|
||||||
|
<svg class="github-star-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279-7.416-3.967-7.417 3.967 1.481-8.279-6.064-5.828 8.332-1.151z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="github-stars" id="githubStars">...</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<!-- Theme Toggle Button -->
|
<!-- Theme Toggle Button -->
|
||||||
<button id="themeToggle" class="theme-toggle" title="{{ t('theme') }}">
|
<button id="themeToggle" class="theme-toggle" title="{{ t('theme') }}">
|
||||||
<svg class="icon-sun" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg class="icon-sun" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -851,6 +861,36 @@ body.update-in-progress .layout {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load GitHub stars count
|
||||||
|
async function loadGitHubStars() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://api.github.com/repos/markmorado/pvemanager', {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/vnd.github.v3+json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const starsElement = document.getElementById('githubStars');
|
||||||
|
if (starsElement) {
|
||||||
|
// Format number with K for thousands
|
||||||
|
const stars = data.stargazers_count;
|
||||||
|
const formatted = stars >= 1000
|
||||||
|
? (stars / 1000).toFixed(1) + 'K'
|
||||||
|
: stars.toString();
|
||||||
|
starsElement.textContent = formatted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load GitHub stars:', error);
|
||||||
|
const starsElement = document.getElementById('githubStars');
|
||||||
|
if (starsElement) {
|
||||||
|
starsElement.textContent = '★';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load unread notification count
|
// Load unread notification count
|
||||||
async function loadNotificationCount() {
|
async function loadNotificationCount() {
|
||||||
try {
|
try {
|
||||||
@@ -1268,6 +1308,9 @@ body.update-in-progress .layout {
|
|||||||
|
|
||||||
// Refresh count every 30 seconds
|
// Refresh count every 30 seconds
|
||||||
setInterval(loadNotificationCount, 30000);
|
setInterval(loadNotificationCount, 30000);
|
||||||
|
|
||||||
|
// Load GitHub stars count
|
||||||
|
loadGitHubStars();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add click handlers for sidebar menu items to update page title
|
// Add click handlers for sidebar menu items to update page title
|
||||||
|
|||||||
@@ -715,6 +715,12 @@
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.update-status-box.warning {
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
.update-changelog-box {
|
.update-changelog-box {
|
||||||
margin-bottom: var(--spacing-lg);
|
margin-bottom: var(--spacing-lg);
|
||||||
}
|
}
|
||||||
@@ -1797,9 +1803,16 @@ async function checkForUpdates() {
|
|||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
statusBox.style.display = 'block';
|
statusBox.style.display = 'block';
|
||||||
statusBox.className = 'update-status-box error';
|
// Если проверка отключена, показываем warning вместо ошибки
|
||||||
statusBox.querySelector('#update-status-message').innerHTML =
|
if (data.disabled || data.error.includes('disabled')) {
|
||||||
'<i class="fas fa-exclamation-circle"></i> ' + data.error;
|
statusBox.className = 'update-status-box warning';
|
||||||
|
statusBox.querySelector('#update-status-message').innerHTML =
|
||||||
|
'<i class="fas fa-info-circle"></i> ' + (t('update_check_disabled') || 'Проверка обновлений отключена');
|
||||||
|
} else {
|
||||||
|
statusBox.className = 'update-status-box error';
|
||||||
|
statusBox.querySelector('#update-status-message').innerHTML =
|
||||||
|
'<i class="fas fa-exclamation-circle"></i> ' + data.error;
|
||||||
|
}
|
||||||
updateBtn.style.display = 'none';
|
updateBtn.style.display = 'none';
|
||||||
changelogBox.style.display = 'none';
|
changelogBox.style.display = 'none';
|
||||||
} else if (data.update_available) {
|
} else if (data.update_available) {
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ services:
|
|||||||
- PYTHONPATH=/app
|
- PYTHONPATH=/app
|
||||||
- TZ=${TZ:-Asia/Tashkent}
|
- TZ=${TZ:-Asia/Tashkent}
|
||||||
- PROJECT_ROOT=/project
|
- PROJECT_ROOT=/project
|
||||||
|
# Отключить проверку обновлений для приватных репозиториев
|
||||||
|
- DISABLE_UPDATE_CHECK=${DISABLE_UPDATE_CHECK:-false}
|
||||||
|
# GitHub token для доступа к приватным репозиториям (опционально)
|
||||||
|
- GITHUB_TOKEN=${GITHUB_TOKEN:-}
|
||||||
# Оптимизация для production
|
# Оптимизация для production
|
||||||
- UVICORN_WORKERS=1
|
- UVICORN_WORKERS=1
|
||||||
- UVICORN_TIMEOUT_KEEP_ALIVE=30
|
- UVICORN_TIMEOUT_KEEP_ALIVE=30
|
||||||
|
|||||||
Reference in New Issue
Block a user