From bfe0a7d68f2515fc0a953e6f8c6a39dd5224dc86 Mon Sep 17 00:00:00 2001 From: Matin Dehghanian Date: Tue, 24 Feb 2026 21:30:40 +0330 Subject: [PATCH 01/28] fix(configs-qrcode-modal): update fetch URL to use base64 links and decode response --- dashboard/src/components/dialogs/configs-qrcode-modal.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dashboard/src/components/dialogs/configs-qrcode-modal.tsx b/dashboard/src/components/dialogs/configs-qrcode-modal.tsx index f3960212b..9bd47008b 100644 --- a/dashboard/src/components/dialogs/configs-qrcode-modal.tsx +++ b/dashboard/src/components/dialogs/configs-qrcode-modal.tsx @@ -65,11 +65,12 @@ const ConfigsQRCodeModal: FC = memo(({ subscribeUrl, on setIsLoading(true) setError(null) try { - const response = await fetch(`${sublink}/links`) + const response = await fetch(`${sublink}/links_base64`) if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`) } - const text = await response.text() + const base64Text = await response.text() + const text = atob(base64Text) const configLines = text.split('\n').filter(line => line.trim() !== '') setConfigs(configLines.map((config, index) => ({ config, index }))) setCurrentIndex(0) From 2ba918ac0ca504d8ce88f8ef3170efd4e4098ff6 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Tue, 24 Feb 2026 22:20:57 +0330 Subject: [PATCH 02/28] feat(telegram): implement NATS-backed memory storage for FSM synchronization (#299) * feat(telegram): implement NATS-backed memory storage for FSM synchronization * refactor: Updated to the aiogram-style implementation --- app/telegram/__init__.py | 19 +- app/telegram/fsm_storage.py | 371 ++++++++++++++++++++++++++++++++++++ 2 files changed, 387 insertions(+), 3 deletions(-) create mode 100644 app/telegram/fsm_storage.py diff --git a/app/telegram/__init__.py b/app/telegram/__init__.py index 3985a84d1..ea5f43aff 100644 --- a/app/telegram/__init__.py +++ b/app/telegram/__init__.py @@ -7,17 +7,19 @@ from aiogram.client.session.aiohttp import AiohttpSession from aiogram.enums import ParseMode from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError, TelegramRetryAfter, TelegramUnauthorizedError +from aiogram.fsm.storage.memory import MemoryStorage from nats.js.kv import KeyValue from python_socks._errors import ProxyConnectionError from app import on_shutdown, on_startup from app.models.settings import RunMethod, Telegram -from app.settings import telegram_settings -from app.utils.logger import get_logger from app.nats import is_nats_enabled from app.nats.client import setup_nats_kv +from app.settings import telegram_settings +from app.utils.logger import get_logger from config import NATS_TELEGRAM_KV_BUCKET +from .fsm_storage import NatsFSMStorage from .handlers import include_routers from .middlewares import setup_middlewares @@ -29,7 +31,7 @@ def __init__(self): self._bot: Bot | None = None self._polling_task: asyncio.Task | None = None self._lock = Lock() - self._dp = Dispatcher() + self._dp = self._create_dispatcher() self._handlers_registered = False self._shutdown_in_progress = False self._stop_requested = False @@ -37,6 +39,13 @@ def __init__(self): self._kv: KeyValue | None = None self._nats_conn = None + @staticmethod + def _create_dispatcher() -> Dispatcher: + if is_nats_enabled(): + storage = NatsFSMStorage(NATS_TELEGRAM_KV_BUCKET) + return Dispatcher(storage=storage, events_isolation=storage.create_isolation()) + return Dispatcher(storage=MemoryStorage()) + def get_bot(self) -> Bot | None: return self._bot @@ -132,6 +141,10 @@ async def shutdown(self): async with self._lock: self._stop_requested = True await self._shutdown_locked() + try: + await self._dp.fsm.close() + except Exception: + pass # Close NATS KV connection if one was opened if self._nats_conn: try: diff --git a/app/telegram/fsm_storage.py b/app/telegram/fsm_storage.py new file mode 100644 index 000000000..9306333cf --- /dev/null +++ b/app/telegram/fsm_storage.py @@ -0,0 +1,371 @@ +import asyncio +import hashlib +import json +import time +import uuid +from collections import defaultdict +from collections.abc import AsyncGenerator, Callable, Mapping +from contextlib import asynccontextmanager +from typing import Any, cast + +import nats.js.errors as nats_js_errors +from aiogram.exceptions import DataNotDictLikeError +from aiogram.fsm.state import State +from aiogram.fsm.storage.base import ( + BaseEventIsolation, + BaseStorage, + DefaultKeyBuilder, + KeyBuilder, + StateType, + StorageKey, +) +from aiogram.fsm.storage.memory import MemoryStorage +from nats.js.kv import KeyValue + +from app.nats import is_nats_enabled +from app.nats.client import setup_nats_kv +from app.utils.logger import get_logger + +logger = get_logger("telegram-fsm") + +DEFAULT_LOCK_TTL_SECONDS = 60.0 +DEFAULT_LOCK_RETRY_DELAY_SECONDS = 0.05 +DEFAULT_CONNECT_RETRY_BACKOFF_SECONDS = 10.0 + +_JsonLoads = Callable[..., Any] +_JsonDumps = Callable[..., str] + + +class NatsFSMStorage(BaseStorage): + """ + Aiogram FSM storage backed by NATS KV with in-memory fallback. + + Data model follows aiogram's built-in storages: + - state and data are stored as separate records + - event isolation is delegated to NatsEventIsolation + """ + + def __init__( + self, + bucket_name: str, + key_builder: KeyBuilder | None = None, + json_loads: _JsonLoads = json.loads, + json_dumps: _JsonDumps = json.dumps, + key_prefix: str = "fsm", + ) -> None: + if key_builder is None: + key_builder = DefaultKeyBuilder( + prefix="fsm", + with_bot_id=True, + with_business_connection_id=True, + with_destiny=True, + ) + + self._memory = MemoryStorage() + self._bucket_name = bucket_name + self._key_prefix = key_prefix + self.key_builder = key_builder + self.json_loads = json_loads + self.json_dumps = json_dumps + + self._nc = None + self._kv: KeyValue | None = None + self._connect_lock = asyncio.Lock() + self._next_connect_try_at = 0.0 + + self._nats_enabled = is_nats_enabled() + + def create_isolation( + self, + lock_ttl: float = DEFAULT_LOCK_TTL_SECONDS, + retry_delay: float = DEFAULT_LOCK_RETRY_DELAY_SECONDS, + ) -> "NatsEventIsolation": + return NatsEventIsolation( + storage=self, + key_builder=self.key_builder, + lock_ttl=lock_ttl, + retry_delay=retry_delay, + ) + + @staticmethod + def _normalize_state(state: StateType = None) -> str | None: + return cast(str | None, state.state if isinstance(state, State) else state) + + def _to_nats_key(self, raw_key: str, part: str) -> str: + digest = hashlib.sha256(raw_key.encode()).hexdigest() + return f"{self._key_prefix}.{part}.{digest}" + + def build_kv_key(self, key: StorageKey, part: str, key_builder: KeyBuilder | None = None) -> str: + builder = key_builder or self.key_builder + raw_key = builder.build(key, part) + return self._to_nats_key(raw_key, part) + + async def ensure_kv(self) -> KeyValue | None: + if not self._nats_enabled: + return None + + if self._kv: + return self._kv + + now = time.monotonic() + if now < self._next_connect_try_at: + return None + + async with self._connect_lock: + if self._kv: + return self._kv + + now = time.monotonic() + if now < self._next_connect_try_at: + return None + + try: + self._nc, _, self._kv = await setup_nats_kv(self._bucket_name) + except Exception as exc: + logger.warning(f"Failed to initialize NATS KV for Telegram FSM: {exc}") + self._kv = None + + if self._kv: + self._next_connect_try_at = 0.0 + return self._kv + + self._next_connect_try_at = time.monotonic() + DEFAULT_CONNECT_RETRY_BACKOFF_SECONDS + logger.warning("NATS KV unavailable for Telegram FSM, using in-memory fallback") + return None + + async def _safe_get(self, kv_key: str) -> KeyValue.Entry | None: + kv = await self.ensure_kv() + if not kv: + return None + + try: + return await kv.get(kv_key) + except (nats_js_errors.KeyNotFoundError, nats_js_errors.KeyDeletedError): + return None + except Exception as exc: + logger.warning(f"Failed to read Telegram FSM record from NATS KV: {exc}") + return None + + async def set_state(self, key: StorageKey, state: StateType = None) -> None: + normalized_state = self._normalize_state(state) + await self._memory.set_state(key, normalized_state) + + kv = await self.ensure_kv() + if not kv: + return + + kv_key = self.build_kv_key(key, "state") + if normalized_state is None: + try: + await kv.delete(kv_key) + except Exception: + pass + return + + try: + await kv.put(kv_key, normalized_state.encode("utf-8")) + except Exception as exc: + logger.warning(f"Failed to write Telegram FSM state to NATS KV: {exc}") + + async def get_state(self, key: StorageKey) -> str | None: + entry = await self._safe_get(self.build_kv_key(key, "state")) + if entry and entry.value is not None: + try: + value = entry.value.decode("utf-8") + except Exception as exc: + logger.warning(f"Failed to decode Telegram FSM state from NATS KV: {exc}") + else: + await self._memory.set_state(key, value) + return value + + return await self._memory.get_state(key) + + async def set_data(self, key: StorageKey, data: Mapping[str, Any]) -> None: + if not isinstance(data, dict): + msg = f"Data must be a dict or dict-like object, got {type(data).__name__}" + raise DataNotDictLikeError(msg) + + normalized_data = data.copy() + await self._memory.set_data(key, normalized_data) + + kv = await self.ensure_kv() + if not kv: + return + + kv_key = self.build_kv_key(key, "data") + if not normalized_data: + try: + await kv.delete(kv_key) + except Exception: + pass + return + + try: + payload = self.json_dumps(normalized_data) + if isinstance(payload, bytes): + encoded = payload + else: + encoded = payload.encode("utf-8") + except TypeError: + logger.warning("Telegram FSM data is not JSON-serializable; skipped NATS KV sync") + return + + try: + await kv.put(kv_key, encoded) + except Exception as exc: + logger.warning(f"Failed to write Telegram FSM data to NATS KV: {exc}") + + async def get_data(self, key: StorageKey) -> dict[str, Any]: + entry = await self._safe_get(self.build_kv_key(key, "data")) + if entry and entry.value is not None: + try: + raw_value = entry.value.decode("utf-8") + payload = self.json_loads(raw_value) + except Exception as exc: + logger.warning(f"Failed to decode Telegram FSM data from NATS KV: {exc}") + else: + if isinstance(payload, dict): + await self._memory.set_data(key, payload) + return payload.copy() + + logger.warning("Invalid Telegram FSM data payload in NATS KV, expected dict") + + return await self._memory.get_data(key) + + async def close(self) -> None: + await self._memory.close() + + if self._nc: + try: + await self._nc.close() + except Exception: + pass + + self._nc = None + self._kv = None + self._next_connect_try_at = 0.0 + + +class NatsEventIsolation(BaseEventIsolation): + def __init__( + self, + storage: NatsFSMStorage, + key_builder: KeyBuilder | None = None, + lock_ttl: float = DEFAULT_LOCK_TTL_SECONDS, + retry_delay: float = DEFAULT_LOCK_RETRY_DELAY_SECONDS, + ) -> None: + if key_builder is None: + key_builder = storage.key_builder + + self.storage = storage + self.key_builder = key_builder + self.lock_ttl = lock_ttl + self.retry_delay = retry_delay + self._local_locks: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock) + + @staticmethod + def _build_lock_payload(token: str, expires_at: float) -> bytes: + return json.dumps({"token": token, "expires_at": expires_at}).encode("utf-8") + + @staticmethod + def _parse_lock_payload(value: bytes | None) -> tuple[str, float] | None: + if not value: + return None + + try: + payload = json.loads(value.decode("utf-8")) + except Exception: + return None + + token = payload.get("token") + expires_at = payload.get("expires_at") + + if not isinstance(token, str): + return None + + try: + expires_at_value = float(expires_at) + except (TypeError, ValueError): + return None + + return token, expires_at_value + + async def _acquire_distributed_lock(self, kv: KeyValue, lock_key: str) -> str: + token = uuid.uuid4().hex + + while True: + now = time.time() + payload = self._build_lock_payload(token, now + self.lock_ttl) + + try: + await kv.create(lock_key, payload) + return token + except nats_js_errors.KeyWrongLastSequenceError: + pass + except Exception as exc: + logger.warning(f"Failed to create Telegram FSM lock in NATS KV: {exc}") + await asyncio.sleep(self.retry_delay) + continue + + try: + entry = await kv.get(lock_key) + except (nats_js_errors.KeyNotFoundError, nats_js_errors.KeyDeletedError): + await asyncio.sleep(self.retry_delay) + continue + except Exception as exc: + logger.warning(f"Failed to read Telegram FSM lock from NATS KV: {exc}") + await asyncio.sleep(self.retry_delay) + continue + + lock_info = self._parse_lock_payload(entry.value) + is_expired = lock_info is None or lock_info[1] <= now + if is_expired: + try: + await kv.update(lock_key, payload, last=entry.revision) + return token + except nats_js_errors.KeyWrongLastSequenceError: + pass + except Exception as exc: + logger.warning(f"Failed to steal expired Telegram FSM lock in NATS KV: {exc}") + + await asyncio.sleep(self.retry_delay) + + async def _release_distributed_lock(self, kv: KeyValue, lock_key: str, token: str) -> None: + try: + entry = await kv.get(lock_key) + except (nats_js_errors.KeyNotFoundError, nats_js_errors.KeyDeletedError): + return + except Exception as exc: + logger.warning(f"Failed to read Telegram FSM lock for release from NATS KV: {exc}") + return + + lock_info = self._parse_lock_payload(entry.value) + if not lock_info or lock_info[0] != token: + return + + try: + await kv.delete(lock_key, last=entry.revision) + except nats_js_errors.KeyWrongLastSequenceError: + pass + except Exception as exc: + logger.warning(f"Failed to release Telegram FSM lock in NATS KV: {exc}") + + @asynccontextmanager + async def lock(self, key: StorageKey) -> AsyncGenerator[None, None]: + lock_key = self.storage.build_kv_key(key, "lock", key_builder=self.key_builder) + kv = await self.storage.ensure_kv() + + if not kv: + local_lock = self._local_locks[lock_key] + async with local_lock: + yield + return + + token = await self._acquire_distributed_lock(kv, lock_key) + try: + yield + finally: + await self._release_distributed_lock(kv, lock_key, token) + + async def close(self) -> None: + self._local_locks.clear() From 572124af09f00067e2467fa612baa125b57913c3 Mon Sep 17 00:00:00 2001 From: x0sina Date: Tue, 24 Feb 2026 23:03:24 +0330 Subject: [PATCH 03/28] feat(user-modal): integrate dropdown menu for user actions and streamline button layout for enhanced usability --- .../src/components/dialogs/user-modal.tsx | 94 +++++++++---------- 1 file changed, 43 insertions(+), 51 deletions(-) diff --git a/dashboard/src/components/dialogs/user-modal.tsx b/dashboard/src/components/dialogs/user-modal.tsx index 566f4d7ba..d200792e1 100644 --- a/dashboard/src/components/dialogs/user-modal.tsx +++ b/dashboard/src/components/dialogs/user-modal.tsx @@ -7,6 +7,7 @@ import UserAllIPsModal from '@/components/dialogs/user-all-ips-modal' import { UserSubscriptionClientsModal } from '@/components/dialogs/user-subscription-clients-modal' import UsageModal from '@/components/dialogs/usage-modal' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { Input } from '@/components/ui/input' import { LoaderButton } from '@/components/ui/loader-button' @@ -38,7 +39,7 @@ import { formatOffsetDateTime, parseDateInput, toDisplayDate, toUnixSeconds } fr import { dateUtils, useRelativeExpiryDate } from '@/utils/dateFormatter' import { formatBytes, gbToBytes } from '@/utils/formatByte' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { CalendarClock, CalendarPlus, ChevronDown, Info, Layers, Link2Off, ListStart, Lock, Menu, Network, PieChart, RefreshCcw, Users } from 'lucide-react' +import { CalendarClock, CalendarPlus, ChevronDown, EllipsisVertical, Info, Layers, Link2Off, ListStart, Lock, Network, PieChart, RefreshCcw, Users } from 'lucide-react' import React, { useEffect, useState } from 'react' import { UseFormReturn } from 'react-hook-form' import { useTranslation } from 'react-i18next' @@ -1341,54 +1342,6 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse return (
- - - - - {t('actions', { defaultValue: 'Actions' })} - - - -
- {isSudo && ( - - )} - - - - -
-
- - @@ -2461,8 +2414,47 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse {renderUserMetaPanel('mt-4 lg:hidden')}
{/* Cancel/Create buttons - always visible */} -
-
+
+ {editingUser && ( + + + + + + {isSudo && ( + setUserAllIPsModalOpen(true)}> + + {t('userAllIPs.ipAddresses', { defaultValue: 'IP addresses' })} + + )} + setUsageModalOpen(true)}> + + {t('userDialog.usage', { defaultValue: 'Usage' })} + + setSubscriptionClientsModalOpen(true)}> + + {t('subscriptionClients.clients', { defaultValue: 'Clients' })} + + setRevokeSubDialogOpen(true)}> + + {t('userDialog.revokeSubscription', { defaultValue: 'Revoke subscription' })} + + setResetUsageDialogOpen(true)}> + + {t('userDialog.resetUsage', { defaultValue: 'Reset usage' })} + + + + )} +
- {row.original.admin?.username && ( + {showCreatedBy && row.original.admin?.username && ( {t('created')} {t('by')} diff --git a/dashboard/src/components/users/users-table.tsx b/dashboard/src/components/users/users-table.tsx index d7ff0dc3a..feb8e20a2 100644 --- a/dashboard/src/components/users/users-table.tsx +++ b/dashboard/src/components/users/users-table.tsx @@ -5,7 +5,7 @@ import { type UseEditFormValues } from '@/components/forms/user-form' import useDirDetection from '@/hooks/use-dir-detection' import { useGetUsers, UserResponse, UserStatus, UsersResponse } from '@/service/api' import { useAdmin } from '@/hooks/use-admin' -import { getUsersPerPageLimitSize, setUsersPerPageLimitSize } from '@/utils/userPreferenceStorage' +import { getUsersPerPageLimitSize, getUsersShowCreatedBy, setUsersPerPageLimitSize, setUsersShowCreatedBy } from '@/utils/userPreferenceStorage' import { normalizeExpireForEditForm } from '@/utils/userEditDateUtils' import { useQueryClient } from '@tanstack/react-query' import { memo, useCallback, useEffect, useRef, useState } from 'react' @@ -104,6 +104,7 @@ const UsersTable = memo(() => { const [selectedUser, setSelectedUser] = useState(null) const [isAdvanceSearchOpen, setIsAdvanceSearchOpen] = useState(false) const [isSorting, setIsSorting] = useState(false) + const [showCreatedBy, setShowCreatedBy] = useState(getUsersShowCreatedBy()) const [filters, setFilters] = useState<{ limit: number @@ -171,6 +172,7 @@ const UsersTable = memo(() => { return { is_username: !urlParams.isProtocol, is_protocol: urlParams.isProtocol, + show_created_by: getUsersShowCreatedBy(), admin: urlParams.admin || [], group: urlParams.group || [], status: urlParams.status || '0', @@ -244,8 +246,9 @@ const UsersTable = memo(() => { advanceSearchForm.setValue('status', filters.status || '0') advanceSearchForm.setValue('admin', filters.admin || []) advanceSearchForm.setValue('group', filters.group || []) + advanceSearchForm.setValue('show_created_by', showCreatedBy) } - }, [isAdvanceSearchOpen, filters.status, filters.admin, filters.group, advanceSearchForm]) + }, [isAdvanceSearchOpen, filters.status, filters.admin, filters.group, showCreatedBy, advanceSearchForm]) const { data: usersData, @@ -468,12 +471,17 @@ const UsersTable = memo(() => { const columns = setupColumns({ t, dir, + showCreatedBy: isSudo && showCreatedBy, handleSort, filters: filters as { sort: string; status?: UserStatus | null; [key: string]: unknown }, handleStatusFilter, }) const handleAdvanceSearchSubmit = (values: AdvanceSearchFormValue) => { + if (isSudo) { + setShowCreatedBy(values.show_created_by) + setUsersShowCreatedBy(values.show_created_by) + } setFilters(prev => ({ ...prev, admin: values.admin && values.admin.length > 0 ? values.admin : undefined, @@ -509,6 +517,7 @@ const UsersTable = memo(() => { advanceSearchForm.reset({ is_username: true, is_protocol: false, + show_created_by: showCreatedBy, admin: [], group: [], status: '0', @@ -591,6 +600,11 @@ const UsersTable = memo(() => { form={advanceSearchForm} onSubmit={handleAdvanceSearchSubmit} isSudo={isSudo} + onShowCreatedByChange={value => { + if (!isSudo) return + setShowCreatedBy(value) + setUsersShowCreatedBy(value) + }} /> )}
diff --git a/dashboard/src/utils/userPreferenceStorage.ts b/dashboard/src/utils/userPreferenceStorage.ts index 3c7aa8644..53ccec42d 100644 --- a/dashboard/src/utils/userPreferenceStorage.ts +++ b/dashboard/src/utils/userPreferenceStorage.ts @@ -4,6 +4,8 @@ const NUM_ITEMS_PER_PAGE_DEFAULT = 10 const USERS_AUTO_REFRESH_INTERVAL_KEY = 'pasarguard-users-auto-refresh-interval' const DEFAULT_USERS_AUTO_REFRESH_INTERVAL_SECONDS = 0 +const USERS_SHOW_CREATED_BY_KEY = 'pasarguard-users-show-created-by' +const DEFAULT_USERS_SHOW_CREATED_BY = true export const DATE_PICKER_PREFERENCE_KEY = 'pasarguard-date-picker-preference' export type DatePickerPreference = 'locale' | 'gregorian' | 'persian' @@ -39,6 +41,18 @@ export const setUsersAutoRefreshIntervalSeconds = (seconds: number) => { localStorage.setItem(USERS_AUTO_REFRESH_INTERVAL_KEY, seconds.toString()) } +export const getUsersShowCreatedBy = () => { + if (typeof localStorage === 'undefined') return DEFAULT_USERS_SHOW_CREATED_BY + const storedValue = localStorage.getItem(USERS_SHOW_CREATED_BY_KEY) + if (storedValue === null) return DEFAULT_USERS_SHOW_CREATED_BY + return storedValue === 'true' +} + +export const setUsersShowCreatedBy = (value: boolean) => { + if (typeof localStorage === 'undefined') return + localStorage.setItem(USERS_SHOW_CREATED_BY_KEY, value ? 'true' : 'false') +} + export const getDatePickerPreference = (): DatePickerPreference => { if (typeof localStorage === 'undefined') return DEFAULT_DATE_PICKER_PREFERENCE const storedValue = localStorage.getItem(DATE_PICKER_PREFERENCE_KEY) From 5b6c9fd4394b2b89c46c6d1b7416decb187790ed Mon Sep 17 00:00:00 2001 From: x0sina Date: Tue, 24 Feb 2026 23:26:36 +0330 Subject: [PATCH 05/28] fix(locales): add 'Show created by' translation for multiple languages and remove related props from advance search modal --- dashboard/public/statics/locales/en.json | 1 + dashboard/public/statics/locales/fa.json | 3 ++- dashboard/public/statics/locales/ru.json | 1 + dashboard/public/statics/locales/zh.json | 1 + dashboard/src/components/dialogs/advance-search-modal.tsx | 4 +--- dashboard/src/components/users/users-table.tsx | 5 ----- 6 files changed, 6 insertions(+), 9 deletions(-) diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index edf8d42d5..b37f8676c 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -1987,6 +1987,7 @@ "title": "Advanced Search", "byUsername": "Username and Notes", "byProtocol": "Protocol Data", + "showCreatedBy": "Show created by", "byStatus": "Status", "byAdmin": "Admin", "byGroup": "Group", diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index 22941db55..821af6e8c 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -1,4 +1,4 @@ -{ +{ "pasarguard": "پاسارگارد", "dashboard": "داشبورد", "master": "همه گره‌ها", @@ -1961,6 +1961,7 @@ "title": "جستجوی پیشرفته", "byUsername": "نام کاربری و یادداشت‌ها", "byProtocol": "داده‌های پروتکل", + "showCreatedBy": "نمایش ایجاد شده توسط", "byStatus": "وضعیت", "byAdmin": "مدیر", "byGroup": "گروه", diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index 25b67ae2d..1e32c5f14 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -1929,6 +1929,7 @@ "title": "Расширенный поиск", "byUsername": "Имя пользователя и заметки", "byProtocol": "Данные протокола", + "showCreatedBy": "Показать создателя", "byStatus": "Статус", "byAdmin": "Администратор", "byGroup": "Группа", diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index d0888ce02..9b7d30c02 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -1984,6 +1984,7 @@ "title": "高级搜索", "byUsername": "用户名和备注", "byProtocol": "协议数据", + "showCreatedBy": "显示创建者", "byStatus": "状态", "byAdmin": "管理员", "byGroup": "分组", diff --git a/dashboard/src/components/dialogs/advance-search-modal.tsx b/dashboard/src/components/dialogs/advance-search-modal.tsx index 2bc3c5064..f61fda8f5 100644 --- a/dashboard/src/components/dialogs/advance-search-modal.tsx +++ b/dashboard/src/components/dialogs/advance-search-modal.tsx @@ -21,9 +21,8 @@ interface AdvanceSearchModalProps { form: UseFormReturn onSubmit: (values: AdvanceSearchFormValue) => void isSudo?: boolean - onShowCreatedByChange?: (value: boolean) => void } -export default function AdvanceSearchModal({ isDialogOpen, onOpenChange, form, onSubmit, isSudo, onShowCreatedByChange }: AdvanceSearchModalProps) { +export default function AdvanceSearchModal({ isDialogOpen, onOpenChange, form, onSubmit, isSudo }: AdvanceSearchModalProps) { const dir = useDirDetection() const { t } = useTranslation() @@ -106,7 +105,6 @@ export default function AdvanceSearchModal({ isDialogOpen, onOpenChange, form, o checked={field.value} onCheckedChange={checked => { field.onChange(checked) - onShowCreatedByChange?.(checked) }} /> diff --git a/dashboard/src/components/users/users-table.tsx b/dashboard/src/components/users/users-table.tsx index feb8e20a2..85d772cff 100644 --- a/dashboard/src/components/users/users-table.tsx +++ b/dashboard/src/components/users/users-table.tsx @@ -600,11 +600,6 @@ const UsersTable = memo(() => { form={advanceSearchForm} onSubmit={handleAdvanceSearchSubmit} isSudo={isSudo} - onShowCreatedByChange={value => { - if (!isSudo) return - setShowCreatedBy(value) - setUsersShowCreatedBy(value) - }} /> )}
From fbabdf4bab244edbccdc436c5b5ed24e110e542f Mon Sep 17 00:00:00 2001 From: x0sina Date: Tue, 24 Feb 2026 23:37:37 +0330 Subject: [PATCH 06/28] fix(admins): improve error handling in admin status toggle and simplify AlertDialogFooter structure --- dashboard/src/components/admins/admins-table.tsx | 2 +- dashboard/src/pages/_dashboard.admins.tsx | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/dashboard/src/components/admins/admins-table.tsx b/dashboard/src/components/admins/admins-table.tsx index 2dd5929ea..75ceae07a 100644 --- a/dashboard/src/components/admins/admins-table.tsx +++ b/dashboard/src/components/admins/admins-table.tsx @@ -76,7 +76,7 @@ const ToggleAdminStatusModal = ({ admin, isOpen, onClose, onConfirm }: { admin: - + {t('cancel')} onConfirm(adminUsersToggle)}>{t('confirm')} diff --git a/dashboard/src/pages/_dashboard.admins.tsx b/dashboard/src/pages/_dashboard.admins.tsx index 95ca313fe..13fa946f7 100644 --- a/dashboard/src/pages/_dashboard.admins.tsx +++ b/dashboard/src/pages/_dashboard.admins.tsx @@ -94,12 +94,16 @@ export default function AdminsPage() { queryClient.invalidateQueries({ queryKey: ['/api/admins'], }) - } catch (error) { + } catch (error: any) { + const status = error?.status ?? error?.response?.status + const backendDetail = error?.data?.detail ?? error?.response?._data?.detail ?? error?.response?.data?.detail + const defaultDescription = t(admin.is_disabled ? 'admins.enableFailed' : 'admins.disableFailed', { + name: admin.username, + defaultValue: `Failed to ${admin.is_disabled ? 'enable' : 'disable'} admin "{name}"`, + }) + toast.error(t('error', { defaultValue: 'Error' }), { - description: t(admin.is_disabled ? 'admins.enableFailed' : 'admins.disableFailed', { - name: admin.username, - defaultValue: `Failed to ${admin.is_disabled ? 'enable' : 'disable'} admin "{name}"`, - }), + description: status === 403 && typeof backendDetail === 'string' && backendDetail.trim().length > 0 ? backendDetail : defaultDescription, }) } } From c89b5ff4dc89709166e44c7cf69c0e706cceecd6 Mon Sep 17 00:00:00 2001 From: x0sina Date: Tue, 24 Feb 2026 23:46:53 +0330 Subject: [PATCH 07/28] feat(admins): prevent sudo admins from disabling their own accounts and enhance admin action controls in the UI --- app/operation/admin.py | 3 + .../src/components/admins/admins-table.tsx | 4 + dashboard/src/components/admins/columns.tsx | 188 ++++++++++-------- .../src/components/admins/data-table.tsx | 58 +++--- tests/api/test_admin.py | 32 +++ 5 files changed, 175 insertions(+), 110 deletions(-) diff --git a/app/operation/admin.py b/app/operation/admin.py index ee2e0ae2a..abc3b59fd 100644 --- a/app/operation/admin.py +++ b/app/operation/admin.py @@ -79,6 +79,9 @@ async def modify_admin( message="You're not allowed to modify sudoer's account. Use pasarguard cli / tui instead.", code=403 ) + if db_admin.username == current_admin.username and modified_admin.is_disabled is True: + await self.raise_error(message="You're not allowed to disable your own account.", code=403) + if modified_admin.telegram_id is not None: existing_admins = await find_admins_by_telegram_id( db, modified_admin.telegram_id, exclude_admin_id=db_admin.id, limit=1 diff --git a/dashboard/src/components/admins/admins-table.tsx b/dashboard/src/components/admins/admins-table.tsx index 75ceae07a..1af2678bd 100644 --- a/dashboard/src/components/admins/admins-table.tsx +++ b/dashboard/src/components/admins/admins-table.tsx @@ -13,6 +13,7 @@ import { Checkbox } from '@/components/ui/checkbox.tsx' import { getAdminsPerPageLimitSize, setAdminsPerPageLimitSize } from '@/utils/userPreferenceStorage' import { toast } from 'sonner' import { queryClient } from '@/utils/query-client' +import { useAdmin } from '@/hooks/use-admin' interface AdminFilters { sort?: string @@ -170,6 +171,7 @@ const BulkUsersStatusConfirmationDialog = ({ export default function AdminsTable({ onEdit, onDelete, onToggleStatus, onResetUsage, onTotalAdminsChange }: AdminsTableProps) { const { t } = useTranslation() + const { admin: currentAdmin } = useAdmin() const [currentPage, setCurrentPage] = useState(0) const [itemsPerPage, setItemsPerPage] = useState(getAdminsPerPageLimitSize()) const [isChangingPage, setIsChangingPage] = useState(false) @@ -418,6 +420,7 @@ export default function AdminsTable({ onEdit, onDelete, onToggleStatus, onResetU t, handleSort, filters, + currentAdminUsername: currentAdmin?.username, onEdit, onDelete: handleDeleteClick, toggleStatus: handleStatusToggleClick, @@ -443,6 +446,7 @@ export default function AdminsTable({ onEdit, onDelete, onToggleStatus, onResetU onDisableAllActiveUsers={handleDisableAllActiveUsersClick} onActivateAllDisabledUsers={handleActivateAllDisabledUsersClick} onRemoveAllUsers={handleRemoveAllUsersClick} + currentAdminUsername={currentAdmin?.username} setStatusToggleDialogOpen={setStatusToggleDialogOpen} isLoading={isCurrentlyLoading && isFirstLoadRef.current} isFetching={isFetching && !isFirstLoadRef.current && !isAutoRefreshingRef.current} diff --git a/dashboard/src/components/admins/columns.tsx b/dashboard/src/components/admins/columns.tsx index 77f24d323..677dd7c7b 100644 --- a/dashboard/src/components/admins/columns.tsx +++ b/dashboard/src/components/admins/columns.tsx @@ -10,6 +10,7 @@ interface ColumnSetupProps { t: (key: string) => string handleSort: (column: string) => void filters: { sort?: string } + currentAdminUsername?: string onEdit: (admin: AdminDetails) => void onDelete: (admin: AdminDetails) => void toggleStatus: (admin: AdminDetails) => void @@ -48,6 +49,7 @@ export const setupColumns = ({ t, handleSort, filters, + currentAdminUsername, onEdit, onDelete, toggleStatus, @@ -130,92 +132,106 @@ export const setupColumns = ({ }, { id: 'actions', - cell: ({ row }) => ( -
- - - - - - { - e.preventDefault() - e.stopPropagation() - onEdit(row.original) - }} - > - - {t('edit')} - - - { - e.preventDefault() - e.stopPropagation() - toggleStatus(row.original) - }} - > - {row.original.is_disabled ? : } - {row.original.is_disabled ? t('enable') : t('disable')} - - { - e.preventDefault() - e.stopPropagation() - onResetUsage(row.original.username) - }} - > - - {t('admins.reset')} - - { - e.preventDefault() - e.stopPropagation() - onDisableAllActiveUsers(row.original.username) - }} - > - - {t('admins.disableAllActiveUsers')} - - { - e.preventDefault() - e.stopPropagation() - onActivateAllDisabledUsers(row.original.username) - }} - > - - {t('admins.activateAllDisabledUsers')} - - { - e.preventDefault() - e.stopPropagation() - onRemoveAllUsers(row.original.username) - }} - > - - {t('admins.removeAllUsers')} - - { - e.preventDefault() - e.stopPropagation() - onDelete(row.original) - }} - > - - {t('delete')} - - - -
- ), + cell: ({ row }) => { + const isSudoTarget = row.original.is_sudo + + return ( +
+ + + + + + { + e.preventDefault() + e.stopPropagation() + onEdit(row.original) + }} + > + + {t('edit')} + + + { + e.preventDefault() + e.stopPropagation() + onResetUsage(row.original.username) + }} + > + + {t('admins.reset')} + + {!isSudoTarget && ( + { + e.preventDefault() + e.stopPropagation() + toggleStatus(row.original) + }} + > + {row.original.is_disabled ? : } + {row.original.is_disabled ? t('enable') : t('disable')} + + )} + {!isSudoTarget && ( + { + e.preventDefault() + e.stopPropagation() + onDisableAllActiveUsers(row.original.username) + }} + > + + {t('admins.disableAllActiveUsers')} + + )} + {!isSudoTarget && ( + { + e.preventDefault() + e.stopPropagation() + onActivateAllDisabledUsers(row.original.username) + }} + > + + {t('admins.activateAllDisabledUsers')} + + )} + {!isSudoTarget && ( + { + e.preventDefault() + e.stopPropagation() + onRemoveAllUsers(row.original.username) + }} + > + + {t('admins.removeAllUsers')} + + )} + {!isSudoTarget && row.original.username !== currentAdminUsername && ( + { + e.preventDefault() + e.stopPropagation() + onDelete(row.original) + }} + > + + {t('delete')} + + )} + + +
+ ) + }, }, { id: 'chevron', diff --git a/dashboard/src/components/admins/data-table.tsx b/dashboard/src/components/admins/data-table.tsx index a7ae27a27..d81903b26 100644 --- a/dashboard/src/components/admins/data-table.tsx +++ b/dashboard/src/components/admins/data-table.tsx @@ -34,6 +34,7 @@ import { interface DataTableProps { columns: ColumnDef[] data: TData[] + currentAdminUsername?: string onEdit: (admin: AdminDetails) => void onDelete: (admin: AdminDetails) => void onToggleStatus: (admin: AdminDetails) => void @@ -56,6 +57,7 @@ const ExpandedRowContent = memo( onDisableAllActiveUsers, onActivateAllDisabledUsers, onRemoveAllUsers, + currentAdminUsername, }: { row: AdminDetails onEdit: (admin: AdminDetails) => void @@ -65,10 +67,12 @@ const ExpandedRowContent = memo( onDisableAllActiveUsers?: (adminUsername: string) => void onActivateAllDisabledUsers?: (adminUsername: string) => void onRemoveAllUsers?: (adminUsername: string) => void + currentAdminUsername?: string }) => { const { t } = useTranslation() const isMobile = useIsMobile() const isSudo = row.is_sudo + const isSudoTarget = row.is_sudo return (
@@ -101,16 +105,18 @@ const ExpandedRowContent = memo( - { - e.preventDefault() - e.stopPropagation() - onToggleStatus(row) - }} - > - {row.is_disabled ? : } - {row.is_disabled ? t('enable') : t('disable')} - + {!isSudoTarget && row.username !== currentAdminUsername && ( + { + e.preventDefault() + e.stopPropagation() + onToggleStatus(row) + }} + > + {row.is_disabled ? : } + {row.is_disabled ? t('enable') : t('disable')} + + )} { e.preventDefault() @@ -121,7 +127,7 @@ const ExpandedRowContent = memo( {t('admins.reset')} - {onDisableAllActiveUsers && + {!isSudoTarget && onDisableAllActiveUsers && { e.preventDefault() @@ -133,7 +139,7 @@ const ExpandedRowContent = memo( {t('admins.disableAllActiveUsers')} } - {onActivateAllDisabledUsers && + {!isSudoTarget && onActivateAllDisabledUsers && { e.preventDefault() @@ -145,7 +151,7 @@ const ExpandedRowContent = memo( {t('admins.activateAllDisabledUsers')} } - {onRemoveAllUsers && + {!isSudoTarget && onRemoveAllUsers && { @@ -158,17 +164,19 @@ const ExpandedRowContent = memo( {t('admins.removeAllUsers')} } - { - e.preventDefault() - e.stopPropagation() - onDelete(row) - }} - > - - {t('delete')} - + {!isSudoTarget && row.username !== currentAdminUsername && ( + { + e.preventDefault() + e.stopPropagation() + onDelete(row) + }} + > + + {t('delete')} + + )}
@@ -180,6 +188,7 @@ const ExpandedRowContent = memo( export function DataTable({ columns, data, + currentAdminUsername, onEdit, onDelete, onToggleStatus, @@ -321,6 +330,7 @@ export function DataTable({ onDisableAllActiveUsers={onDisableAllActiveUsers} onActivateAllDisabledUsers={onActivateAllDisabledUsers} onRemoveAllUsers={onRemoveAllUsers} + currentAdminUsername={currentAdminUsername} /> diff --git a/tests/api/test_admin.py b/tests/api/test_admin.py index e2bb1947e..67d4191c2 100644 --- a/tests/api/test_admin.py +++ b/tests/api/test_admin.py @@ -273,6 +273,38 @@ def test_sudo_admin_can_modify_self(access_token): delete_admin(access_token, sudo_admin["username"]) +def test_sudo_admin_cannot_disable_self(access_token): + """A sudo admin cannot disable their own account.""" + sudo_admin = create_admin(access_token) + set_admin_sudo(sudo_admin["username"], True) + try: + login_response = client.post( + url="/api/admin/token", + data={ + "username": sudo_admin["username"], + "password": sudo_admin["password"], + "grant_type": "password", + }, + ) + assert login_response.status_code == status.HTTP_200_OK + sudo_token = login_response.json()["access_token"] + + response = client.put( + url=f"/api/admin/{sudo_admin['username']}", + json={ + "is_sudo": True, + "is_disabled": True, + }, + headers={"Authorization": f"Bearer {sudo_token}"}, + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json()["detail"] == "You're not allowed to disable your own account." + finally: + set_admin_sudo(sudo_admin["username"], False) + delete_admin(access_token, sudo_admin["username"]) + + def test_sudo_admin_cannot_modify_other_sudo_admin(access_token): """A sudo admin cannot edit another sudo admin account.""" sudo_admin_a = create_admin(access_token) From e4955c6932e60340af5dbeabee2f7deee94db446 Mon Sep 17 00:00:00 2001 From: x0sina Date: Wed, 25 Feb 2026 00:19:35 +0330 Subject: [PATCH 08/28] fix(user-modal): enhance actions menu with improved dropdown interactions and state management --- .../src/components/dialogs/user-modal.tsx | 44 ++++++++++++++----- .../src/components/users/action-buttons.tsx | 32 ++++++++------ 2 files changed, 52 insertions(+), 24 deletions(-) diff --git a/dashboard/src/components/dialogs/user-modal.tsx b/dashboard/src/components/dialogs/user-modal.tsx index d200792e1..dacc3b49e 100644 --- a/dashboard/src/components/dialogs/user-modal.tsx +++ b/dashboard/src/components/dialogs/user-modal.tsx @@ -310,6 +310,7 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse const [isUserAllIPsModalOpen, setUserAllIPsModalOpen] = useState(false) const [isUsageModalOpen, setUsageModalOpen] = useState(false) const [isSubscriptionClientsModalOpen, setSubscriptionClientsModalOpen] = useState(false) + const [isActionsMenuOpen, setActionsMenuOpen] = useState(false) // Watch next plan values directly for reactivity const nextPlanUserTemplateId = form.watch('next_plan.user_template_id') @@ -342,6 +343,7 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse setOnHoldCalendarOpen(false) setNextPlanEnabled(false) setNextPlanManuallyDisabled(false) + setActionsMenuOpen(false) } else { setNextPlanManuallyDisabled(false) if (editingUser) { @@ -2414,40 +2416,60 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse {renderUserMetaPanel('mt-4 lg:hidden')}
{/* Cancel/Create buttons - always visible */} -
+
{editingUser && ( - + - + setActionsMenuOpen(false)} + onPointerDownOutside={() => setActionsMenuOpen(false)} + onInteractOutside={() => setActionsMenuOpen(false)} + > {isSudo && ( - setUserAllIPsModalOpen(true)}> + { + setActionsMenuOpen(false) + setUserAllIPsModalOpen(true) + }}> {t('userAllIPs.ipAddresses', { defaultValue: 'IP addresses' })} )} - setUsageModalOpen(true)}> + { + setActionsMenuOpen(false) + setUsageModalOpen(true) + }}> {t('userDialog.usage', { defaultValue: 'Usage' })} - setSubscriptionClientsModalOpen(true)}> + { + setActionsMenuOpen(false) + setSubscriptionClientsModalOpen(true) + }}> {t('subscriptionClients.clients', { defaultValue: 'Clients' })} - setRevokeSubDialogOpen(true)}> + { + setActionsMenuOpen(false) + setRevokeSubDialogOpen(true) + }}> {t('userDialog.revokeSubscription', { defaultValue: 'Revoke subscription' })} - setResetUsageDialogOpen(true)}> + { + setActionsMenuOpen(false) + setResetUsageDialogOpen(true) + }}> {t('userDialog.resetUsage', { defaultValue: 'Reset usage' })} diff --git a/dashboard/src/components/users/action-buttons.tsx b/dashboard/src/components/users/action-buttons.tsx index 694e8ddbf..c68f041ee 100644 --- a/dashboard/src/components/users/action-buttons.tsx +++ b/dashboard/src/components/users/action-buttons.tsx @@ -46,6 +46,7 @@ const ActionButtons: FC = ({ user }) => { const [isActiveNextPlanModalOpen, setIsActiveNextPlanModalOpen] = useState(false) const [isSubscriptionClientsModalOpen, setSubscriptionClientsModalOpen] = useState(false) const [isUserAllIPsModalOpen, setUserAllIPsModalOpen] = useState(false) + const [isActionsMenuOpen, setActionsMenuOpen] = useState(false) const queryClient = useQueryClient() const { t } = useTranslation() const dir = useDirDetection() @@ -450,28 +451,33 @@ const ActionButtons: FC = ({ user }) => { {copied ? t('usersTable.copied') : t('usersTable.copyConfigs')} - + - + setActionsMenuOpen(false)} + onInteractOutside={() => setActionsMenuOpen(false)} + onEscapeKeyDown={() => setActionsMenuOpen(false)} + > {/* Edit */} - + {t('edit')} {/* QR Code */} - + QR Code {/* Set Owner: only for sudo admins */} {currentAdmin?.is_sudo && ( - + {t('setOwnerModal.title')} @@ -479,7 +485,7 @@ const ActionButtons: FC = ({ user }) => { {/* Copy Core Username for sudo admins */} {currentAdmin?.is_sudo && ( - + {t('coreUsername')} @@ -488,40 +494,40 @@ const ActionButtons: FC = ({ user }) => { {/* Revoke Sub */} - + {t('userDialog.revokeSubscription')} {/* Reset Usage */} - + {t('userDialog.resetUsage')} {/* Usage State */} - + {t('userDialog.usage')} {/* Active Next Plan */} {user.next_plan && ( - + {t('usersTable.activeNextPlanSubmit')} )} {/* Subscription Info */} - setSubscriptionClientsModalOpen(true)}> + setSubscriptionClientsModalOpen(true)}> {t('subscriptionClients.clients', { defaultValue: 'Clients' })} {/* View All IPs: only for sudo admins */} {currentAdmin?.is_sudo && ( - setUserAllIPsModalOpen(true)}> + setUserAllIPsModalOpen(true)}> {t('userAllIPs.ipAddresses', { defaultValue: 'IP addresses' })} @@ -530,7 +536,7 @@ const ActionButtons: FC = ({ user }) => { {/* Trash */} - + {t('remove')} From 33c6ce8f258135e553b7aba2607f47c02d09ff71 Mon Sep 17 00:00:00 2001 From: x0sina Date: Wed, 25 Feb 2026 00:29:53 +0330 Subject: [PATCH 09/28] fix(user-modal): normalize data limit handling and adjust reset strategy based on data limit presence --- dashboard/src/components/dialogs/user-modal.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dashboard/src/components/dialogs/user-modal.tsx b/dashboard/src/components/dialogs/user-modal.tsx index dacc3b49e..f47bf56fa 100644 --- a/dashboard/src/components/dialogs/user-modal.tsx +++ b/dashboard/src/components/dialogs/user-modal.tsx @@ -1112,10 +1112,14 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse } : undefined + const normalizedDataLimitGb = Number(preparedValues.data_limit ?? 0) + const hasDataLimit = Number.isFinite(normalizedDataLimitGb) && normalizedDataLimitGb > 0 + // Prepare next plan data const sendValues: any = { ...preparedValues, - data_limit: gbToBytes(preparedValues.data_limit as any), + data_limit: gbToBytes(normalizedDataLimitGb as any), + data_limit_reset_strategy: hasDataLimit ? preparedValues.data_limit_reset_strategy : 'no_reset', expire: preparedValues.expire, ...(hasProxySettings ? { proxy_settings: cleanedProxySettings } : {}), } From cfc7b210dfdec8e020a78d7d2c20023f57ee6925 Mon Sep 17 00:00:00 2001 From: x0sina Date: Wed, 25 Feb 2026 00:30:01 +0330 Subject: [PATCH 10/28] fix(users-statistics): adjust layout for total users card to improve responsiveness on different screen sizes --- dashboard/src/components/users/users-statistics.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/components/users/users-statistics.tsx b/dashboard/src/components/users/users-statistics.tsx index 08d341516..8f3a8ec44 100644 --- a/dashboard/src/components/users/users-statistics.tsx +++ b/dashboard/src/components/users/users-statistics.tsx @@ -84,7 +84,7 @@ const UsersStatistics = () => {
{/* Total Users */} -
+
Date: Wed, 25 Feb 2026 00:38:25 +0330 Subject: [PATCH 11/28] fix(subscription-modal): adjust layout and sizing for QR code and configs list to enhance visual alignment --- .../src/components/dialogs/subscription-modal.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/dashboard/src/components/dialogs/subscription-modal.tsx b/dashboard/src/components/dialogs/subscription-modal.tsx index a537d85f4..a775e8f63 100644 --- a/dashboard/src/components/dialogs/subscription-modal.tsx +++ b/dashboard/src/components/dialogs/subscription-modal.tsx @@ -187,14 +187,11 @@ const SubscriptionModal: FC = memo(({ subscribeUrl, user -
+
{/* Subscription QR Code Section */}
-
- {t('subscriptionModal.subscriptionLink', { defaultValue: 'Subscription Link' })} -
-
- +
+
@@ -227,7 +224,7 @@ const SubscriptionModal: FC = memo(({ subscribeUrl, user ) : ( <> {/* Configs List */} -
+
{currentConfigs.map((item, index) => (
From c6741cbcd763ed4f172c9c407fb2332ffd5e35ae Mon Sep 17 00:00:00 2001 From: x0sina Date: Wed, 25 Feb 2026 00:43:31 +0330 Subject: [PATCH 12/28] fix(dashboard-statistics): update layout for memory and disk usage display to enhance visual clarity and responsiveness --- .../components/dashboard/dashboard-statistics.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/dashboard/src/components/dashboard/dashboard-statistics.tsx b/dashboard/src/components/dashboard/dashboard-statistics.tsx index 2ed722f9d..f9ace36c5 100644 --- a/dashboard/src/components/dashboard/dashboard-statistics.tsx +++ b/dashboard/src/components/dashboard/dashboard-statistics.tsx @@ -183,13 +183,15 @@ const DashboardStatistics = ({ systemData }: { systemData: SystemStats | undefin
-
+
{formatBytes(memory.used, 1, false, false, 'GB')}/{formatBytes(memory.total, 1, true, false, 'GB')} - ({memoryPercent.toFixed(1)}%) + + {memoryPercent.toFixed(1)}% +
@@ -218,13 +220,15 @@ const DashboardStatistics = ({ systemData }: { systemData: SystemStats | undefin
-
+
{formatBytes(disk.used, 1, false, false, 'GB')}/{formatBytes(disk.total, 1, true, false, 'GB')} - ({diskPercent.toFixed(1)}%) + + {diskPercent.toFixed(1)}% +
From 273a8903539275af23d9d15f64a7f7626effc31a Mon Sep 17 00:00:00 2001 From: x0sina Date: Wed, 25 Feb 2026 01:06:25 +0330 Subject: [PATCH 13/28] feat(chart-view): implement area chart option and enhance chart view preferences across the dashboard --- dashboard/public/statics/locales/en.json | 9 +- dashboard/public/statics/locales/fa.json | 9 +- dashboard/public/statics/locales/ru.json | 9 +- dashboard/public/statics/locales/zh.json | 9 +- .../charts/all-nodes-stacked-bar-chart.tsx | 159 +++++++++----- .../components/charts/costume-bar-chart.tsx | 110 +++++++--- .../components/dashboard/data-usage-chart.tsx | 122 +++++++---- .../src/components/dialogs/usage-modal.tsx | 196 ++++++++++++------ dashboard/src/hooks/use-chart-view-type.ts | 22 ++ .../src/pages/_dashboard.settings.theme.tsx | 66 +++++- dashboard/src/utils/userPreferenceStorage.ts | 22 ++ 11 files changed, 540 insertions(+), 193 deletions(-) create mode 100644 dashboard/src/hooks/use-chart-view-type.ts diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index b37f8676c..558bcd743 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -1589,7 +1589,14 @@ "datePickerPreferenceSaved": "Date picker preference saved", "datePickerModeLocale": "Locale default", "datePickerModeGregorian": "Gregorian", - "datePickerModePersian": "Jalali" + "datePickerModePersian": "Jalali", + "chartViewType": "Chart view", + "chartViewDescription": "Choose the default chart style for traffic charts.", + "chartViewBar": "Bars", + "chartViewArea": "Area", + "chartViewBarDescription": "Display traffic as bars.", + "chartViewAreaDescription": "Display traffic as area charts.", + "chartViewSaved": "Chart view preference saved" }, "coreConfigModal": { "addConfig": "Create Core Configuration", diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index 821af6e8c..5ed20abdf 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -1508,7 +1508,14 @@ "datePickerPreferenceSaved": "ترجیحات تقویم ذخیره شد", "datePickerModeLocale": "پیش‌فرض بر اساس زبان", "datePickerModeGregorian": "میلادی", - "datePickerModePersian": "جلالی" + "datePickerModePersian": "جلالی", + "chartViewType": "نوع نمایش نمودار", + "chartViewDescription": "سبک پیش‌فرض نمودار برای نمودارهای ترافیک را انتخاب کنید.", + "chartViewBar": "میله‌ای", + "chartViewArea": "ناحیه‌ای", + "chartViewBarDescription": "نمایش ترافیک به‌صورت نمودار میله‌ای.", + "chartViewAreaDescription": "نمایش ترافیک به‌صورت نمودار ناحیه‌ای.", + "chartViewSaved": "ترجیح نمایش نمودار ذخیره شد" }, "coreConfigModal": { "addConfig": "افزودن پیکربندی هسته", diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index 1e32c5f14..1e6370eeb 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -1487,7 +1487,14 @@ "datePickerPreferenceSaved": "Предпочтение календаря сохранено", "datePickerModeLocale": "По языку", "datePickerModeGregorian": "Григорианский", - "datePickerModePersian": "Джалали" + "datePickerModePersian": "Джалали", + "chartViewType": "Вид диаграммы", + "chartViewDescription": "Выберите стиль диаграмм по умолчанию для графиков трафика.", + "chartViewBar": "Столбцы", + "chartViewArea": "Область", + "chartViewBarDescription": "Показывать трафик в виде столбцов.", + "chartViewAreaDescription": "Показывать трафик в виде диаграммы с областью.", + "chartViewSaved": "Предпочтение вида диаграммы сохранено" }, "coreConfigModal": { "addConfig": "Добавить конфигурацию ядра", diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index 9b7d30c02..6dace73b1 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -1550,7 +1550,14 @@ "datePickerPreferenceSaved": "日期选择器偏好已保存", "datePickerModeLocale": "语言默认", "datePickerModeGregorian": "公历", - "datePickerModePersian": "贾拉里历" + "datePickerModePersian": "贾拉里历", + "chartViewType": "图表视图", + "chartViewDescription": "选择流量图表的默认显示样式。", + "chartViewBar": "柱状图", + "chartViewArea": "面积图", + "chartViewBarDescription": "以柱状图显示流量。", + "chartViewAreaDescription": "以面积图显示流量。", + "chartViewSaved": "图表视图偏好已保存" }, "coreConfigModal": { "addConfig": "添加核心配置", diff --git a/dashboard/src/components/charts/all-nodes-stacked-bar-chart.tsx b/dashboard/src/components/charts/all-nodes-stacked-bar-chart.tsx index a7d3e6c2e..26272507c 100644 --- a/dashboard/src/components/charts/all-nodes-stacked-bar-chart.tsx +++ b/dashboard/src/components/charts/all-nodes-stacked-bar-chart.tsx @@ -1,10 +1,11 @@ import { useEffect, useState, useMemo, useCallback, useRef } from 'react' -import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis, TooltipProps } from 'recharts' +import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis, TooltipProps } from 'recharts' import { DateRange } from 'react-day-picker' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { type ChartConfig, ChartContainer, ChartTooltip } from '@/components/ui/chart' import { useTranslation } from 'react-i18next' import useDirDetection from '@/hooks/use-dir-detection' +import { useChartViewType } from '@/hooks/use-chart-view-type' import { Period, type NodeUsageStat, type UserUsageStat, useGetAdminUsage, useGetNodesSimple, type NodeSimple, useGetUsage } from '@/service/api' import { formatBytes, formatGigabytes } from '@/utils/formatByte' import { Skeleton } from '@/components/ui/skeleton' @@ -193,6 +194,7 @@ export function AllNodesStackedBarChart() { const { t, i18n } = useTranslation() const dir = useDirDetection() + const chartViewType = useChartViewType() const { data: nodesResponse } = useGetNodesSimple({ all: true }, { query: { enabled: true } }) const { resolvedTheme } = useTheme() const shouldUseNodeUsage = selectedAdmin === 'all' @@ -439,6 +441,30 @@ export function AllNodesStackedBarChart() { } }, []) + const handleChartPointClick = useCallback( + (data: any) => { + const clickedIndex = typeof data?.activeTooltipIndex === 'number' ? data.activeTooltipIndex : -1 + const clickedData = (data?.activePayload?.[0]?.payload ?? (clickedIndex >= 0 ? chartData[clickedIndex] : undefined)) as NodeChartDataPoint | undefined + if (!clickedData) return + + const activeNodesCount = Object.keys(clickedData).filter(key => { + if (key.startsWith('_') || key === 'time' || key === '_period_start') return false + const usageValue = Number(clickedData[key] || 0) + const uplinkValue = Number(clickedData[`_uplink_${key}`] || 0) + const downlinkValue = Number(clickedData[`_downlink_${key}`] || 0) + return usageValue > 0 || uplinkValue > 0 || downlinkValue > 0 + }).length + + if (activeNodesCount > 0) { + const resolvedIndex = clickedIndex >= 0 ? clickedIndex : chartData.findIndex(item => item._period_start === clickedData._period_start) + setCurrentDataIndex(resolvedIndex >= 0 ? resolvedIndex : 0) + setSelectedData(clickedData) + setModalOpen(true) + } + }, + [chartData], + ) + return ( <> @@ -471,7 +497,7 @@ export function AllNodesStackedBarChart() {
) : ( - { - if (state.activeTooltipIndex !== activeIndex) { - setActiveIndex(state.activeTooltipIndex !== undefined ? state.activeTooltipIndex : null) - } - }} - onMouseLeave={() => { - setActiveIndex(null) - }} - > - - value || ''} - /> - formatBytes(val, 0, true).toString()} tick={{ fontSize: 10 }} /> - } /> - - {chartData.map((_, index: number) => ( - - ))} - - + {chartViewType === 'area' ? ( + { + if (state.activeTooltipIndex !== activeIndex) { + setActiveIndex(state.activeTooltipIndex !== undefined ? state.activeTooltipIndex : null) + } + }} + onMouseLeave={() => { + setActiveIndex(null) + }} + > + + + + + + + + value || ''} + /> + formatBytes(val, 0, true).toString()} + tick={{ fontSize: 10 }} + /> + } /> + + + ) : ( + { + if (state.activeTooltipIndex !== activeIndex) { + setActiveIndex(state.activeTooltipIndex !== undefined ? state.activeTooltipIndex : null) + } + }} + onMouseLeave={() => { + setActiveIndex(null) + }} + > + + value || ''} + /> + formatBytes(val, 0, true).toString()} tick={{ fontSize: 10 }} /> + } /> + + {chartData.map((_, index: number) => ( + + ))} + + + )} )} diff --git a/dashboard/src/components/dialogs/usage-modal.tsx b/dashboard/src/components/dialogs/usage-modal.tsx index 8ede0aa12..9b3b94cc1 100644 --- a/dashboard/src/components/dialogs/usage-modal.tsx +++ b/dashboard/src/components/dialogs/usage-modal.tsx @@ -10,8 +10,9 @@ import { DateRange } from 'react-day-picker' import { TimeRangeSelector } from '@/components/common/time-range-selector' import { Button } from '../ui/button' import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '../ui/select' -import { ResponsiveContainer, TooltipProps, Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell, Pie, PieChart as RechartsPieChart } from 'recharts' +import { ResponsiveContainer, TooltipProps, Area, AreaChart, Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell, Pie, PieChart as RechartsPieChart } from 'recharts' import useDirDetection from '@/hooks/use-dir-detection' +import { useChartViewType } from '@/hooks/use-chart-view-type' import { useTheme } from '@/components/common/theme-provider' import NodeStatsModal from './node-stats-modal' import { @@ -192,6 +193,7 @@ const UsageModal = ({ open, onClose, username }: UsageModalProps) => { const [currentDataIndex, setCurrentDataIndex] = useState(0) const [chartData, setChartData] = useState(null) const [chartView, setChartView] = useState<'bar' | 'pie'>('bar') + const chartViewType = useChartViewType() // Get current admin to check permissions const { data: currentAdmin } = useGetCurrentAdmin() @@ -575,6 +577,31 @@ const UsageModal = ({ open, onClose, username }: UsageModalProps) => { setCustomRange(undefined) }, []) + const handleTrafficChartClick = useCallback( + (data: any) => { + if (!processedChartData || processedChartData.length === 0) return + + const clickedIndex = typeof data?.activeTooltipIndex === 'number' ? data.activeTooltipIndex : -1 + const clickedData = data?.activePayload?.[0]?.payload ?? (clickedIndex >= 0 ? processedChartData[clickedIndex] : undefined) + if (!clickedData) return + + if (allNodesSelected) { + const activeNodesCount = Object.keys(clickedData).filter( + key => !key.startsWith('_') && key !== 'time' && key !== '_period_start' && key !== 'usage' && Number(clickedData[key] || 0) > 0, + ).length + if (activeNodesCount === 0) return + } else if (Number(clickedData.usage || 0) <= 0) { + return + } + + const resolvedIndex = clickedIndex >= 0 ? clickedIndex : processedChartData.findIndex(item => item._period_start === clickedData._period_start) + setCurrentDataIndex(resolvedIndex >= 0 ? resolvedIndex : 0) + setSelectedData(clickedData) + setModalOpen(true) + }, + [processedChartData, allNodesSelected], + ) + return ( @@ -611,7 +638,7 @@ const UsageModal = ({ open, onClose, username }: UsageModalProps) => {
+
+
+
+ +

{t('theme.chartViewType')}

+
+

{t('theme.chartViewDescription')}

+
+ handleChartViewTypeChange(value as ChartViewType)} className="grid gap-2 sm:grid-cols-2"> + {chartViewOptions.map(option => ( +
+ + +
+ ))} +
+
+
diff --git a/dashboard/src/utils/userPreferenceStorage.ts b/dashboard/src/utils/userPreferenceStorage.ts index 53ccec42d..7e643ed58 100644 --- a/dashboard/src/utils/userPreferenceStorage.ts +++ b/dashboard/src/utils/userPreferenceStorage.ts @@ -6,11 +6,16 @@ const USERS_AUTO_REFRESH_INTERVAL_KEY = 'pasarguard-users-auto-refresh-interval' const DEFAULT_USERS_AUTO_REFRESH_INTERVAL_SECONDS = 0 const USERS_SHOW_CREATED_BY_KEY = 'pasarguard-users-show-created-by' const DEFAULT_USERS_SHOW_CREATED_BY = true +const CHART_VIEW_TYPE_KEY = 'pasarguard-chart-view-type' export const DATE_PICKER_PREFERENCE_KEY = 'pasarguard-date-picker-preference' export type DatePickerPreference = 'locale' | 'gregorian' | 'persian' const DEFAULT_DATE_PICKER_PREFERENCE: DatePickerPreference = 'locale' +export const CHART_VIEW_TYPE_CHANGE_EVENT = 'pasarguard-chart-view-type-change' +export type ChartViewType = 'bar' | 'area' +const DEFAULT_CHART_VIEW_TYPE: ChartViewType = 'bar' + // Generic function for any table type export const getItemsPerPageLimitSize = (tableType: 'users' | 'admins' = 'users') => { const storageKey = tableType === 'users' ? NUM_USERS_PER_PAGE_LOCAL_STORAGE_KEY : NUM_ADMINS_PER_PAGE_LOCAL_STORAGE_KEY @@ -66,3 +71,20 @@ export const setDatePickerPreference = (preference: DatePickerPreference) => { if (typeof localStorage === 'undefined') return localStorage.setItem(DATE_PICKER_PREFERENCE_KEY, preference) } + +export const getChartViewTypePreference = (): ChartViewType => { + if (typeof localStorage === 'undefined') return DEFAULT_CHART_VIEW_TYPE + const storedValue = localStorage.getItem(CHART_VIEW_TYPE_KEY) + if (storedValue === 'bar' || storedValue === 'area') { + return storedValue + } + return DEFAULT_CHART_VIEW_TYPE +} + +export const setChartViewTypePreference = (viewType: ChartViewType) => { + if (typeof localStorage === 'undefined') return + localStorage.setItem(CHART_VIEW_TYPE_KEY, viewType) + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(CHART_VIEW_TYPE_CHANGE_EVENT, { detail: viewType })) + } +} From cf60574495dd86253eeec62b74b0568d46f45a9d Mon Sep 17 00:00:00 2001 From: x0sina Date: Wed, 25 Feb 2026 01:15:42 +0330 Subject: [PATCH 14/28] refactor(sidebar): replace panel icons with chevrons and enhance button styles for improved UX --- dashboard/src/components/layout/sidebar.tsx | 15 +++++++++------ dashboard/src/pages/_dashboard.settings.theme.tsx | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/dashboard/src/components/layout/sidebar.tsx b/dashboard/src/components/layout/sidebar.tsx index f7450304f..238ed5ee6 100644 --- a/dashboard/src/components/layout/sidebar.tsx +++ b/dashboard/src/components/layout/sidebar.tsx @@ -23,6 +23,8 @@ import { Bell, BookOpen, Calendar, + ChevronsLeft, + ChevronsRight, Cpu, Database, FileText, @@ -35,8 +37,6 @@ import { Lock, MessageCircle, Palette, - PanelLeftClose, - PanelLeftOpen, PieChart, RssIcon, Send, @@ -378,12 +378,12 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - + Expand Sidebar {isSudo && hasUpdate && } @@ -424,14 +424,17 @@ export function AppSidebar({ ...props }: React.ComponentProps) { diff --git a/dashboard/src/pages/_dashboard.settings.theme.tsx b/dashboard/src/pages/_dashboard.settings.theme.tsx index 79ff9bbcd..5ce1f7d75 100644 --- a/dashboard/src/pages/_dashboard.settings.theme.tsx +++ b/dashboard/src/pages/_dashboard.settings.theme.tsx @@ -344,7 +344,7 @@ export default function ThemeSettings() {
handleChartViewTypeChange(value as ChartViewType)} className="grid gap-2 sm:grid-cols-2"> {chartViewOptions.map(option => ( -
+