From df1eeb2a9f30a270f934013339e7acea450f37fc Mon Sep 17 00:00:00 2001 From: Mohammad Date: Thu, 4 Sep 2025 21:59:23 +0330 Subject: [PATCH 1/4] refactor: replace CLI with TUI --- CONTRIBUTING.md | 57 +++++++++++++++----------- Dockerfile | 8 +++- Makefile | 6 +++ README-fa.md | 12 ++++++ README-ru.md | 12 ++++++ README-zh-cn.md | 12 ++++++ README.md | 12 ++++++ cli/README.md | 42 ------------------- pasarguard-cli.py => pasarguard-tui.py | 10 ++--- tui/README.md | 42 +++++++++++++++++++ {cli => tui}/__init__.py | 0 {cli => tui}/admin.py | 2 +- {cli => tui}/help.py | 2 +- {cli => tui}/style.tcss | 0 tui_wrapper.sh | 2 + 15 files changed, 143 insertions(+), 76 deletions(-) delete mode 100644 cli/README.md rename pasarguard-cli.py => pasarguard-tui.py (88%) create mode 100644 tui/README.md rename {cli => tui}/__init__.py (100%) rename {cli => tui}/admin.py (99%) rename {cli => tui}/help.py (98%) rename {cli => tui}/style.tcss (100%) create mode 100644 tui_wrapper.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3caf2fab4..7d388f937 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,3 @@ - - # Contribute to PasarGuard Thanks for considering contributing to **PasarGuard**! @@ -8,17 +6,17 @@ Thanks for considering contributing to **PasarGuard**! Please **don’t use GitHub Issues** to ask questions. Instead, use one of the following platforms: -* 💬 Telegram: [@Pasar_Guard](https://t.me/pasar_guard) -* 🗣️ GitHub Discussions: [PasarGuard Discussions](https://github.com/pasarguard/panel/discussions) +- 💬 Telegram: [@Pasar_Guard](https://t.me/pasar_guard) +- 🗣️ GitHub Discussions: [PasarGuard Discussions](https://github.com/pasarguard/panel/discussions) ## 🐞 Reporting Issues When reporting a bug or issue, please include: -* ✅ What you expected to happen -* ❌ What actually happened (include server logs or browser errors) -* ⚙️ Your `xray` JSON config and `.env` settings (censor sensitive info) -* 🔢 Your PasarGuard version and Docker version (if applicable) +- ✅ What you expected to happen +- ❌ What actually happened (include server logs or browser errors) +- ⚙️ Your `xray` JSON config and `.env` settings (censor sensitive info) +- 🔢 Your PasarGuard version and Docker version (if applicable) --- @@ -28,15 +26,15 @@ If there's no open issue for your idea, consider opening one for discussion **be You can contribute to any issue that: -* Has no PR linked -* Has no maintainer assigned +- Has no PR linked +- Has no maintainer assigned No need to ask for permission! ## 🔀 Branching Strategy -* Always branch off of the `next` branch -* Keep `master` stable and production-ready +- Always branch off of the `next` branch +- Keep `master` stable and production-ready --- @@ -46,6 +44,7 @@ No need to ask for permission! . ├── app # Backend code (FastAPI - Python) ├── cli # CLI code (Typer - Python) +├── tui # TUI code (Textual - Python) ├── dashboard # Frontend code (React - TypeScript) └── tests # API tests ``` @@ -56,10 +55,10 @@ No need to ask for permission! The backend is built with **FastAPI** and **SQLAlchemy**: -* **Pydantic models**: `app/models` -* **Database models & operations**: `app/db` -* **backend logic should go in**: `app/operations` -* **Migrations (Alembic)**: `app/db/migrations` +- **Pydantic models**: `app/models` +- **Database models & operations**: `app/db` +- **backend logic should go in**: `app/operations` +- **Migrations (Alembic)**: `app/db/migrations` 🧩 **Note**: Ensure **all backend logic is organized and implemented in the `operations` module**. This keeps route handling, database access, and service logic clearly separated and easier to maintain. @@ -67,8 +66,8 @@ The backend is built with **FastAPI** and **SQLAlchemy**: Enable the `DOCS` flag in your `.env` file to access: -* Swagger UI: [http://localhost:8000/docs](http://localhost:8000/docs) -* ReDoc: [http://localhost:8000/redoc](http://localhost:8000/redoc) +- Swagger UI: [http://localhost:8000/docs](http://localhost:8000/docs) +- ReDoc: [http://localhost:8000/redoc](http://localhost:8000/redoc) ### 🎯 Code Formatting @@ -95,8 +94,8 @@ make run-migration The frontend is located in the `dashboard` directory and is built using: -* **React + TypeScript** -* **Tailwind CSS (Shadcn UI)** +- **React + TypeScript** +- **Tailwind CSS (Shadcn UI)** To build: @@ -108,17 +107,25 @@ Remove the `dashboard/build` directory and restart the Python backend — the fr ### 🧩 Component Guidelines -* Follow **Tailwind + Shadcn** best practices -* Keep components **single-purpose** -* Prioritize **readability** and **maintainability** +- Follow **Tailwind + Shadcn** best practices +- Keep components **single-purpose** +- Prioritize **readability** and **maintainability** --- ## 🛠️ PasarGuard CLI -PasarGuard’s CLI is built using [Textual](https://textual.textualize.io/). +PasarGuard’s CLI is built using [Typer](https://typer.tiangolo.com/). + +- CLI codebase: `cli/` + +--- + +## 🛠️ PasarGuard TUI + +PasarGuard’s TUI is built using [Textual](https://textual.textualize.io/). -* CLI codebase: `cli/` +- TUI codebase: `tui/` --- diff --git a/Dockerfile b/Dockerfile index 7091558d3..c7bd152e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,8 +28,12 @@ WORKDIR /code ENV PATH="/code/.venv/bin:$PATH" -COPY cli_wrapper.sh /usr/bin/marzban-cli -RUN chmod +x /usr/bin/marzban-cli +COPY cli_wrapper.sh /usr/bin/pasargaurd-cli +RUN chmod +x /usr/bin/pasargaurd-cli + +COPY tui_wrapper.sh /usr/bin/pasargaurd-tui +RUN chmod +x /usr/bin/pasargaurd-tui + RUN chmod +x /code/start.sh ENTRYPOINT ["/code/start.sh"] \ No newline at end of file diff --git a/Makefile b/Makefile index b78b1572d..d5c913da9 100644 --- a/Makefile +++ b/Makefile @@ -94,6 +94,12 @@ run: run-cli: @uv run pasarguard-cli.py +# run pasarguard-tui +.PHONY: run-tui +run-tui: + @uv run pasarguard-tui.py + + # Run tests .PHONY: test test: diff --git a/README-fa.md b/README-fa.md index dc4c52e21..fb3b6f31e 100644 --- a/README-fa.md +++ b/README-fa.md @@ -376,6 +376,18 @@ pasarguard cli [OPTIONS] COMMAND [ARGS]... برای اطلاعات بیشتر، می توانید [مستندات CLI پاسارگارد](./cli/README.md) را مطالعه کنید. +# رابط کاربری متنی (TUI) پاسارگارد + +پاسارگارد همچنین یک رابط کاربری متنی (TUI) برای مدیریت تعاملی مستقیم در ترمینال شما فراهم می کند. + +اگر پاسارگارد را با استفاده از اسکریپت نصب آسان نصب کرده اید، می توانید با اجرای دستور زیر به TUI دسترسی پیدا کنید: + +```bash +pasarguard tui +``` + +برای اطلاعات بیشتر، می توانید [مستندات TUI پاسارگارد](./tui/README.md) را مطالعه کنید. + # نود پروژه پاسارگارد [نود](https://github.com/PasarGuard/node) را معرفی می کند که توزیع زیرساخت را متحول می کند. با نود، می توانید زیرساخت خود را در چندین مکان توزیع کنید و از مزایایی مانند افزونگی، در دسترس بودن بالا، مقیاس پذیری و انعطاف پذیری بهره مند شوید. نود به کاربران این امکان را می دهد که به سرورهای مختلف متصل شوند و به آنها انعطاف پذیری انتخاب و اتصال به چندین سرور را به جای محدود شدن به تنها یک سرور ارائه می دهد. diff --git a/README-ru.md b/README-ru.md index 277ea40e6..401042784 100644 --- a/README-ru.md +++ b/README-ru.md @@ -397,6 +397,18 @@ pasarguard cli [OPTIONS] COMMAND [ARGS]... Для получения дополнительной информации можно ознакомиться с [документацией по PasarGuard CLI](./cli/README.md). +# Пользовательский интерфейс терминала (TUI) PasarGuard + +PasarGuard также предоставляет пользовательский интерфейс терминала (TUI) для интерактивного управления непосредственно в вашем терминале. + +Если вы установили PasarGuard с помощью простого установочного скрипта, вы можете получить доступ к TUI, запустив: + +```bash +pasarguard tui +``` + +Для получения дополнительной информации вы можете ознакомиться с [документацией по TUI PasarGuard](./tui/README.md). + # Node Проект PasarGuard представляет [node](https://github.com/PasarGuard/node), который помогает Вам в распределении инфраструктуры. С помощью node можно распределить инфраструктуру по нескольким узлам, получив такие преимущества, как высокая доступность, масштабируемость и гибкость. node позволяет пользователям подключаться к различным серверам, предоставляя им гибкость в выборе, а не ограничиваться только одним сервером. diff --git a/README-zh-cn.md b/README-zh-cn.md index 8ffe55c5b..9933bd13b 100644 --- a/README-zh-cn.md +++ b/README-zh-cn.md @@ -391,6 +391,18 @@ pasarguard cli [OPTIONS] COMMAND [ARGS]... 更多信息,您可以阅读[PasarGuard CLI的文档](./cli/README.md)。 +# PasarGuard 终端用户界面 (TUI) + +PasarGuard 还提供了一个终端用户界面 (TUI),用于直接在您的终端中进行交互式管理。 + +如果您使用简易安装脚本安装了 PasarGuard,您可以通过运行以下命令访问 TUI: + +```bash +pasarguard tui +``` + +更多信息,您可以阅读 [PasarGuard TUI 的文档](./tui/README.md)。 + # Node PasarGuard项目引入了[node](https://github.com/PasarGuard/node),它彻底改变了基础设施的分布。通过node,您可以将您的基础设施分布到多个位置,从而获得冗余、高可用性、可伸缩性、灵活性等好处。node使用户能够连接到不同的服务器,为他们提供了选择和连接多个服务器的灵活性,而不是仅限于一个服务器。 diff --git a/README.md b/README.md index 23007b85d..9ba5d55d4 100644 --- a/README.md +++ b/README.md @@ -385,6 +385,18 @@ pasarguard cli [OPTIONS] COMMAND [ARGS]... For more information, You can read [PasarGuard CLI's documentation](./cli/README.md). +# PasarGuard TUI + +PasarGuard also provides a Terminal User Interface (TUI) for interactive management directly within your terminal. + +If you've installed PasarGuard using the easy install script, you can access the TUI by running: + +```bash +pasarguard tui +``` + +For more information, you can read [PasarGuard TUI's documentation](./tui/README.md). + # Node The PasarGuard project introduces the [node](https://github.com/PasarGuard/node), which revolutionizes infrastructure distribution. With node, you can distribute your infrastructure across multiple locations, unlocking benefits such as redundancy, high availability, scalability, flexibility. node empowers users to connect to different servers, offering them the flexibility to choose and connect to multiple servers instead of being limited to only one server. diff --git a/cli/README.md b/cli/README.md deleted file mode 100644 index b96344c0b..000000000 --- a/cli/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# PasarGuard CLI - -A modern, interactive command-line interface for managing PasarGuard, built with Textual. - -## Features - -- 🎯 Interactive TUI (Text User Interface) -- 📱 Responsive design with dark mode support -- ⌨️ Keyboard shortcuts for quick navigation -- 🔄 Real-time updates -- 📊 Rich data visualization -- 🔒 Secure admin management - -## Usage - -### Starting the CLI - -```bash -pasarguard cli -``` - -### Keyboard Shortcuts - -#### Global Commands - -- `q` - Quit the application -- `?` - Show help - -#### Admin Section - -- `c` - Create new admin -- `m` - Modify admin -- `r` - Reset admin usage -- `d` - Delete admin -- `i` - Import admins from environment - -### Admin Management - -- Create, modify, and delete admin accounts -- Reset admin usage statistics -- Import admins from environment variables -- View admin details and status diff --git a/pasarguard-cli.py b/pasarguard-tui.py similarity index 88% rename from pasarguard-cli.py rename to pasarguard-tui.py index a0859ae61..a5fab073b 100644 --- a/pasarguard-cli.py +++ b/pasarguard-tui.py @@ -2,14 +2,14 @@ from textual.app import App, ComposeResult from textual.widgets import Footer, Header -from cli.help import HelpModal from config import DEBUG +from tui.help import HelpModal -class PasarGuardCLI(App): +class PasarGuardTUI(App): """A Textual app to manage pasarguard""" - CSS_PATH = "cli/style.tcss" + CSS_PATH = "tui/style.tcss" ENABLE_COMMAND_PALETTE = DEBUG def __init__(self, *args, **kwargs) -> None: @@ -24,7 +24,7 @@ def __init__(self, *args, **kwargs) -> None: def compose(self) -> ComposeResult: """Create child widgets for the app.""" - from cli.admin import AdminContent + from tui.admin import AdminContent yield Header() yield AdminContent(id="admin-content") @@ -49,5 +49,5 @@ def action_help(self) -> None: if __name__ == "__main__": - app = PasarGuardCLI() + app = PasarGuardTUI() app.run() diff --git a/tui/README.md b/tui/README.md new file mode 100644 index 000000000..55dd11307 --- /dev/null +++ b/tui/README.md @@ -0,0 +1,42 @@ +# PasarGuard TUI + +A modern, interactive command-line interface for managing PasarGuard, built with Textual. + +## Features + +- 🎯 Interactive TUI (Text User Interface) +- 📱 Responsive design with dark mode support +- ⌨️ Keyboard shortcuts for quick navigation +- 🔄 Real-time updates +- 📊 Rich data visualization +- 🔒 Secure admin management + +## Usage + +### Starting the TUI + +```bash +pasarguard tui +``` + +### Keyboard Shortcuts + +#### Global Commands + +- `q` - Quit the application +- `?` - Show help + +#### Admin Section + +- `c` - Create new admin +- `m` - Modify admin +- `r` - Reset admin usage +- `d` - Delete admin +- `i` - Import admins from environment + +### Admin Management + +- Create, modify, and delete admin accounts +- Reset admin usage statistics +- Import admins from environment variables +- View admin details and status diff --git a/cli/__init__.py b/tui/__init__.py similarity index 100% rename from cli/__init__.py rename to tui/__init__.py diff --git a/cli/admin.py b/tui/admin.py similarity index 99% rename from cli/admin.py rename to tui/admin.py index 497f001f5..eb4c822ea 100644 --- a/cli/admin.py +++ b/tui/admin.py @@ -17,7 +17,7 @@ from app.operation.admin import AdminOperation from app.utils.helpers import format_cli_validation_error, readable_datetime from app.utils.system import readable_size -from cli import BaseModal +from tui import BaseModal SYSTEM_ADMIN = AdminDetails(username="cli", is_sudo=True, telegram_id=None, discord_webhook=None) diff --git a/cli/help.py b/tui/help.py similarity index 98% rename from cli/help.py rename to tui/help.py index bf059fe67..98b89b85e 100644 --- a/cli/help.py +++ b/tui/help.py @@ -1,7 +1,7 @@ from textual.app import ComposeResult from textual.widgets import Static, Button from textual.containers import Container -from cli import BaseModal +from tui import BaseModal class HelpModal(BaseModal): diff --git a/cli/style.tcss b/tui/style.tcss similarity index 100% rename from cli/style.tcss rename to tui/style.tcss diff --git a/tui_wrapper.sh b/tui_wrapper.sh new file mode 100644 index 000000000..24f9c583e --- /dev/null +++ b/tui_wrapper.sh @@ -0,0 +1,2 @@ +#!/bin/bash +python /code/pasarguard-tui.py From 78acb55a4f2cb9e50657f52d035fb25ea099cfc7 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Fri, 5 Sep 2025 13:06:25 +0330 Subject: [PATCH 2/4] feat: Add cli --- Dockerfile | 8 +- app/utils/helpers.py | 9 -- cli/README.md | 82 ++++++++++++++++ cli/__init__.py | 67 +++++++++++++ cli/admin.py | 226 +++++++++++++++++++++++++++++++++++++++++++ cli/main.py | 82 ++++++++++++++++ cli/node.py | 60 ++++++++++++ cli/system.py | 58 +++++++++++ cli/users.py | 68 +++++++++++++ pasarguard-cli.py | 24 +++++ pyproject.toml | 1 + tui/admin.py | 56 ++++++++--- uv.lock | 26 +++++ 13 files changed, 743 insertions(+), 24 deletions(-) create mode 100644 cli/README.md create mode 100644 cli/__init__.py create mode 100644 cli/admin.py create mode 100644 cli/main.py create mode 100644 cli/node.py create mode 100644 cli/system.py create mode 100644 cli/users.py create mode 100644 pasarguard-cli.py diff --git a/Dockerfile b/Dockerfile index c7bd152e9..7ef9f68fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,11 +28,11 @@ WORKDIR /code ENV PATH="/code/.venv/bin:$PATH" -COPY cli_wrapper.sh /usr/bin/pasargaurd-cli -RUN chmod +x /usr/bin/pasargaurd-cli +COPY cli_wrapper.sh /usr/bin/PasarGuard-cli +RUN chmod +x /usr/bin/PasarGuard-cli -COPY tui_wrapper.sh /usr/bin/pasargaurd-tui -RUN chmod +x /usr/bin/pasargaurd-tui +COPY tui_wrapper.sh /usr/bin/PasarGuard-tui +RUN chmod +x /usr/bin/PasarGuard-tui RUN chmod +x /code/start.sh diff --git a/app/utils/helpers.py b/app/utils/helpers.py index f91837d9f..4fd780111 100644 --- a/app/utils/helpers.py +++ b/app/utils/helpers.py @@ -57,15 +57,6 @@ def format_validation_error(error: ValidationError) -> str: return "\n".join([e["loc"][0].replace("_", " ").capitalize() + ": " + e["msg"] for e in error.errors()]) -def format_cli_validation_error(errors: ValidationError, notify: callable): - for error in errors.errors(): - for err in error["msg"].split(";"): - notify( - title=f"Error: {error['loc'][0].replace('_', ' ').capitalize()}", - message=err.strip(), - severity="error", - ) - def escape_tg_html(list: tuple[str]) -> tuple[str]: """Escapes HTML special characters for the telegram HTML parser.""" diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 000000000..5ee9a7a86 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,82 @@ +# PasarGuard CLI + +A modern, type-safe command-line interface for managing PasarGuard, built with Typer. + +## Features + +- 🎯 Type-safe CLI with rich output +- 📊 Beautiful tables and panels +- 🔒 Secure admin management +- 👥 User account listing +- 🖥️ Node listing +- 📈 System status monitoring +- ⌨️ Interactive prompts and confirmations + +## Installation + +The CLI is included with PasarGuard and can be used directly: + +```bash +PasarGuard cli --help + +# Or from the project root +uv run PasarGuard-cli.py --help +``` + +## Usage + +### General Commands + +```bash +# Show version +PasarGuard cli version + +# Show help +PasarGuard cli --help +``` + +### Admin Management + +```bash +# List all admins +PasarGuard cli admins --list + +# Create new admin +PasarGuard cli admins --create username + +# Delete admin +PasarGuard cli admins --delete username + +# Modify admin (password and sudo status) +PasarGuard cli admins --modify username + +# Reset admin usage +PasarGuard cli admins --reset-usage username +``` + +### User Account Listing + +```bash +# List all users +PasarGuard cli users + +# List users with status filter +PasarGuard cli users --status active + +# List users with pagination +PasarGuard cli users --offset 10 --limit 20 +``` + +### Node Listing + +```bash +# List all nodes +PasarGuard cli nodes +``` + +### System Information + +```bash +# Show system status +PasarGuard cli system +``` diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 000000000..655d4390a --- /dev/null +++ b/cli/__init__.py @@ -0,0 +1,67 @@ +""" +PasarGuard CLI Package + +A modern, type-safe CLI built with Typer for managing PasarGuard instances. +""" + +from pydantic import ValidationError +from rich.console import Console +from rich.table import Table + +from app.models.admin import AdminDetails +from app.operation import OperatorType +from app.operation.admin import AdminOperation +from app.operation.group import GroupOperation +from app.operation.node import NodeOperation +from app.operation.system import SystemOperation +from app.operation.user import UserOperation + +# Initialize console for rich output +console = Console() + +# system admin for CLI operations +SYSTEM_ADMIN = AdminDetails(username="cli", is_sudo=True, telegram_id=None, discord_webhook=None) + + +def get_admin_operation() -> AdminOperation: + """Get admin operation instance.""" + return AdminOperation(OperatorType.CLI) + + +def get_user_operation() -> UserOperation: + """Get user operation instance.""" + return UserOperation(OperatorType.CLI) + + +def get_group_operation() -> GroupOperation: + """Get group operation instance.""" + return GroupOperation(OperatorType.CLI) + + +def get_node_operation() -> NodeOperation: + """Get node operation instance.""" + return NodeOperation(OperatorType.CLI) + + +def get_system_operation() -> SystemOperation: + """Get node operation instance.""" + return SystemOperation(OperatorType.CLI) + + +class BaseCLI: + """Base class for CLI operations.""" + + def __init__(self): + self.console = console + + def create_table(self, title: str, columns: list) -> Table: + """Create a rich table with given columns.""" + table = Table(title=title) + for column in columns: + table.add_column(column["name"], style=column.get("style", "white")) + return table + + def format_cli_validation_error(self, errors: ValidationError): + for error in errors.errors(): + for err in error["msg"].split(";"): + self.console.print(f"[red]Error: {err}[/red]") diff --git a/cli/admin.py b/cli/admin.py new file mode 100644 index 000000000..4e2f8d939 --- /dev/null +++ b/cli/admin.py @@ -0,0 +1,226 @@ +""" +Admin CLI Module + +Handles admin account management through the command line interface. +""" + +import typer +from pydantic import ValidationError + +from app.db.base import get_db +from app.models.admin import AdminCreate, AdminModify +from app.utils.system import readable_size +from cli import SYSTEM_ADMIN, BaseCLI, console, get_admin_operation + + +class AdminCLI(BaseCLI): + """Admin CLI operations.""" + + async def list_admins(self, db): + """List all admin accounts.""" + admin_op = get_admin_operation() + admins = await admin_op.get_admins(db) + + if not admins: + self.console.print("[yellow]No admins found[/yellow]") + return + + table = self.create_table( + "Admin Accounts", + [ + {"name": "Username", "style": "cyan"}, + {"name": "Is Sudo", "style": "green"}, + {"name": "Used Traffic", "style": "blue"}, + {"name": "Is Disabled", "style": "red"}, + ], + ) + + for admin in admins: + table.add_row( + admin.username, + "✓" if admin.is_sudo else "✗", + readable_size(admin.used_traffic), + "✓" if admin.is_disabled else "✗", + ) + + self.console.print(table) + + async def create_admin(self, db, username: str): + """Create a new admin account.""" + admin_op = get_admin_operation() + + # Check if admin already exists + admins = await admin_op.get_admins(db) + if any(admin.username == username for admin in admins): + self.console.print(f"[red]Admin '{username}' already exists[/red]") + return + + while True: + # Get password + password = typer.prompt("Password", hide_input=True) + if not password: + self.console.print("[red]Password is required[/red]") + continue + + confirm_password = typer.prompt("Confirm Password", hide_input=True) + if password != confirm_password: + self.console.print("[red]Passwords do not match[/red]") + continue + + try: + # Create admin + new_admin = AdminCreate(username=username, password=password, is_sudo=False) + await admin_op.create_admin(db, new_admin, SYSTEM_ADMIN) + self.console.print(f"[green]Admin '{username}' created successfully[/green]") + break + except ValidationError as e: + self.format_cli_validation_error(e) + continue + except Exception as e: + self.console.print(f"[red]Error creating admin: {e}[/red]") + break + + async def delete_admin(self, db, username: str): + """Delete an admin account.""" + admin_op = get_admin_operation() + + # Check if admin exists + admins = await admin_op.get_admins(db) + if not any(admin.username == username for admin in admins): + self.console.print(f"[red]Admin '{username}' not found[/red]") + return + + if typer.confirm(f"Are you sure you want to delete admin '{username}'?"): + try: + await admin_op.remove_admin(db, username, SYSTEM_ADMIN) + self.console.print(f"[green]Admin '{username}' deleted successfully[/green]") + except Exception as e: + self.console.print(f"[red]Error deleting admin: {e}[/red]") + + async def modify_admin(self, db, username: str): + """Modify an admin account.""" + admin_op = get_admin_operation() + + # Check if admin exists + admins = await admin_op.get_admins(db) + if not any(admin.username == username for admin in admins): + self.console.print(f"[red]Admin '{username}' not found[/red]") + return + + # Get the current admin details + current_admin = next(admin for admin in admins if admin.username == username) + + self.console.print(f"[yellow]Modifying admin '{username}'[/yellow]") + self.console.print("[cyan]Current settings:[/cyan]") + self.console.print(f" Username: {current_admin.username}") + self.console.print(f" Is Sudo: {'✓' if current_admin.is_sudo else '✗'}") + + new_password = None + is_sudo = current_admin.is_sudo + # Password modification + if typer.confirm("Do you want to change the password?"): + new_password = typer.prompt("New password", hide_input=True) + confirm_password = typer.prompt("Confirm Password", hide_input=True) + if new_password != confirm_password: + self.console.print("[red]Passwords do not match[/red]") + return + + # Sudo status modification + if typer.confirm(f"Do you want to change sudo status? (Current: {'✓' if current_admin.is_sudo else '✗'})"): + is_sudo = typer.confirm("Make this admin a sudo admin?") + + # Confirm changes + self.console.print("\n[cyan]Summary of changes:[/cyan]") + if new_password: + self.console.print(" Password: [yellow]Will be updated[/yellow]") + if is_sudo != current_admin.is_sudo: + self.console.print(f" Is Sudo: {'✓' if is_sudo else '✗'} [yellow](changed)[/yellow]") + + if typer.confirm("Do you want to apply these changes?"): + try: + # Interactive modification + modified_admin = AdminModify(is_sudo=is_sudo, password=new_password) + await admin_op.modify_admin(db, username, modified_admin, SYSTEM_ADMIN) + self.console.print(f"[green]Admin '{username}' modified successfully[/green]") + except Exception as e: + self.console.print(f"[red]Error modifying admin: {e}[/red]") + else: + self.console.print("[yellow]Modification cancelled[/yellow]") + + async def reset_admin_usage(self, db, username: str): + """Reset admin usage statistics.""" + admin_op = get_admin_operation() + + # Check if admin exists + admins = await admin_op.get_admins(db) + if not any(admin.username == username for admin in admins): + self.console.print(f"[red]Admin '{username}' not found[/red]") + return + + if typer.confirm(f"Are you sure you want to reset usage for admin '{username}'?"): + try: + await admin_op.reset_admin_usage(db, username, SYSTEM_ADMIN) + self.console.print(f"[green]Usage reset for admin '{username}'[/green]") + except Exception as e: + self.console.print(f"[red]Error resetting usage: {e}[/red]") + + +# CLI commands +async def list_admins(): + """List all admin accounts.""" + admin_cli = AdminCLI() + async for db in get_db(): + try: + await admin_cli.list_admins(db) + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + finally: + break + + +async def create_admin(username: str): + """Create a new admin account.""" + admin_cli = AdminCLI() + async for db in get_db(): + try: + await admin_cli.create_admin(db, username) + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + finally: + break + + +async def delete_admin(username: str): + """Delete an admin account.""" + admin_cli = AdminCLI() + async for db in get_db(): + try: + await admin_cli.delete_admin(db, username) + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + finally: + break + + +async def modify_admin(username: str): + """Modify an admin account.""" + admin_cli = AdminCLI() + async for db in get_db(): + try: + await admin_cli.modify_admin(db, username) + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + finally: + break + + +async def reset_admin_usage(username: str): + """Reset admin usage statistics.""" + admin_cli = AdminCLI() + async for db in get_db(): + try: + await admin_cli.reset_admin_usage(db, username) + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + finally: + break diff --git a/cli/main.py b/cli/main.py new file mode 100644 index 000000000..abe9ccc60 --- /dev/null +++ b/cli/main.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +""" +PasarGuard CLI - Command Line Interface for PasarGuard Management + +A modern, type-safe CLI built with Typer for managing PasarGuard instances. +""" + +import asyncio +from typing import Optional + +import typer + +from app.db.models import UserStatus +from cli import console +from cli.admin import create_admin, delete_admin, list_admins, modify_admin, reset_admin_usage +from cli.node import list_nodes +from cli.system import show_status +from cli.users import list_users + +# Initialize Typer app +app = typer.Typer( + name="PasarGuard", + help="PasarGuard CLI - Command Line Interface for PasarGuard Management", + add_completion=False, + rich_markup_mode="rich", +) + + +@app.command() +def version(): + """Show PasarGuard version.""" + from app import __version__ + + console.print(f"[bold blue]PasarGuard[/bold blue] version [bold green]{__version__}[/bold green]") + + +@app.command() +def admins( + list: bool = typer.Option(False, "--list", "-l", help="List all admins"), + create: Optional[str] = typer.Option(None, "--create", "-c", help="Create new admin"), + delete: Optional[str] = typer.Option(None, "--delete", "-d", help="Delete admin"), + modify: Optional[str] = typer.Option(None, "--modify", "-m", help="Modify admin"), + reset_usage: Optional[str] = typer.Option(None, "--reset-usage", "-r", help="Reset admin usage"), +): + """List & manage admin accounts.""" + + if list or not any([create, delete, modify, reset_usage]): + asyncio.run(list_admins()) + elif create: + asyncio.run(create_admin(create)) + elif delete: + asyncio.run(delete_admin(delete)) + elif modify: + asyncio.run(modify_admin(modify)) + elif reset_usage: + asyncio.run(reset_admin_usage(reset_usage)) + + +@app.command() +def users( + status: Optional[UserStatus] = typer.Option(None, "--status", "-s", help="Filter by status"), + offset: int = typer.Option(0, "--offset", "-o", help="Offset for pagination"), + limit: int = typer.Option(10, "--limit", "-n", help="Limit number of results"), +): + """List user accounts.""" + asyncio.run(list_users(status, offset, limit)) + + +@app.command() +def nodes(): + """List all nodes.""" + asyncio.run(list_nodes()) + + +@app.command() +def system(): + """Show system status.""" + asyncio.run(show_status()) + + +if __name__ == "__main__": + app() diff --git a/cli/node.py b/cli/node.py new file mode 100644 index 000000000..ba925d9d9 --- /dev/null +++ b/cli/node.py @@ -0,0 +1,60 @@ +""" +Nodes CLI Module + +Handles node management through the command line interface. +""" + +from app.db.base import get_db +from app.db.models import NodeStatus +from app.utils.helpers import readable_datetime +from cli import BaseCLI, console, get_node_operation + + +class NodeCLI(BaseCLI): + """Node CLI operations.""" + + async def list_nodes(self, db): + """List all nodes.""" + node_op = get_node_operation() + nodes = await node_op.get_db_nodes(db) + + if not nodes: + self.console.print("[yellow]No nodes found[/yellow]") + return + + table = self.create_table( + "Nodes", + [ + {"name": "ID", "style": "cyan"}, + {"name": "Name", "style": "green"}, + {"name": "Address", "style": "blue"}, + {"name": "Port", "style": "magenta"}, + {"name": "Status", "style": "yellow"}, + {"name": "Created At", "style": "white"}, + ], + ) + + for node in nodes: + table.add_row( + str(node.id), + node.name, + node.address, + str(node.port), + "Online" if node.status == NodeStatus.connected else "Offline", + readable_datetime(node.created_at), + ) + + self.console.print(table) + + +# CLI commands +async def list_nodes(): + """List all nodes.""" + node_cli = NodeCLI() + async for db in get_db(): + try: + await node_cli.list_nodes(db) + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + finally: + break diff --git a/cli/system.py b/cli/system.py new file mode 100644 index 000000000..4399704f7 --- /dev/null +++ b/cli/system.py @@ -0,0 +1,58 @@ +""" +System CLI Module + +Handles system status and information through the command line interface. +""" + +from rich.panel import Panel + +from app.db.base import get_db +from app.utils.system import readable_size +from cli import SYSTEM_ADMIN, BaseCLI, console, get_system_operation + + +class SystemCLI(BaseCLI): + """System CLI operations.""" + + async def show_status(self, db): + """Show system status.""" + system_op = get_system_operation() + stats = await system_op.get_system_stats(db, SYSTEM_ADMIN) + + status_text = ( + f"[bold]System Statistics[/bold]\n\n" + f"CPU Usage: [green]{stats.cpu_usage:.1f}%[/green]\n" + f"Memory Usage: [green]{stats.mem_used / stats.mem_total * 100:.1f}%[/green] " + f"([cyan]{readable_size(stats.mem_used)}[/cyan] / [cyan]{readable_size(stats.mem_total)}[/cyan])\n" + f"CPU Cores: [magenta]{stats.cpu_cores}[/magenta]\n" + f"Total Users: [blue]{stats.total_user}[/blue]\n" + f"Active Users: [green]{stats.active_users}[/green]\n" + f"Online Users: [yellow]{stats.online_users}[/yellow]\n" + f"On Hold Users: [yellow]{stats.on_hold_users}[/yellow]\n" + f"Disabled Users: [red]{stats.disabled_users}[/red]\n" + f"Expired Users: [red]{stats.expired_users}[/red]\n" + f"Limited Users: [yellow]{stats.limited_users}[/yellow]\n" + f"Data Usage (In): [blue]{readable_size(stats.incoming_bandwidth)}[/blue]\n" + f"Data Usage (Out): [blue]{readable_size(stats.outgoing_bandwidth)}[/blue]" + ) + + panel = Panel( + status_text, + title="System Information", + border_style="blue", + ) + + self.console.print(panel) + + +# CLI commands +async def show_status(): + """Show system status.""" + system_cli = SystemCLI() + async for db in get_db(): + try: + await system_cli.show_status(db) + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + finally: + break diff --git a/cli/users.py b/cli/users.py new file mode 100644 index 000000000..7cc47d703 --- /dev/null +++ b/cli/users.py @@ -0,0 +1,68 @@ +""" +Users CLI Module + +Handles user account management through the command line interface. +""" + +from typing import Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.base import get_db +from app.db.models import UserStatus +from app.models.user import UsersResponse +from app.utils.helpers import readable_datetime +from app.utils.system import readable_size +from cli import SYSTEM_ADMIN, BaseCLI, console, get_user_operation + + +class UserCLI(BaseCLI): + """User CLI operations.""" + + async def list_users(self, db: AsyncSession, status: Optional[UserStatus] = None, offset: int = 0, limit: int = 10): + """List user accounts.""" + user_op = get_user_operation() + users_response: UsersResponse = await user_op.get_users( + db=db, admin=SYSTEM_ADMIN, limit=limit, status=status, offset=offset + ) + + if not users_response or not users_response.users: + self.console.print("[yellow]No users found[/yellow]") + return + + table = self.create_table( + "User Accounts", + [ + {"name": "Username", "style": "cyan"}, + {"name": "Status", "style": "green"}, + {"name": "Used Traffic", "style": "blue"}, + {"name": "Data Limit", "style": "magenta"}, + {"name": "Expire", "style": "yellow"}, + ], + ) + for user in users_response.users: + data_limit = readable_size(user.data_limit) if user.data_limit else "∞" + expire = readable_datetime(user.expire) if user.expire else "∞" + + table.add_row( + user.username, + user.status.value, + readable_size(user.used_traffic), + data_limit, + expire, + ) + + self.console.print(table) + + +# CLI commands +async def list_users(status: Optional[UserStatus] = None, offset: int = 0, limit: int = 10): + """List user accounts.""" + user_cli = UserCLI() + async for db in get_db(): + try: + await user_cli.list_users(db, status, offset, limit) + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + finally: + break diff --git a/pasarguard-cli.py b/pasarguard-cli.py new file mode 100644 index 000000000..d54fdb7d6 --- /dev/null +++ b/pasarguard-cli.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +""" +PasarGuard CLI Wrapper +This script provides a simple entry point for the PasarGuard CLI. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +try: + from cli.main import app + + if len(sys.argv) == 1: + sys.argv.append("--help") + app() +except ImportError as e: + print(f"Error importing CLI: {e}") + print("Make sure you're running this from the PasarGuard project directory.") + sys.exit(1) +except Exception as e: + print(f"Error running CLI: {e}") + sys.exit(1) diff --git a/pyproject.toml b/pyproject.toml index c08b1d20e..d14220056 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dependencies = [ "aiorwlock>=1.5.0", "gozargah-node-bridge>=0.0.44", "aiomonitor>=0.7.1", + "typer>=0.17.3", ] [tool.ruff] diff --git a/tui/admin.py b/tui/admin.py index eb4c822ea..04b3d1732 100644 --- a/tui/admin.py +++ b/tui/admin.py @@ -15,11 +15,11 @@ from app.models.admin import AdminCreate, AdminDetails, AdminModify from app.operation import OperatorType from app.operation.admin import AdminOperation -from app.utils.helpers import format_cli_validation_error, readable_datetime +from app.utils.helpers import readable_datetime from app.utils.system import readable_size from tui import BaseModal -SYSTEM_ADMIN = AdminDetails(username="cli", is_sudo=True, telegram_id=None, discord_webhook=None) +SYSTEM_ADMIN = AdminDetails(username="tui", is_sudo=True, telegram_id=None, discord_webhook=None) class AdminDelete(BaseModal): @@ -92,11 +92,20 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: class AdminCreateModale(BaseModal): - def __init__(self, db: AsyncSession, operation: AdminOperation, on_close: callable, *args, **kwargs) -> None: + def __init__( + self, + db: AsyncSession, + operation: AdminOperation, + on_close: callable, + format_tui_validation_error: callable, + *args, + **kwargs, + ) -> None: super().__init__(*args, **kwargs) self.db = db self.operation = operation self.on_close = on_close + self.format_tui_validation_error = format_tui_validation_error def compose(self) -> ComposeResult: with Container(classes="modal-box-form"): @@ -160,7 +169,7 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: await self.key_escape() self.on_close() except ValidationError as e: - format_cli_validation_error(e, self.notify) + self.format_tui_validation_error(e) except ValueError as e: self.notify(str(e), severity="error", title="Error") elif event.button.id == "cancel": @@ -169,13 +178,21 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: class AdminModifyModale(BaseModal): def __init__( - self, db: AsyncSession, operation: AdminOperation, admin: Admin, on_close: callable, *args, **kwargs + self, + db: AsyncSession, + operation: AdminOperation, + admin: Admin, + on_close: callable, + format_tui_validation_error: callable, + *args, + **kwargs, ) -> None: super().__init__(*args, **kwargs) self.db = db self.operation = operation self.admin = admin self.on_close = on_close + self.format_tui_validation_error = format_tui_validation_error def compose(self) -> ComposeResult: with Container(classes="modal-box-form"): @@ -253,7 +270,7 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: await self.key_escape() self.on_close() except ValidationError as e: - format_cli_validation_error(e, self.notify) + self.format_tui_validation_error(e) except ValueError as e: self.notify(str(e), severity="error", title="Error") elif event.button.id == "cancel": @@ -362,9 +379,11 @@ async def admins_list(self): label = Text(f"{i + offset}") i += 1 self.table.add_row(*centered_row, key=adnin.username, label=label) - + total_pages = (self.total_admins + self.page_size - 1) // self.page_size - self.pagination_info.update(f"Page {self.current_page}/{total_pages} (Total admins: {self.total_admins})\nPress `n` for go to the next page and `p` to back to previose page") + self.pagination_info.update( + f"Page {self.current_page}/{total_pages} (Total admins: {self.total_admins})\nPress `n` for go to the next page and `p` to back to previose page" + ) @property def selected_admin(self): @@ -379,13 +398,19 @@ def _refresh_table(self): self.run_worker(self.admins_list) async def action_create_admin(self): - self.app.push_screen(AdminCreateModale(self.db, self.admin_operator, self._refresh_table)) + self.app.push_screen( + AdminCreateModale(self.db, self.admin_operator, self._refresh_table, self.format_tui_validation_error) + ) async def action_modify_admin(self): if not self.table.columns: return admin = await self.admin_operator.get_validated_admin(self.db, username=self.selected_admin) - self.app.push_screen(AdminModifyModale(self.db, self.admin_operator, admin, self._refresh_table)) + self.app.push_screen( + AdminModifyModale( + self.db, self.admin_operator, admin, self._refresh_table, self.format_tui_validation_error + ) + ) async def action_import_from_env(self): try: @@ -414,7 +439,7 @@ async def action_import_from_env(self): self.notify("Admin created successfully", severity="success", title="Success") self._refresh_table() except ValidationError as e: - format_cli_validation_error(e, self.notify) + self.format_tui_validation_error(e) except ValueError as e: self.notify(str(e), severity="error", title="Error") @@ -445,3 +470,12 @@ async def key_enter(self) -> None: async def on_prune(self, event): await self.db.close() return await super().on_prune(event) + + def format_tui_validation_error(self, errors: ValidationError): + for error in errors.errors(): + for err in error["msg"].split(";"): + self.notify( + title=f"Error: {error['loc'][0].replace('_', ' ').capitalize()}", + message=err.strip(), + severity="error", + ) diff --git a/uv.lock b/uv.lock index 58cc4e7dc..6d6c41bb8 100644 --- a/uv.lock +++ b/uv.lock @@ -944,6 +944,7 @@ dependencies = [ { name = "sqlalchemy", extra = ["asyncio"] }, { name = "sse-starlette" }, { name = "textual", extra = ["syntax"] }, + { name = "typer" }, { name = "uvicorn" }, { name = "uvloop", marker = "sys_platform != 'win32'" }, { name = "websocket-client" }, @@ -1000,6 +1001,7 @@ requires-dist = [ { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.40" }, { name = "sse-starlette", specifier = ">=2.1.3" }, { name = "textual", extras = ["syntax"], specifier = ">=2.1.2" }, + { name = "typer", specifier = ">=0.17.3" }, { name = "uvicorn", specifier = "==0.27.0.post1" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.21.0" }, { name = "websocket-client", specifier = "==1.7.0" }, @@ -1401,6 +1403,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/db/c376b0661c24cf770cb8815268190668ec1330eba8374a126ceef8c72d55/ruff-0.12.5-py3-none-win_arm64.whl", hash = "sha256:48cdbfc633de2c5c37d9f090ba3b352d1576b0015bfc3bc98eaf230275b7e805", size = 11951564, upload-time = "2025-07-24T13:26:34.994Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1836,6 +1847,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/fa/b25e688df5b4e024bc3627bc3f951524ef9c8b0756f0646411efa5063a10/tree_sitter_yaml-0.7.1-cp310-abi3-win_arm64.whl", hash = "sha256:298ade69ad61f76bb3e50ced809650ec30521a51aa2708166b176419ccb0a6ba", size = 43801, upload-time = "2025-05-22T13:34:55.471Z" }, ] +[[package]] +name = "typer" +version = "0.17.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/82/f4bfed3bc18c6ebd6f828320811bbe4098f92a31adf4040bee59c4ae02ea/typer-0.17.3.tar.gz", hash = "sha256:0c600503d472bcf98d29914d4dcd67f80c24cc245395e2e00ba3603c9332e8ba", size = 103517, upload-time = "2025-08-30T12:35:24.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/e8/b3d537470e8404659a6335e7af868e90657efb73916ef31ddf3d8b9cb237/typer-0.17.3-py3-none-any.whl", hash = "sha256:643919a79182ab7ac7581056d93c6a2b865b026adf2872c4d02c72758e6f095b", size = 46494, upload-time = "2025-08-30T12:35:22.391Z" }, +] + [[package]] name = "typing-extensions" version = "4.14.1" From 38c492691fd8d0c4aa1ab9759eb20c5431809b26 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Fri, 5 Sep 2025 13:49:29 +0330 Subject: [PATCH 3/4] update Contribute file --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d388f937..e08f0f67d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,7 +34,7 @@ No need to ask for permission! ## 🔀 Branching Strategy - Always branch off of the `next` branch -- Keep `master` stable and production-ready +- Keep `main` stable and production-ready --- From b949f59acdf1a959e3bd9d60fc7dc94120482bec Mon Sep 17 00:00:00 2001 From: Mohammad Date: Fri, 5 Sep 2025 13:50:04 +0330 Subject: [PATCH 4/4] remove: node and users command --- Dockerfile | 8 +++--- cli/README.md | 34 +++++-------------------- cli/__init__.py | 18 ------------- cli/main.py | 19 -------------- cli/node.py | 60 ------------------------------------------- cli/users.py | 68 ------------------------------------------------- 6 files changed, 10 insertions(+), 197 deletions(-) delete mode 100644 cli/node.py delete mode 100644 cli/users.py diff --git a/Dockerfile b/Dockerfile index 7ef9f68fc..62defc9a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,11 +28,11 @@ WORKDIR /code ENV PATH="/code/.venv/bin:$PATH" -COPY cli_wrapper.sh /usr/bin/PasarGuard-cli -RUN chmod +x /usr/bin/PasarGuard-cli +COPY cli_wrapper.sh /usr/bin/pasarguard-cli +RUN chmod +x /usr/bin/pasarguard-cli -COPY tui_wrapper.sh /usr/bin/PasarGuard-tui -RUN chmod +x /usr/bin/PasarGuard-tui +COPY tui_wrapper.sh /usr/bin/pasarguard-tui +RUN chmod +x /usr/bin/pasarguard-tui RUN chmod +x /code/start.sh diff --git a/cli/README.md b/cli/README.md index 5ee9a7a86..529542f84 100644 --- a/cli/README.md +++ b/cli/README.md @@ -7,8 +7,6 @@ A modern, type-safe command-line interface for managing PasarGuard, built with T - 🎯 Type-safe CLI with rich output - 📊 Beautiful tables and panels - 🔒 Secure admin management -- 👥 User account listing -- 🖥️ Node listing - 📈 System status monitoring - ⌨️ Interactive prompts and confirmations @@ -29,49 +27,29 @@ uv run PasarGuard-cli.py --help ```bash # Show version -PasarGuard cli version +pasarguard cli version # Show help -PasarGuard cli --help +pasarguard cli --help ``` ### Admin Management ```bash # List all admins -PasarGuard cli admins --list +pasarguard cli admins --list # Create new admin -PasarGuard cli admins --create username +pasarguard cli admins --create username # Delete admin -PasarGuard cli admins --delete username +pasarguard cli admins --delete username # Modify admin (password and sudo status) PasarGuard cli admins --modify username # Reset admin usage -PasarGuard cli admins --reset-usage username -``` - -### User Account Listing - -```bash -# List all users -PasarGuard cli users - -# List users with status filter -PasarGuard cli users --status active - -# List users with pagination -PasarGuard cli users --offset 10 --limit 20 -``` - -### Node Listing - -```bash -# List all nodes -PasarGuard cli nodes +pasarguard cli admins --reset-usage username ``` ### System Information diff --git a/cli/__init__.py b/cli/__init__.py index 655d4390a..abcb32581 100644 --- a/cli/__init__.py +++ b/cli/__init__.py @@ -11,10 +11,7 @@ from app.models.admin import AdminDetails from app.operation import OperatorType from app.operation.admin import AdminOperation -from app.operation.group import GroupOperation -from app.operation.node import NodeOperation from app.operation.system import SystemOperation -from app.operation.user import UserOperation # Initialize console for rich output console = Console() @@ -28,21 +25,6 @@ def get_admin_operation() -> AdminOperation: return AdminOperation(OperatorType.CLI) -def get_user_operation() -> UserOperation: - """Get user operation instance.""" - return UserOperation(OperatorType.CLI) - - -def get_group_operation() -> GroupOperation: - """Get group operation instance.""" - return GroupOperation(OperatorType.CLI) - - -def get_node_operation() -> NodeOperation: - """Get node operation instance.""" - return NodeOperation(OperatorType.CLI) - - def get_system_operation() -> SystemOperation: """Get node operation instance.""" return SystemOperation(OperatorType.CLI) diff --git a/cli/main.py b/cli/main.py index abe9ccc60..cbd3fdb79 100644 --- a/cli/main.py +++ b/cli/main.py @@ -10,12 +10,9 @@ import typer -from app.db.models import UserStatus from cli import console from cli.admin import create_admin, delete_admin, list_admins, modify_admin, reset_admin_usage -from cli.node import list_nodes from cli.system import show_status -from cli.users import list_users # Initialize Typer app app = typer.Typer( @@ -56,22 +53,6 @@ def admins( asyncio.run(reset_admin_usage(reset_usage)) -@app.command() -def users( - status: Optional[UserStatus] = typer.Option(None, "--status", "-s", help="Filter by status"), - offset: int = typer.Option(0, "--offset", "-o", help="Offset for pagination"), - limit: int = typer.Option(10, "--limit", "-n", help="Limit number of results"), -): - """List user accounts.""" - asyncio.run(list_users(status, offset, limit)) - - -@app.command() -def nodes(): - """List all nodes.""" - asyncio.run(list_nodes()) - - @app.command() def system(): """Show system status.""" diff --git a/cli/node.py b/cli/node.py deleted file mode 100644 index ba925d9d9..000000000 --- a/cli/node.py +++ /dev/null @@ -1,60 +0,0 @@ -""" -Nodes CLI Module - -Handles node management through the command line interface. -""" - -from app.db.base import get_db -from app.db.models import NodeStatus -from app.utils.helpers import readable_datetime -from cli import BaseCLI, console, get_node_operation - - -class NodeCLI(BaseCLI): - """Node CLI operations.""" - - async def list_nodes(self, db): - """List all nodes.""" - node_op = get_node_operation() - nodes = await node_op.get_db_nodes(db) - - if not nodes: - self.console.print("[yellow]No nodes found[/yellow]") - return - - table = self.create_table( - "Nodes", - [ - {"name": "ID", "style": "cyan"}, - {"name": "Name", "style": "green"}, - {"name": "Address", "style": "blue"}, - {"name": "Port", "style": "magenta"}, - {"name": "Status", "style": "yellow"}, - {"name": "Created At", "style": "white"}, - ], - ) - - for node in nodes: - table.add_row( - str(node.id), - node.name, - node.address, - str(node.port), - "Online" if node.status == NodeStatus.connected else "Offline", - readable_datetime(node.created_at), - ) - - self.console.print(table) - - -# CLI commands -async def list_nodes(): - """List all nodes.""" - node_cli = NodeCLI() - async for db in get_db(): - try: - await node_cli.list_nodes(db) - except Exception as e: - console.print(f"[red]Error: {e}[/red]") - finally: - break diff --git a/cli/users.py b/cli/users.py deleted file mode 100644 index 7cc47d703..000000000 --- a/cli/users.py +++ /dev/null @@ -1,68 +0,0 @@ -""" -Users CLI Module - -Handles user account management through the command line interface. -""" - -from typing import Optional - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.base import get_db -from app.db.models import UserStatus -from app.models.user import UsersResponse -from app.utils.helpers import readable_datetime -from app.utils.system import readable_size -from cli import SYSTEM_ADMIN, BaseCLI, console, get_user_operation - - -class UserCLI(BaseCLI): - """User CLI operations.""" - - async def list_users(self, db: AsyncSession, status: Optional[UserStatus] = None, offset: int = 0, limit: int = 10): - """List user accounts.""" - user_op = get_user_operation() - users_response: UsersResponse = await user_op.get_users( - db=db, admin=SYSTEM_ADMIN, limit=limit, status=status, offset=offset - ) - - if not users_response or not users_response.users: - self.console.print("[yellow]No users found[/yellow]") - return - - table = self.create_table( - "User Accounts", - [ - {"name": "Username", "style": "cyan"}, - {"name": "Status", "style": "green"}, - {"name": "Used Traffic", "style": "blue"}, - {"name": "Data Limit", "style": "magenta"}, - {"name": "Expire", "style": "yellow"}, - ], - ) - for user in users_response.users: - data_limit = readable_size(user.data_limit) if user.data_limit else "∞" - expire = readable_datetime(user.expire) if user.expire else "∞" - - table.add_row( - user.username, - user.status.value, - readable_size(user.used_traffic), - data_limit, - expire, - ) - - self.console.print(table) - - -# CLI commands -async def list_users(status: Optional[UserStatus] = None, offset: int = 0, limit: int = 10): - """List user accounts.""" - user_cli = UserCLI() - async for db in get_db(): - try: - await user_cli.list_users(db, status, offset, limit) - except Exception as e: - console.print(f"[red]Error: {e}[/red]") - finally: - break