mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-25 13:51:50 +00:00
Merge pull request #628 from Fr1ngg/bedolaga/update-miniapp-index.html-template-mdwbtr
Enhance miniapp subscription details
This commit is contained in:
@@ -1,15 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.server_squad import get_server_squad_by_uuid
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.models import Subscription, Transaction
|
||||
from app.database.models import Subscription, Transaction, User
|
||||
from app.services.remnawave_service import (
|
||||
RemnaWaveConfigurationError,
|
||||
RemnaWaveService,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.subscription_utils import get_happ_cryptolink_redirect_link
|
||||
from app.utils.telegram_webapp import (
|
||||
@@ -19,6 +24,9 @@ from app.utils.telegram_webapp import (
|
||||
|
||||
from ..dependencies import get_db_session
|
||||
from ..schemas.miniapp import (
|
||||
MiniAppConnectedServer,
|
||||
MiniAppDevice,
|
||||
MiniAppPromoGroup,
|
||||
MiniAppSubscriptionRequest,
|
||||
MiniAppSubscriptionResponse,
|
||||
MiniAppSubscriptionUser,
|
||||
@@ -71,6 +79,122 @@ def _status_label(status: str) -> str:
|
||||
return mapping.get(status, status.title())
|
||||
|
||||
|
||||
def _parse_datetime_string(value: Optional[str]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
|
||||
try:
|
||||
cleaned = value.strip()
|
||||
if cleaned.endswith("Z"):
|
||||
cleaned = f"{cleaned[:-1]}+00:00"
|
||||
# Normalize duplicated timezone suffixes like +00:00+00:00
|
||||
if "+00:00+00:00" in cleaned:
|
||||
cleaned = cleaned.replace("+00:00+00:00", "+00:00")
|
||||
|
||||
datetime.fromisoformat(cleaned)
|
||||
return cleaned
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return value
|
||||
|
||||
|
||||
async def _resolve_connected_servers(
|
||||
db: AsyncSession,
|
||||
squad_uuids: List[str],
|
||||
) -> List[MiniAppConnectedServer]:
|
||||
if not squad_uuids:
|
||||
return []
|
||||
|
||||
resolved: Dict[str, str] = {}
|
||||
missing: List[str] = []
|
||||
|
||||
for squad_uuid in squad_uuids:
|
||||
if squad_uuid in resolved:
|
||||
continue
|
||||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
if server and server.display_name:
|
||||
resolved[squad_uuid] = server.display_name
|
||||
else:
|
||||
missing.append(squad_uuid)
|
||||
|
||||
if missing:
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
if service.is_configured:
|
||||
squads = await service.get_all_squads()
|
||||
for squad in squads:
|
||||
uuid = squad.get("uuid")
|
||||
name = squad.get("name")
|
||||
if uuid in missing and name:
|
||||
resolved[uuid] = name
|
||||
except RemnaWaveConfigurationError:
|
||||
logger.debug("RemnaWave is not configured; skipping server name enrichment")
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.warning("Failed to resolve server names from RemnaWave: %s", error)
|
||||
|
||||
connected_servers: List[MiniAppConnectedServer] = []
|
||||
for squad_uuid in squad_uuids:
|
||||
name = resolved.get(squad_uuid, squad_uuid)
|
||||
connected_servers.append(MiniAppConnectedServer(uuid=squad_uuid, name=name))
|
||||
|
||||
return connected_servers
|
||||
|
||||
|
||||
async def _load_devices_info(user: User) -> Tuple[int, List[MiniAppDevice]]:
|
||||
remnawave_uuid = getattr(user, "remnawave_uuid", None)
|
||||
if not remnawave_uuid:
|
||||
return 0, []
|
||||
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.warning("Failed to initialise RemnaWave service: %s", error)
|
||||
return 0, []
|
||||
|
||||
if not service.is_configured:
|
||||
return 0, []
|
||||
|
||||
try:
|
||||
async with service.get_api_client() as api:
|
||||
response = await api.get_user_devices(remnawave_uuid)
|
||||
except RemnaWaveConfigurationError:
|
||||
logger.debug("RemnaWave configuration missing while loading devices")
|
||||
return 0, []
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.warning("Failed to load devices from RemnaWave: %s", error)
|
||||
return 0, []
|
||||
|
||||
total_devices = int(response.get("total") or 0)
|
||||
devices_payload = response.get("devices") or []
|
||||
|
||||
devices: List[MiniAppDevice] = []
|
||||
for device in devices_payload:
|
||||
platform = device.get("platform") or device.get("platformType")
|
||||
model = device.get("deviceModel") or device.get("model") or device.get("name")
|
||||
app_version = device.get("appVersion") or device.get("version")
|
||||
last_seen_raw = (
|
||||
device.get("updatedAt")
|
||||
or device.get("lastSeen")
|
||||
or device.get("lastActiveAt")
|
||||
or device.get("createdAt")
|
||||
)
|
||||
last_ip = device.get("ip") or device.get("ipAddress")
|
||||
|
||||
devices.append(
|
||||
MiniAppDevice(
|
||||
platform=platform,
|
||||
device_model=model,
|
||||
app_version=app_version,
|
||||
last_seen=_parse_datetime_string(last_seen_raw),
|
||||
last_ip=last_ip,
|
||||
)
|
||||
)
|
||||
|
||||
if total_devices == 0:
|
||||
total_devices = len(devices)
|
||||
|
||||
return total_devices, devices
|
||||
|
||||
|
||||
def _resolve_display_name(user_data: Dict[str, Any]) -> str:
|
||||
username = user_data.get("username")
|
||||
if username:
|
||||
@@ -186,6 +310,8 @@ async def get_subscription_details(
|
||||
happ_redirect_link = get_happ_cryptolink_redirect_link(subscription_crypto_link)
|
||||
|
||||
connected_squads: List[str] = list(subscription.connected_squads or [])
|
||||
connected_servers = await _resolve_connected_servers(db, connected_squads)
|
||||
devices_count, devices = await _load_devices_info(user)
|
||||
links: List[str] = links_payload.get("links") or connected_squads
|
||||
ss_conf_links: Dict[str, str] = links_payload.get("ss_conf_links") or {}
|
||||
|
||||
@@ -202,6 +328,8 @@ async def get_subscription_details(
|
||||
if isinstance(balance_currency, str):
|
||||
balance_currency = balance_currency.upper()
|
||||
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
|
||||
response_user = MiniAppSubscriptionUser(
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
@@ -239,6 +367,9 @@ async def get_subscription_details(
|
||||
links=links,
|
||||
ss_conf_links=ss_conf_links,
|
||||
connected_squads=connected_squads,
|
||||
connected_servers=connected_servers,
|
||||
connected_devices_count=devices_count,
|
||||
connected_devices=devices,
|
||||
happ=links_payload.get("happ"),
|
||||
happ_link=links_payload.get("happ_link"),
|
||||
happ_crypto_link=links_payload.get("happ_crypto_link"),
|
||||
@@ -247,5 +378,10 @@ async def get_subscription_details(
|
||||
balance_rubles=round(user.balance_rubles, 2),
|
||||
balance_currency=balance_currency,
|
||||
transactions=[_serialize_transaction(tx) for tx in transactions],
|
||||
promo_group=MiniAppPromoGroup(id=promo_group.id, name=promo_group.name)
|
||||
if promo_group
|
||||
else None,
|
||||
subscription_type="trial" if subscription.is_trial else "paid",
|
||||
autopay_enabled=bool(subscription.autopay_enabled),
|
||||
)
|
||||
|
||||
|
||||
@@ -31,6 +31,24 @@ class MiniAppSubscriptionUser(BaseModel):
|
||||
has_active_subscription: bool = False
|
||||
|
||||
|
||||
class MiniAppPromoGroup(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
class MiniAppConnectedServer(BaseModel):
|
||||
uuid: str
|
||||
name: str
|
||||
|
||||
|
||||
class MiniAppDevice(BaseModel):
|
||||
platform: Optional[str] = None
|
||||
device_model: Optional[str] = None
|
||||
app_version: Optional[str] = None
|
||||
last_seen: Optional[str] = None
|
||||
last_ip: Optional[str] = None
|
||||
|
||||
|
||||
class MiniAppTransaction(BaseModel):
|
||||
id: int
|
||||
type: str
|
||||
@@ -54,6 +72,9 @@ class MiniAppSubscriptionResponse(BaseModel):
|
||||
links: List[str] = Field(default_factory=list)
|
||||
ss_conf_links: Dict[str, str] = Field(default_factory=dict)
|
||||
connected_squads: List[str] = Field(default_factory=list)
|
||||
connected_servers: List[MiniAppConnectedServer] = Field(default_factory=list)
|
||||
connected_devices_count: int = 0
|
||||
connected_devices: List[MiniAppDevice] = Field(default_factory=list)
|
||||
happ: Optional[Dict[str, Any]] = None
|
||||
happ_link: Optional[str] = None
|
||||
happ_crypto_link: Optional[str] = None
|
||||
@@ -62,4 +83,7 @@ class MiniAppSubscriptionResponse(BaseModel):
|
||||
balance_rubles: float = 0.0
|
||||
balance_currency: Optional[str] = None
|
||||
transactions: List[MiniAppTransaction] = Field(default_factory=list)
|
||||
promo_group: Optional[MiniAppPromoGroup] = None
|
||||
subscription_type: str
|
||||
autopay_enabled: bool = False
|
||||
|
||||
|
||||
@@ -230,6 +230,39 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.device-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.device-item {
|
||||
padding: 12px;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
box-shadow: inset 0 0 0 1px var(--border-color);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.device-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.device-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.device-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* User Info */
|
||||
.user-header {
|
||||
display: flex;
|
||||
@@ -302,7 +335,7 @@
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@@ -606,6 +639,10 @@
|
||||
<div class="stat-value" id="serversCount">-</div>
|
||||
<div class="stat-label" data-i18n="stats.servers">Servers</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="devicesCount">-</div>
|
||||
<div class="stat-label" data-i18n="stats.devices">Devices</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
@@ -620,6 +657,22 @@
|
||||
<span class="info-label" data-i18n="info.traffic_limit">Traffic Limit</span>
|
||||
<span class="info-value" id="trafficLimit">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label" data-i18n="info.subscription_type">Subscription Type</span>
|
||||
<span class="info-value" id="subscriptionType">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label" data-i18n="info.promo_group">Promo Group</span>
|
||||
<span class="info-value" id="promoGroup">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label" data-i18n="info.device_limit">Device Limit</span>
|
||||
<span class="info-value" id="deviceLimit">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label" data-i18n="info.autopay">Auto-Pay</span>
|
||||
<span class="info-value" id="autopayStatus">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
@@ -660,6 +713,12 @@
|
||||
<div class="empty-state hidden" id="serversEmpty" data-i18n="servers.empty">No servers connected yet</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="devicesCard">
|
||||
<div class="card-title" data-i18n="card.devices.title">Connected Devices</div>
|
||||
<ul class="device-list" id="devicesList"></ul>
|
||||
<div class="empty-state hidden" id="devicesEmpty" data-i18n="devices.empty">No devices connected yet</div>
|
||||
</div>
|
||||
|
||||
<!-- App Installation Section -->
|
||||
<div class="app-section">
|
||||
<div class="card">
|
||||
@@ -716,15 +775,21 @@
|
||||
'error.default.message': 'Please contact support to activate your subscription.',
|
||||
'stats.days_left': 'Days left',
|
||||
'stats.servers': 'Servers',
|
||||
'stats.devices': 'Devices',
|
||||
'info.expires': 'Expires',
|
||||
'info.traffic_used': 'Traffic used',
|
||||
'info.traffic_limit': 'Traffic limit',
|
||||
'info.subscription_type': 'Subscription type',
|
||||
'info.promo_group': 'Promo group',
|
||||
'info.device_limit': 'Device limit',
|
||||
'info.autopay': 'Auto-pay',
|
||||
'button.connect.default': 'Connect to VPN',
|
||||
'button.connect.happ': 'Connect',
|
||||
'button.copy': 'Copy subscription link',
|
||||
'card.balance.title': 'Balance',
|
||||
'card.history.title': 'Transaction History',
|
||||
'card.servers.title': 'Connected Servers',
|
||||
'card.devices.title': 'Connected Devices',
|
||||
'apps.title': 'Installation guide',
|
||||
'apps.no_data': 'No installation guide available for this platform yet.',
|
||||
'apps.featured': 'Recommended',
|
||||
@@ -740,6 +805,7 @@
|
||||
'history.type.refund': 'Refund',
|
||||
'history.type.referral_reward': 'Referral reward',
|
||||
'servers.empty': 'No servers connected yet',
|
||||
'devices.empty': 'No devices connected yet',
|
||||
'language.ariaLabel': 'Select interface language',
|
||||
'notifications.copy.success': 'Subscription link copied to clipboard.',
|
||||
'notifications.copy.failure': 'Unable to copy the subscription link automatically. Please copy it manually.',
|
||||
@@ -750,12 +816,17 @@
|
||||
'status.expired': 'Expired',
|
||||
'status.disabled': 'Disabled',
|
||||
'status.unknown': 'Unknown',
|
||||
'subscription.type.trial': 'Trial',
|
||||
'subscription.type.paid': 'Paid',
|
||||
'autopay.enabled': 'Enabled',
|
||||
'autopay.disabled': 'Disabled',
|
||||
'platform.ios': 'iOS',
|
||||
'platform.android': 'Android',
|
||||
'platform.pc': 'PC',
|
||||
'platform.tv': 'TV',
|
||||
'units.gb': 'GB',
|
||||
'values.unlimited': 'Unlimited'
|
||||
'values.unlimited': 'Unlimited',
|
||||
'values.not_available': 'Not available'
|
||||
},
|
||||
ru: {
|
||||
'app.title': 'Подписка VPN',
|
||||
@@ -766,15 +837,21 @@
|
||||
'error.default.message': 'Свяжитесь с поддержкой, чтобы активировать подписку.',
|
||||
'stats.days_left': 'Осталось дней',
|
||||
'stats.servers': 'Серверы',
|
||||
'stats.devices': 'Устройства',
|
||||
'info.expires': 'Действует до',
|
||||
'info.traffic_used': 'Использовано трафика',
|
||||
'info.traffic_limit': 'Лимит трафика',
|
||||
'info.subscription_type': 'Тип подписки',
|
||||
'info.promo_group': 'Промогруппа',
|
||||
'info.device_limit': 'Лимит устройств',
|
||||
'info.autopay': 'Автоплатеж',
|
||||
'button.connect.default': 'Подключиться к VPN',
|
||||
'button.connect.happ': 'Подключиться',
|
||||
'button.copy': 'Скопировать ссылку подписки',
|
||||
'card.balance.title': 'Баланс',
|
||||
'card.history.title': 'История операций',
|
||||
'card.servers.title': 'Подключённые серверы',
|
||||
'card.devices.title': 'Подключенные устройства',
|
||||
'apps.title': 'Инструкция по установке',
|
||||
'apps.no_data': 'Для этой платформы инструкция пока недоступна.',
|
||||
'apps.featured': 'Рекомендуем',
|
||||
@@ -790,6 +867,7 @@
|
||||
'history.type.refund': 'Возврат',
|
||||
'history.type.referral_reward': 'Реферальное вознаграждение',
|
||||
'servers.empty': 'Подключённых серверов пока нет',
|
||||
'devices.empty': 'Подключённых устройств пока нет',
|
||||
'language.ariaLabel': 'Выберите язык интерфейса',
|
||||
'notifications.copy.success': 'Ссылка подписки скопирована.',
|
||||
'notifications.copy.failure': 'Не удалось автоматически скопировать ссылку. Пожалуйста, сделайте это вручную.',
|
||||
@@ -800,12 +878,17 @@
|
||||
'status.expired': 'Истекла',
|
||||
'status.disabled': 'Отключена',
|
||||
'status.unknown': 'Неизвестно',
|
||||
'subscription.type.trial': 'Триал',
|
||||
'subscription.type.paid': 'Платная',
|
||||
'autopay.enabled': 'Включен',
|
||||
'autopay.disabled': 'Выключен',
|
||||
'platform.ios': 'iOS',
|
||||
'platform.android': 'Android',
|
||||
'platform.pc': 'ПК',
|
||||
'platform.tv': 'TV',
|
||||
'platform.tv': 'ТВ',
|
||||
'units.gb': 'ГБ',
|
||||
'values.unlimited': 'Безлимит'
|
||||
'values.unlimited': 'Безлимит',
|
||||
'values.not_available': 'Недоступно'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1087,19 +1170,74 @@
|
||||
|
||||
const serversCount = Array.isArray(userData.connected_squads)
|
||||
? userData.connected_squads.length
|
||||
: Array.isArray(userData.links)
|
||||
? userData.links.length
|
||||
: 0;
|
||||
: Array.isArray(userData.connected_servers)
|
||||
? userData.connected_servers.length
|
||||
: Array.isArray(userData.links)
|
||||
? userData.links.length
|
||||
: 0;
|
||||
document.getElementById('serversCount').textContent = serversCount;
|
||||
|
||||
const devicesCountRaw = Number(userData?.connected_devices_count);
|
||||
const devicesCount = Number.isFinite(devicesCountRaw)
|
||||
? devicesCountRaw
|
||||
: Array.isArray(userData?.connected_devices)
|
||||
? userData.connected_devices.length
|
||||
: 0;
|
||||
const devicesCountElement = document.getElementById('devicesCount');
|
||||
if (devicesCountElement) {
|
||||
devicesCountElement.textContent = devicesCount;
|
||||
}
|
||||
|
||||
document.getElementById('trafficUsed').textContent =
|
||||
user.traffic_used_label || formatTraffic(user.traffic_used_gb);
|
||||
document.getElementById('trafficLimit').textContent =
|
||||
user.traffic_limit_label || formatTrafficLimit(user.traffic_limit_gb);
|
||||
|
||||
const deviceLimitElement = document.getElementById('deviceLimit');
|
||||
if (deviceLimitElement) {
|
||||
const limitValue = typeof user.device_limit === 'number'
|
||||
? user.device_limit
|
||||
: Number.parseInt(user.device_limit ?? '', 10);
|
||||
deviceLimitElement.textContent = Number.isFinite(limitValue)
|
||||
? String(limitValue)
|
||||
: t('values.not_available');
|
||||
}
|
||||
|
||||
const subscriptionTypeElement = document.getElementById('subscriptionType');
|
||||
if (subscriptionTypeElement) {
|
||||
const fallbackSubscriptionType = (user?.subscription_status || '').toLowerCase() === 'trial'
|
||||
? 'trial'
|
||||
: 'paid';
|
||||
const subscriptionTypeRaw = String(
|
||||
userData?.subscription_type
|
||||
|| fallbackSubscriptionType
|
||||
).toLowerCase();
|
||||
const subscriptionTypeKey = `subscription.type.${subscriptionTypeRaw}`;
|
||||
const subscriptionTypeLabel = t(subscriptionTypeKey);
|
||||
subscriptionTypeElement.textContent = subscriptionTypeLabel === subscriptionTypeKey
|
||||
? subscriptionTypeRaw
|
||||
: subscriptionTypeLabel;
|
||||
}
|
||||
|
||||
const promoGroupElement = document.getElementById('promoGroup');
|
||||
if (promoGroupElement) {
|
||||
const promoGroupName = userData?.promo_group?.name || user?.promo_group?.name;
|
||||
promoGroupElement.textContent = promoGroupName || t('values.not_available');
|
||||
}
|
||||
|
||||
const autopayElement = document.getElementById('autopayStatus');
|
||||
if (autopayElement) {
|
||||
const autopayKey = userData?.autopay_enabled ? 'autopay.enabled' : 'autopay.disabled';
|
||||
const autopayLabel = t(autopayKey);
|
||||
autopayElement.textContent = autopayLabel === autopayKey
|
||||
? (userData?.autopay_enabled ? 'On' : 'Off')
|
||||
: autopayLabel;
|
||||
}
|
||||
|
||||
renderBalanceSection();
|
||||
renderTransactionHistory();
|
||||
renderServersList();
|
||||
renderDevicesList();
|
||||
updateConnectButtonLabel();
|
||||
updateActionButtons();
|
||||
}
|
||||
@@ -1432,7 +1570,16 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const servers = Array.isArray(userData?.connected_squads) ? userData.connected_squads : [];
|
||||
let servers = [];
|
||||
if (Array.isArray(userData?.connected_servers) && userData.connected_servers.length) {
|
||||
servers = userData.connected_servers.map(server => ({
|
||||
uuid: server?.uuid || '',
|
||||
name: server?.name || server?.uuid || '',
|
||||
}));
|
||||
} else if (Array.isArray(userData?.connected_squads)) {
|
||||
servers = userData.connected_squads.map(uuid => ({ uuid, name: uuid }));
|
||||
}
|
||||
|
||||
if (!servers.length) {
|
||||
list.innerHTML = '';
|
||||
emptyState.textContent = t('servers.empty');
|
||||
@@ -1441,7 +1588,63 @@
|
||||
}
|
||||
|
||||
emptyState.classList.add('hidden');
|
||||
list.innerHTML = servers.map(server => `<li class="server-item">${escapeHtml(server)}</li>`).join('');
|
||||
list.innerHTML = servers
|
||||
.map(server => `<li class="server-item">${escapeHtml(server.name || server.uuid || '')}</li>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function renderDevicesList() {
|
||||
const list = document.getElementById('devicesList');
|
||||
const emptyState = document.getElementById('devicesEmpty');
|
||||
if (!list || !emptyState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const devices = Array.isArray(userData?.connected_devices)
|
||||
? userData.connected_devices
|
||||
: [];
|
||||
|
||||
if (!devices.length) {
|
||||
list.innerHTML = '';
|
||||
emptyState.textContent = t('devices.empty');
|
||||
emptyState.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.classList.add('hidden');
|
||||
list.innerHTML = devices.map(device => {
|
||||
const platform = device?.platform ? String(device.platform) : '';
|
||||
const model = device?.device_model ? String(device.device_model) : '';
|
||||
const titleParts = [platform, model].filter(Boolean);
|
||||
const title = titleParts.length
|
||||
? titleParts.join(' — ')
|
||||
: t('values.not_available');
|
||||
|
||||
const metaParts = [];
|
||||
if (device?.app_version) {
|
||||
metaParts.push(String(device.app_version));
|
||||
}
|
||||
if (device?.last_seen) {
|
||||
const formatted = formatDateTime(device.last_seen);
|
||||
if (formatted) {
|
||||
metaParts.push(formatted);
|
||||
}
|
||||
}
|
||||
if (device?.last_ip) {
|
||||
metaParts.push(String(device.last_ip));
|
||||
}
|
||||
|
||||
const metaHtml = metaParts.length
|
||||
? `<div class="device-meta">${metaParts.map(part => `<span>${escapeHtml(part)}</span>`).join('')}</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<li class="device-item">
|
||||
<div class="device-title">${escapeHtml(title)}</div>
|
||||
${metaHtml}
|
||||
</li>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function getCurrentSubscriptionUrl() {
|
||||
|
||||
Reference in New Issue
Block a user