From a33642ecba0caeec30521836b6e0ae990dc1afb8 Mon Sep 17 00:00:00 2001 From: Rerowros Date: Wed, 14 Jan 2026 21:57:36 +0700 Subject: [PATCH 01/11] fix(models/stats): normalize timezone for stat date fields --- app/models/stats.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/app/models/stats.py b/app/models/stats.py index b775aa281..c5dbaf36f 100644 --- a/app/models/stats.py +++ b/app/models/stats.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, field_validator +from app.utils.helpers import fix_datetime_timezone from .validators import NumericValidatorMixin @@ -18,6 +19,13 @@ class StatList(BaseModel): start: dt end: dt + @field_validator("start", "end", mode="before") + @classmethod + def validator_date(cls, v): + if not v: + return v + return fix_datetime_timezone(v) + class UserUsageStat(BaseModel): total_traffic: int @@ -27,6 +35,13 @@ class UserUsageStat(BaseModel): def cast_to_int(cls, v): return NumericValidatorMixin.cast_to_int(v) + @field_validator("period_start", mode="before") + @classmethod + def validator_date(cls, v): + if not v: + return v + return fix_datetime_timezone(v) + class UserUsageStatsList(StatList): stats: dict[int, list[UserUsageStat]] @@ -41,6 +56,13 @@ class NodeUsageStat(BaseModel): def cast_to_int(cls, v): return NumericValidatorMixin.cast_to_int(v) + @field_validator("period_start", mode="before") + @classmethod + def validator_date(cls, v): + if not v: + return v + return fix_datetime_timezone(v) + class NodeUsageStatsList(StatList): stats: dict[int, list[NodeUsageStat]] @@ -72,6 +94,13 @@ class NodeStats(BaseModel): def cast_to_float(cls, v): return NumericValidatorMixin.cast_to_float(v) + @field_validator("period_start", mode="before") + @classmethod + def validator_date(cls, v): + if not v: + return v + return fix_datetime_timezone(v) + class NodeStatsList(StatList): stats: list[NodeStats] From d617b824d172620b5b3019c07de5a4457832de80 Mon Sep 17 00:00:00 2001 From: Rerowros Date: Wed, 14 Jan 2026 21:57:36 +0700 Subject: [PATCH 02/11] fix(models/user): normalize timezone for user-related date fields --- app/models/user.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/models/user.py b/app/models/user.py index fb6737daf..4d9c6cba4 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -109,6 +109,13 @@ class UserNotificationResponse(User): def cast_to_int(cls, v): return NumericValidatorMixin.cast_to_int(v) + @field_validator("created_at", "edit_at", "online_at", mode="before") + @classmethod + def validator_date(cls, v): + if not v: + return v + return fix_datetime_timezone(v) + class UserResponse(UserNotificationResponse): admin: AdminBase | None = Field(default=None) @@ -140,6 +147,13 @@ class UserSubscriptionUpdateSchema(BaseModel): model_config = ConfigDict(from_attributes=True) + @field_validator("created_at", mode="before") + @classmethod + def validator_date(cls, v): + if not v: + return v + return fix_datetime_timezone(v) + class UserSubscriptionUpdateList(BaseModel): updates: list[UserSubscriptionUpdateSchema] = Field(default_factory=list) From b6cac7e97f47e94649b18553c9648697e7e228ae Mon Sep 17 00:00:00 2001 From: Rerowros Date: Wed, 14 Jan 2026 21:57:36 +0700 Subject: [PATCH 03/11] fix(utils/jwt): use timezone-aware datetime for token iat/exp --- app/utils/jwt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/utils/jwt.py b/app/utils/jwt.py index dbd9059c2..4f9b6842c 100644 --- a/app/utils/jwt.py +++ b/app/utils/jwt.py @@ -19,7 +19,7 @@ async def get_secret_key(): async def create_admin_token(username: str, is_sudo=False) -> str: - data = {"sub": username, "access": "sudo" if is_sudo else "admin", "iat": datetime.utcnow()} + data = {"sub": username, "access": "sudo" if is_sudo else "admin", "iat": datetime.now(timezone.utc)} if JWT_ACCESS_TOKEN_EXPIRE_MINUTES > 0: expire = datetime.now(timezone.utc) + timedelta(minutes=JWT_ACCESS_TOKEN_EXPIRE_MINUTES) data["exp"] = expire From 6dcf3c5bf2a53de1a5970659a7c8075c0dbceae1 Mon Sep 17 00:00:00 2001 From: Rerowros Date: Sat, 17 Jan 2026 21:32:01 +0700 Subject: [PATCH 04/11] feat(db): add NodeUserLimit model and migrations for per-node data limits - Add NodeUserLimit model for per-user per-node traffic limits - Add node_user_limits field to UserTemplate model - Add user_data_limit, user_data_limit_reset_strategy, user_reset_time to Node model - Add migrations for all schema changes --- .../e3879a8dfdab_add_node_user_limits.py | 37 ++++++++++ ..._add_reset_strategy_to_node_user_limits.py | 35 ++++++++++ ...67890b_add_user_data_limit_cols_to_node.py | 28 ++++++++ ...67890c_add_node_user_limits_to_template.py | 28 ++++++++ app/db/models.py | 35 ++++++++++ app/models/node.py | 6 ++ app/models/node_user_limit.py | 70 +++++++++++++++++++ app/models/user_template.py | 10 +++ 8 files changed, 249 insertions(+) create mode 100644 app/db/migrations/versions/e3879a8dfdab_add_node_user_limits.py create mode 100644 app/db/migrations/versions/f1234567890a_add_reset_strategy_to_node_user_limits.py create mode 100644 app/db/migrations/versions/f1234567890b_add_user_data_limit_cols_to_node.py create mode 100644 app/db/migrations/versions/f1234567890c_add_node_user_limits_to_template.py create mode 100644 app/models/node_user_limit.py diff --git a/app/db/migrations/versions/e3879a8dfdab_add_node_user_limits.py b/app/db/migrations/versions/e3879a8dfdab_add_node_user_limits.py new file mode 100644 index 000000000..a5029c019 --- /dev/null +++ b/app/db/migrations/versions/e3879a8dfdab_add_node_user_limits.py @@ -0,0 +1,37 @@ +"""add node user limits + +Revision ID: e3879a8dfdab +Revises: ee97c01bfbaf +Create Date: 2026-01-15 23:12:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e3879a8dfdab' +down_revision = 'ee97c01bfbaf' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('node_user_limits', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('node_id', sa.Integer(), nullable=False), + sa.Column('data_limit', sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint(['node_id'], ['nodes.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'node_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('node_user_limits') + # ### end Alembic commands ### diff --git a/app/db/migrations/versions/f1234567890a_add_reset_strategy_to_node_user_limits.py b/app/db/migrations/versions/f1234567890a_add_reset_strategy_to_node_user_limits.py new file mode 100644 index 000000000..4108f909b --- /dev/null +++ b/app/db/migrations/versions/f1234567890a_add_reset_strategy_to_node_user_limits.py @@ -0,0 +1,35 @@ +"""add reset strategy to node user limits + +Revision ID: f1234567890a +Revises: e3879a8dfdab +Create Date: 2026-01-16 16:20:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import text + + +# revision identifiers, used by Alembic. +revision = 'f1234567890a' +down_revision = 'e3879a8dfdab' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add data_limit_reset_strategy column with default value + op.add_column('node_user_limits', + sa.Column('data_limit_reset_strategy', sa.String(), server_default='no_reset', nullable=False) + ) + + # Add reset_time column with default value + op.add_column('node_user_limits', + sa.Column('reset_time', sa.Integer(), server_default=text('-1'), nullable=False) + ) + + +def downgrade() -> None: + # Remove the added columns + op.drop_column('node_user_limits', 'reset_time') + op.drop_column('node_user_limits', 'data_limit_reset_strategy') diff --git a/app/db/migrations/versions/f1234567890b_add_user_data_limit_cols_to_node.py b/app/db/migrations/versions/f1234567890b_add_user_data_limit_cols_to_node.py new file mode 100644 index 000000000..5856dbb85 --- /dev/null +++ b/app/db/migrations/versions/f1234567890b_add_user_data_limit_cols_to_node.py @@ -0,0 +1,28 @@ +"""add user data limit cols to node + +Revision ID: f1234567890b +Revises: f1234567890a +Create Date: 2026-01-16 10:05:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f1234567890b' +down_revision = 'f1234567890a' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('nodes', sa.Column('user_data_limit', sa.BigInteger(), server_default=sa.text('0'), nullable=False)) + op.add_column('nodes', sa.Column('user_data_limit_reset_strategy', sa.Enum('no_reset', 'day', 'week', 'month', 'year', name='datalimitresetstrategy'), server_default='no_reset', nullable=False)) + op.add_column('nodes', sa.Column('user_reset_time', sa.Integer(), server_default=sa.text('-1'), nullable=False)) + + +def downgrade() -> None: + op.drop_column('nodes', 'user_reset_time') + op.drop_column('nodes', 'user_data_limit_reset_strategy') + op.drop_column('nodes', 'user_data_limit') diff --git a/app/db/migrations/versions/f1234567890c_add_node_user_limits_to_template.py b/app/db/migrations/versions/f1234567890c_add_node_user_limits_to_template.py new file mode 100644 index 000000000..a8dc09d42 --- /dev/null +++ b/app/db/migrations/versions/f1234567890c_add_node_user_limits_to_template.py @@ -0,0 +1,28 @@ +"""add_node_user_limits_to_template + +Revision ID: f1234567890c +Revises: f1234567890b +Create Date: 2026-01-16 22:20:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql, postgresql, sqlite + +# revision identifiers, used by Alembic. +revision = 'f1234567890c' +down_revision = 'f1234567890b' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add node_user_limits JSON column to user_templates table + with op.batch_alter_table('user_templates', schema=None) as batch_op: + batch_op.add_column(sa.Column('node_user_limits', sa.JSON(), nullable=True)) + + +def downgrade() -> None: + # Remove node_user_limits column from user_templates table + with op.batch_alter_table('user_templates', schema=None) as batch_op: + batch_op.drop_column('node_user_limits') diff --git a/app/db/models.py b/app/db/models.py index c9afc9052..5fc796735 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -129,9 +129,13 @@ class User(Base): cascade="all, delete-orphan", init=False, ) + node_limits: Mapped[List["NodeUserLimit"]] = relationship( + back_populates="user", cascade="all, delete-orphan", init=False + ) notification_reminders: Mapped[List["NotificationReminder"]] = relationship( back_populates="user", cascade="all, delete-orphan", init=False ) + subscription_updates: Mapped[List["UserSubscriptionUpdate"]] = relationship( back_populates="user", cascade="all, delete-orphan", init=False ) @@ -330,6 +334,7 @@ class UserTemplate(Base): back_populates="user_template", cascade="all, delete-orphan", init=False ) groups: Mapped[List["Group"]] = relationship(secondary=template_group_association, back_populates="templates") + node_user_limits: Mapped[Optional[Dict[int, Any]]] = mapped_column(JSON(True), default=None) data_limit: Mapped[int] = mapped_column(BigInteger, default=0) expire_duration: Mapped[int] = mapped_column(BigInteger, default=0) # in seconds on_hold_timeout: Mapped[Optional[int]] = mapped_column(default=None) @@ -499,7 +504,11 @@ class Node(Base): user_usages: Mapped[List["NodeUserUsage"]] = relationship( back_populates="node", cascade="all, delete-orphan", init=False ) + user_limits: Mapped[List["NodeUserLimit"]] = relationship( + back_populates="node", cascade="all, delete-orphan", init=False + ) usages: Mapped[List["NodeUsage"]] = relationship(back_populates="node", cascade="all, delete-orphan", init=False) + usage_logs: Mapped[List["NodeUsageResetLogs"]] = relationship( back_populates="node", cascade="all, delete-orphan", init=False ) @@ -515,6 +524,13 @@ class Node(Base): default=DataLimitResetStrategy.no_reset, ) reset_time: Mapped[int] = mapped_column(default=-1, server_default=text("-1")) + user_data_limit: Mapped[int] = mapped_column(BigInteger, default=0, server_default=text("0")) + user_data_limit_reset_strategy: Mapped[DataLimitResetStrategy] = mapped_column( + SQLEnum(DataLimitResetStrategy), + default=DataLimitResetStrategy.no_reset, + server_default=DataLimitResetStrategy.no_reset.name, + ) + user_reset_time: Mapped[int] = mapped_column(default=-1, server_default=text("-1")) usage_coefficient: Mapped[float] = mapped_column(Float, server_default=text("1.0"), default=1) connection_type: Mapped[NodeConnectionType] = mapped_column( SQLEnum(NodeConnectionType), @@ -592,7 +608,26 @@ class NodeUserUsage(Base): used_traffic: Mapped[int] = mapped_column(BigInteger, default=0) +class NodeUserLimit(Base): + __tablename__ = "node_user_limits" + __table_args__ = (UniqueConstraint("user_id", "node_id"),) + + id: Mapped[int] = mapped_column(primary_key=True, init=False) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + user: Mapped["User"] = relationship(back_populates="node_limits", init=False) + node_id: Mapped[int] = mapped_column(ForeignKey("nodes.id")) + node: Mapped["Node"] = relationship(back_populates="user_limits", init=False) + data_limit: Mapped[int] = mapped_column(BigInteger, default=0) + data_limit_reset_strategy: Mapped[DataLimitResetStrategy] = mapped_column( + SQLEnum(DataLimitResetStrategy), + default=DataLimitResetStrategy.no_reset, + server_default="no_reset", + ) + reset_time: Mapped[int] = mapped_column(default=-1, server_default=text("-1")) + + class NodeUsage(Base): + __tablename__ = "node_usages" __table_args__ = (UniqueConstraint("created_at", "node_id"),) diff --git a/app/models/node.py b/app/models/node.py index dc03c6635..1c91781cb 100644 --- a/app/models/node.py +++ b/app/models/node.py @@ -47,6 +47,9 @@ class Node(BaseModel): data_limit: int = Field(default=0) data_limit_reset_strategy: DataLimitResetStrategy = Field(default=DataLimitResetStrategy.no_reset) reset_time: int = Field(default=-1) + user_data_limit: int | None = Field(default=None) + user_data_limit_reset_strategy: DataLimitResetStrategy = Field(default=DataLimitResetStrategy.no_reset) + user_reset_time: int = Field(default=-1) default_timeout: int = Field(default=10, ge=3, le=60) internal_timeout: int = Field(default=15, ge=3, le=60) @@ -170,6 +173,9 @@ class NodeModify(NodeCreate): data_limit: int | None = None data_limit_reset_strategy: DataLimitResetStrategy | None = None reset_time: int | None = None + user_data_limit: int | None = None + user_data_limit_reset_strategy: DataLimitResetStrategy | None = None + user_reset_time: int | None = None default_timeout: int | None = Field(default=None, ge=3, le=60) internal_timeout: int | None = Field(default=None, ge=3, le=60) diff --git a/app/models/node_user_limit.py b/app/models/node_user_limit.py new file mode 100644 index 000000000..704685010 --- /dev/null +++ b/app/models/node_user_limit.py @@ -0,0 +1,70 @@ +from pydantic import BaseModel, ConfigDict, Field + + +class NodeUserLimitBase(BaseModel): + """Base schema for node user limits""" + + user_id: int + node_id: int + data_limit: int = Field(ge=0, default=0, description="Per-user data limit for this node in bytes") + data_limit_reset_strategy: str = Field(default="no_reset", description="Reset strategy for per-user limit") + reset_time: int = Field(default=-1, description="Reset time for the limit") + + +class NodeUserLimitCreate(NodeUserLimitBase): + """Schema for creating a new node user limit""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "user_id": 1, + "node_id": 1, + "data_limit": 10737418240, # 10 GB + } + } + ) + + +class NodeUserLimitModify(BaseModel): + """Schema for modifying an existing node user limit""" + + data_limit: int = Field(ge=0, description="Per-user data limit for this node in bytes") + data_limit_reset_strategy: str | None = Field(default=None, description="Reset strategy for per-user limit") + reset_time: int | None = Field(default=None, description="Reset time for the limit") + + model_config = ConfigDict(json_schema_extra={"example": {"data_limit": 21474836480, "data_limit_reset_strategy": "month", "reset_time": 1}}) # 20 GB + + +class NodeUserLimitResponse(NodeUserLimitBase): + """Schema for node user limit responses""" + + id: int + + model_config = ConfigDict(from_attributes=True) + + +class NodeUserLimitsResponse(BaseModel): + """Schema for listing multiple node user limits""" + + limits: list[NodeUserLimitResponse] + total: int + + +class BulkSetLimitRequest(BaseModel): + """Schema for setting the same limit for all users on a node""" + + node_id: int + data_limit: int = Field(ge=0, description="Data limit in bytes to apply to all users on this node") + data_limit_reset_strategy: str = Field(default="no_reset", description="Reset strategy for per-user limits") + reset_time: int = Field(default=-1, description="Reset time for the limits") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "node_id": 1, + "data_limit": 10737418240, # 10 GB + "data_limit_reset_strategy": "month", + "reset_time": 1, + } + } + ) diff --git a/app/models/user_template.py b/app/models/user_template.py index acb311e02..b143b5e28 100644 --- a/app/models/user_template.py +++ b/app/models/user_template.py @@ -18,6 +18,12 @@ def dict(self, *, no_obj=True, **kwargs): return super().model_dump(**kwargs) +class NodeLimitSettings(BaseModel): + data_limit: int | None = Field(ge=0, default=None) + data_limit_reset_strategy: DataLimitResetStrategy = Field(default=DataLimitResetStrategy.no_reset) + reset_time: int = Field(default=-1) + + class UserTemplate(BaseModel): name: str | None = None data_limit: int | None = Field(ge=0, default=None, description="data_limit can be 0 or greater") @@ -28,6 +34,10 @@ class UserTemplate(BaseModel): username_suffix: str | None = Field(max_length=20, default=None) group_ids: list[int] extra_settings: ExtraSettings | None = None + node_user_limits: dict[int, NodeLimitSettings | int] | None = Field( + default=None, + description="Per-node data limits: {node_id: limit_bytes or NodeLimitSettings}" + ) status: UserStatusCreate | None = None reset_usages: bool | None = None on_hold_timeout: int | None = None From 8a114f631ccefddd9b3477b577f3ce78e99a27ba Mon Sep 17 00:00:00 2001 From: Rerowros Date: Sat, 17 Jan 2026 21:32:36 +0700 Subject: [PATCH 05/11] feat(crud): add CRUD operations for per-node user limits - Add node_user_limit.py with get, create, modify, delete, upsert functions - Add reset_user_node_usage to user.py for per-node usage reset - Update user create to apply Node.user_data_limit defaults - Add node_user_limits field handling to user_template --- app/db/crud/node.py | 2 + app/db/crud/node_user_limit.py | 236 +++++++++++++++++++++++++++++++++ app/db/crud/user.py | 78 +++++++++++ app/db/crud/user_template.py | 11 +- 4 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 app/db/crud/node_user_limit.py diff --git a/app/db/crud/node.py b/app/db/crud/node.py index 48b4a6806..eb36a3005 100644 --- a/app/db/crud/node.py +++ b/app/db/crud/node.py @@ -13,6 +13,7 @@ NodeStatus, NodeUsage, NodeUsageResetLogs, + NodeUserLimit, NodeUserUsage, ) from app.db.compiles_types import DateDiff @@ -274,6 +275,7 @@ async def remove_node(db: AsyncSession, db_node: Node) -> None: # Remove dependent rows explicitly to avoid ORM cascading overhead on large tables. await db.execute(delete(NodeUserUsage).where(NodeUserUsage.node_id == node_id)) + await db.execute(delete(NodeUserLimit).where(NodeUserLimit.node_id == node_id)) await db.execute(delete(NodeUsage).where(NodeUsage.node_id == node_id)) await db.execute(delete(NodeUsageResetLogs).where(NodeUsageResetLogs.node_id == node_id)) await db.execute(delete(NodeStat).where(NodeStat.node_id == node_id)) diff --git a/app/db/crud/node_user_limit.py b/app/db/crud/node_user_limit.py new file mode 100644 index 000000000..1331c4c1a --- /dev/null +++ b/app/db/crud/node_user_limit.py @@ -0,0 +1,236 @@ +from typing import Optional + +from sqlalchemy import and_, delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import NodeUserLimit, NodeUserUsage +from app.models.node_user_limit import NodeUserLimitCreate + + +async def get_node_user_limit( + db: AsyncSession, user_id: int, node_id: int +) -> Optional[NodeUserLimit]: + """ + Retrieves a specific node user limit by user_id and node_id. + + Args: + db (AsyncSession): The database session. + user_id (int): The user ID. + node_id (int): The node ID. + + Returns: + Optional[NodeUserLimit]: The NodeUserLimit object if found, None otherwise. + """ + stmt = select(NodeUserLimit).where( + and_(NodeUserLimit.user_id == user_id, NodeUserLimit.node_id == node_id) + ) + return (await db.execute(stmt)).scalar_one_or_none() + + +async def get_node_user_limit_by_id(db: AsyncSession, limit_id: int) -> Optional[NodeUserLimit]: + """ + Retrieves a node user limit by its ID. + + Args: + db (AsyncSession): The database session. + limit_id (int): The limit ID. + + Returns: + Optional[NodeUserLimit]: The NodeUserLimit object if found, None otherwise. + """ + stmt = select(NodeUserLimit).where(NodeUserLimit.id == limit_id) + return (await db.execute(stmt)).scalar_one_or_none() + + +async def get_user_limits_for_node(db: AsyncSession, node_id: int) -> list[NodeUserLimit]: + """ + Retrieves all user limits for a specific node. + + Args: + db (AsyncSession): The database session. + node_id (int): The node ID. + + Returns: + list[NodeUserLimit]: List of NodeUserLimit objects for the node. + """ + stmt = select(NodeUserLimit).where(NodeUserLimit.node_id == node_id) + return list((await db.execute(stmt)).scalars().all()) + + +async def get_node_limits_for_user(db: AsyncSession, user_id: int) -> list[NodeUserLimit]: + """ + Retrieves all node limits for a specific user. + + Args: + db (AsyncSession): The database session. + user_id (int): The user ID. + + Returns: + list[NodeUserLimit]: List of NodeUserLimit objects for the user. + """ + stmt = select(NodeUserLimit).where(NodeUserLimit.user_id == user_id) + return list((await db.execute(stmt)).scalars().all()) + + +async def create_node_user_limit( + db: AsyncSession, limit: NodeUserLimitCreate +) -> NodeUserLimit: + """ + Creates a new node user limit. + + Args: + db (AsyncSession): The database session. + limit (NodeUserLimitCreate): The limit creation model. + + Returns: + NodeUserLimit: The newly created NodeUserLimit object. + """ + db_limit = NodeUserLimit(**limit.model_dump()) + db.add(db_limit) + await db.commit() + await db.refresh(db_limit) + return db_limit + + +async def upsert_node_user_limit( + db: AsyncSession, limit: NodeUserLimitCreate +) -> NodeUserLimit: + """ + Creates or updates a node user limit (upsert). + If limit exists for user_id/node_id, updates it; otherwise creates new. + + Args: + db (AsyncSession): The database session. + limit (NodeUserLimitCreate): The limit data. + + Returns: + NodeUserLimit: The created or updated NodeUserLimit object. + """ + existing = await get_node_user_limit(db, limit.user_id, limit.node_id) + + if existing: + existing.data_limit = limit.data_limit + existing.data_limit_reset_strategy = limit.data_limit_reset_strategy + existing.reset_time = limit.reset_time + await db.commit() + await db.refresh(existing) + return existing + else: + return await create_node_user_limit(db, limit) + + +async def modify_node_user_limit( + db: AsyncSession, db_limit: NodeUserLimit, data_limit: int, + data_limit_reset_strategy: str | None = None, reset_time: int | None = None +) -> NodeUserLimit: + """ + Modifies an existing node user limit. + + Args: + db (AsyncSession): The database session. + db_limit (NodeUserLimit): The NodeUserLimit object to modify. + data_limit (int): The new data limit value. + data_limit_reset_strategy (str | None): The reset strategy. + reset_time (int | None): The reset time. + + Returns: + NodeUserLimit: The modified NodeUserLimit object. + """ + db_limit.data_limit = data_limit + if data_limit_reset_strategy is not None: + db_limit.data_limit_reset_strategy = data_limit_reset_strategy + if reset_time is not None: + db_limit.reset_time = reset_time + await db.commit() + await db.refresh(db_limit) + return db_limit + + +async def remove_node_user_limit(db: AsyncSession, db_limit: NodeUserLimit) -> None: + """ + Removes a node user limit. + + Args: + db (AsyncSession): The database session. + db_limit (NodeUserLimit): The NodeUserLimit object to remove. + """ + await db.execute(delete(NodeUserLimit).where(NodeUserLimit.id == db_limit.id)) + await db.commit() + + +async def bulk_set_user_limits_for_node( + db: AsyncSession, node_id: int, user_limits: dict[int, int], + data_limit_reset_strategy: str = "no_reset", reset_time: int = -1 +) -> list[NodeUserLimit]: + """ + Bulk sets or updates user limits for a specific node. + + Args: + db (AsyncSession): The database session. + node_id (int): The node ID. + user_limits (dict[int, int]): Dictionary mapping user_id to data_limit. + data_limit_reset_strategy (str): Reset strategy to apply to all limits. + reset_time (int): Reset time to apply to all limits. + + Returns: + list[NodeUserLimit]: List of created/updated NodeUserLimit objects. + """ + result_limits = [] + + for user_id, data_limit in user_limits.items(): + # Check if limit already exists + existing_limit = await get_node_user_limit(db, user_id, node_id) + + if existing_limit: + # Update existing limit + existing_limit.data_limit = data_limit + existing_limit.data_limit_reset_strategy = data_limit_reset_strategy + existing_limit.reset_time = reset_time + result_limits.append(existing_limit) + else: + # Create new limit + new_limit = NodeUserLimit( + user_id=user_id, + node_id=node_id, + data_limit=data_limit, + data_limit_reset_strategy=data_limit_reset_strategy, + reset_time=reset_time + ) + db.add(new_limit) + result_limits.append(new_limit) + + await db.commit() + + # Refresh all limits + for limit in result_limits: + await db.refresh(limit) + + return result_limits +async def get_nodes_with_over_limit_users(db: AsyncSession) -> list[int]: + """ + Finds IDs of nodes that have at least one user who exceeded their per-node limit. + + Args: + db (AsyncSession): The database session. + + Returns: + list[int]: List of node IDs. + """ + from sqlalchemy import func + + stmt = ( + select(NodeUserLimit.node_id) + .join( + NodeUserUsage, + and_( + NodeUserLimit.user_id == NodeUserUsage.user_id, + NodeUserLimit.node_id == NodeUserUsage.node_id, + ), + ) + .where(NodeUserLimit.data_limit > 0) + .group_by(NodeUserLimit.node_id, NodeUserLimit.user_id, NodeUserLimit.data_limit) + .having(func.sum(NodeUserUsage.used_traffic) >= NodeUserLimit.data_limit) + .distinct() + ) + result = await db.execute(stmt) + return list(result.scalars().all()) diff --git a/app/db/crud/user.py b/app/db/crud/user.py index 6068046ad..3eed23de7 100644 --- a/app/db/crud/user.py +++ b/app/db/crud/user.py @@ -14,6 +14,7 @@ DataLimitResetStrategy, Group, NextPlan, + NodeUserLimit, NodeUserUsage, NotificationReminder, ReminderType, @@ -406,6 +407,10 @@ async def get_user_usages( node_id_val = row_dict.pop("node_id", node_id) if node_id_val not in stats: stats[node_id_val] = [] + # Convert period_start from string to datetime if needed + if "period_start" in row_dict and isinstance(row_dict["period_start"], str): + row_dict["period_start"] = datetime.fromisoformat(row_dict["period_start"]) + stats[node_id_val].append(UserUsageStat(**row_dict)) return UserUsageStatsList(period=period, start=start, end=end, stats=stats) @@ -502,6 +507,29 @@ async def create_user(db: AsyncSession, new_user: UserCreate, groups: list[Group await db.commit() await db.refresh(db_user) + # Apply per-node default limits from nodes that have user_data_limit configured + # This provides the lowest-priority default; template limits will override this later + from app.db.models import Node, NodeUserLimit + + # Get all nodes with user_data_limit configured + nodes_stmt = select(Node).where(Node.user_data_limit > 0) + nodes_result = await db.execute(nodes_stmt) + nodes = nodes_result.scalars().all() + + for node in nodes: + # Create default limit for new user based on Node.user_data_limit + new_limit = NodeUserLimit( + user_id=db_user.id, + node_id=node.id, + data_limit=node.user_data_limit, + data_limit_reset_strategy=node.user_data_limit_reset_strategy, + reset_time=node.user_reset_time + ) + db.add(new_limit) + + await db.commit() + await db.refresh(db_user) + await load_user_attrs(db_user) return db_user @@ -553,6 +581,7 @@ async def _delete_user_dependencies(db: AsyncSession, user_ids: list[int]): return await db.execute(delete(NodeUserUsage).where(NodeUserUsage.user_id.in_(user_ids))) + await db.execute(delete(NodeUserLimit).where(NodeUserLimit.user_id.in_(user_ids))) await db.execute(delete(NotificationReminder).where(NotificationReminder.user_id.in_(user_ids))) await db.execute(delete(UserSubscriptionUpdate).where(UserSubscriptionUpdate.user_id.in_(user_ids))) await db.execute(delete(UserUsageResetLogs).where(UserUsageResetLogs.user_id.in_(user_ids))) @@ -560,6 +589,7 @@ async def _delete_user_dependencies(db: AsyncSession, user_ids: list[int]): await db.execute(users_groups_association.delete().where(users_groups_association.c.user_id.in_(user_ids))) + async def remove_user(db: AsyncSession, db_user: User) -> User: """ Removes a user from the database. @@ -753,6 +783,49 @@ async def bulk_reset_user_data_usage(db: AsyncSession, users: list[User]) -> lis return users +async def reset_user_node_usage(db: AsyncSession, db_user: User, node_ids: list[int]) -> User: + """ + Resets the data usage of a user for specific nodes. + + Args: + db (AsyncSession): Database session. + db_user (User): The user object. + node_ids (list[int]): List of node IDs to reset usage for. + + Returns: + User: The updated user object. + """ + if not node_ids: + return db_user + + # Delete usage records for these nodes + await db.execute( + delete(NodeUserUsage) + .where(NodeUserUsage.user_id == db_user.id) + .where(NodeUserUsage.node_id.in_(node_ids)) + ) + + # Recalculate total used traffic from remaining records + # If no records left, sum will be None, so we coalesce to 0 + result = await db.execute( + select(func.coalesce(func.sum(NodeUserUsage.used_traffic), 0)) + .where(NodeUserUsage.user_id == db_user.id) + ) + total_usage = result.scalar() + + db_user.used_traffic = total_usage + + # If user was limited due to usage, check if we can reactivate + if db_user.status not in [UserStatus.expired, UserStatus.disabled, UserStatus.on_hold]: + if not db_user.data_limit or db_user.used_traffic < db_user.data_limit: + db_user.status = UserStatus.active + + await db.commit() + await db.refresh(db_user) + await load_user_attrs(db_user) + return db_user + + async def reset_user_by_next(db: AsyncSession, db_user: User) -> User: """ Resets the data usage of a user based on next user. @@ -1013,6 +1086,11 @@ async def get_all_users_usages( for row in result.mappings(): row_dict = dict(row) node_id_val = row_dict.pop("node_id", node_id) + + # Convert period_start from string to datetime if needed + if "period_start" in row_dict and isinstance(row_dict["period_start"], str): + row_dict["period_start"] = datetime.fromisoformat(row_dict["period_start"]) + if node_id_val not in stats: stats[node_id_val] = [] stats[node_id_val].append(UserUsageStat(**row_dict)) diff --git a/app/db/crud/user_template.py b/app/db/crud/user_template.py index 6f26f5c34..11e5e6aaa 100644 --- a/app/db/crud/user_template.py +++ b/app/db/crud/user_template.py @@ -11,6 +11,7 @@ async def load_user_template_attrs(template: UserTemplate): await template.awaitable_attrs.groups + await template.awaitable_attrs.node_user_limits async def create_user_template(db: AsyncSession, user_template: UserTemplateCreate) -> UserTemplate: @@ -24,7 +25,6 @@ async def create_user_template(db: AsyncSession, user_template: UserTemplateCrea Returns: UserTemplate: The created user template object. """ - db_user_template = UserTemplate( name=user_template.name, data_limit=user_template.data_limit, @@ -38,6 +38,11 @@ async def create_user_template(db: AsyncSession, user_template: UserTemplateCrea on_hold_timeout=user_template.on_hold_timeout, is_disabled=user_template.is_disabled, data_limit_reset_strategy=user_template.data_limit_reset_strategy, + node_user_limits={ + k: (v.dict() if hasattr(v, "dict") else v) for k, v in user_template.node_user_limits.items() + } + if user_template.node_user_limits + else None, ) db.add(db_user_template) @@ -85,6 +90,10 @@ async def modify_user_template( db_user_template.is_disabled = modified_user_template.is_disabled if modified_user_template.data_limit_reset_strategy is not None: db_user_template.data_limit_reset_strategy = modified_user_template.data_limit_reset_strategy + if modified_user_template.node_user_limits is not None: + db_user_template.node_user_limits = { + k: (v.dict() if hasattr(v, "dict") else v) for k, v in modified_user_template.node_user_limits.items() + } await db.commit() await db.refresh(db_user_template) From 0610be746cdf9bed3bbbaf1fd65b394e854ae707 Mon Sep 17 00:00:00 2001 From: Rerowros Date: Sat, 17 Jan 2026 21:32:59 +0700 Subject: [PATCH 06/11] feat(operation): add per-node limits business logic - Add _apply_node_limits_from_template using upsert for template priority - Add _propagate_user_data_limit_change for Node.user_data_limit updates - Add reset_user_node_usage operation method - Fix MissingGreenlet errors with awaitable_attrs and proper refresh - Update core_users to filter users by per-node limits --- app/jobs/review_users.py | 26 ++++++ app/node/user.py | 35 +++++++- app/operation/node.py | 143 +++++++++++++++++++++++++++++-- app/operation/user.py | 176 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 369 insertions(+), 11 deletions(-) diff --git a/app/jobs/review_users.py b/app/jobs/review_users.py index 4f38b380d..14b91eedb 100644 --- a/app/jobs/review_users.py +++ b/app/jobs/review_users.py @@ -17,6 +17,8 @@ update_users_status, bulk_create_notification_reminders, ) +from app.db.crud.node_user_limit import get_nodes_with_over_limit_users +from app.operation.node import NodeOperation from app.operation import OperatorType from app.operation.user import UserOperation from app.jobs.dependencies import SYSTEM_ADMIN @@ -29,6 +31,7 @@ logger = get_logger("review-users") user_operator = UserOperation(operator_type=OperatorType.SYSTEM) +node_operator = NodeOperation(operator_type=OperatorType.SYSTEM) async def reset_user_by_next_report(db: AsyncSession, db_user: User): @@ -153,6 +156,21 @@ async def days_left_notification_job(): await notification.wh.bulk_notify(webhook_data) +async def node_user_limit_review_job(): + """ + Review per-user per-node traffic limits and sync nodes if necessary. + """ + async with GetDB() as db: + node_ids = await get_nodes_with_over_limit_users(db) + if node_ids: + logger.info(f"Nodes needing sync due to per-node limits: {node_ids}") + for node_id in node_ids: + try: + await node_operator.sync_node_users(db, node_id) + except Exception as e: + logger.error(f"Failed to sync node {node_id} in limit review job: {e}") + + now = dt.now(tz.utc) interval = int(JOB_REVIEW_USERS_INTERVAL / 5) @@ -192,3 +210,11 @@ async def days_left_notification_job(): max_instances=1, start_date=now + td(seconds=interval * 4), ) +scheduler.add_job( + node_user_limit_review_job, + "interval", + seconds=JOB_REVIEW_USERS_INTERVAL, + coalesce=True, + max_instances=1, + start_date=now + td(seconds=interval * 4 + 10), +) diff --git a/app/node/user.py b/app/node/user.py index 8e489124e..fc0879bc2 100644 --- a/app/node/user.py +++ b/app/node/user.py @@ -2,7 +2,16 @@ from sqlalchemy import and_, func, select from app.db import AsyncSession -from app.db.models import Group, ProxyInbound, User, UserStatus, inbounds_groups_association, users_groups_association +from app.db.models import ( + Group, + NodeUserLimit, + NodeUserUsage, + ProxyInbound, + User, + UserStatus, + inbounds_groups_association, + users_groups_association, +) def serialize_user_for_node(id: int, username: str, user_settings: dict, inbounds: list[str] = None): @@ -25,7 +34,7 @@ def serialize_user_for_node(id: int, username: str, user_settings: dict, inbound ) -async def core_users(db: AsyncSession): +async def core_users(db: AsyncSession, node_id: int | None = None): dialect = db.bind.dialect.name # Use dialect-specific aggregation and grouping @@ -53,9 +62,29 @@ async def core_users(db: AsyncSession): .outerjoin(inbounds_groups_association, Group.id == inbounds_groups_association.c.group_id) .outerjoin(ProxyInbound, inbounds_groups_association.c.inbound_id == ProxyInbound.id) .where(User.status.in_([UserStatus.active, UserStatus.on_hold])) - .group_by(User.id) ) + # Filter by node-specific traffic limits if node_id is provided + if node_id: + # Subquery to find users who exceeded their limit on this node + over_limit_subquery = ( + select(NodeUserLimit.user_id) + .join( + NodeUserUsage, + and_( + NodeUserLimit.user_id == NodeUserUsage.user_id, + NodeUserLimit.node_id == NodeUserUsage.node_id, + ), + ) + .where(NodeUserLimit.node_id == node_id) + .where(NodeUserLimit.data_limit > 0) + .group_by(NodeUserLimit.user_id, NodeUserLimit.data_limit) + .having(func.sum(NodeUserUsage.used_traffic) >= NodeUserLimit.data_limit) + ) + stmt = stmt.where(User.id.notin_(over_limit_subquery)) + + stmt = stmt.group_by(User.id) + results = (await db.execute(stmt)).all() bridge_users: list = [] diff --git a/app/operation/node.py b/app/operation/node.py index 55be235fb..0205dc936 100644 --- a/app/operation/node.py +++ b/app/operation/node.py @@ -134,13 +134,19 @@ async def _update_single_node_status( asyncio.create_task(notification.error_node(node_notif)) @staticmethod - async def connect_node(db_node: Node, users: list) -> dict | None: + async def connect_node( + db_node: Node, + users: list, + limit_enforcer_config: dict | None = None, + ) -> dict | None: """ Connect to a node and return status result (does NOT update database). Args: db_node (Node): Node object from database. users (list): Pre-fetched core users list. + limit_enforcer_config (dict): Optional config for real-time limit enforcement. + Keys: panel_api_url, limit_check_interval, limit_refresh_interval Returns: dict: {node_id, status, message, xray_version, node_version, old_status} @@ -155,6 +161,13 @@ async def connect_node(db_node: Node, users: list) -> dict | None: core = await core_manager.get_core(db_node.core_config_id if db_node.core_config_id else 1) + # Prepare limit enforcer parameters + le_config = limit_enforcer_config or {} + node_id = db_node.id if le_config.get("enabled") else 0 + panel_api_url = le_config.get("panel_api_url", "") if le_config.get("enabled") else "" + limit_check_interval = le_config.get("limit_check_interval", 30) + limit_refresh_interval = le_config.get("limit_refresh_interval", 60) + try: info = await pg_node.start( config=core.to_str(), @@ -162,6 +175,10 @@ async def connect_node(db_node: Node, users: list) -> dict | None: users=users, keep_alive=db_node.keep_alive, exclude_inbounds=core.exclude_inbound_tags, + node_id=node_id, + panel_api_url=panel_api_url, + limit_check_interval=limit_check_interval, + limit_refresh_interval=limit_refresh_interval, ) logger.info(f'Connected to "{db_node.name}" node v{info.node_version}, xray run on v{info.core_version}') @@ -223,11 +240,25 @@ async def modify_node( if modified_node.core_config_id is not None: await self.get_validated_core_config(db, modified_node.core_config_id) + # Track if user_data_limit is being changed + old_user_data_limit = db_node.user_data_limit + new_user_data_limit = modified_node.user_data_limit + try: db_node = await modify_node(db, db_node, modified_node) except IntegrityError: await self.raise_error(message=f'Node "{db_node.name}" already exists', code=409, db=db) + # If user_data_limit changed, update existing users without individual limits + if new_user_data_limit is not None and new_user_data_limit != old_user_data_limit: + await self._propagate_user_data_limit_change( + db, db_node.id, new_user_data_limit, + db_node.user_data_limit_reset_strategy, + db_node.user_reset_time + ) + # Refresh db_node after commit in propagate to avoid MissingGreenlet + await db.refresh(db_node) + if db_node.status in (NodeStatus.disabled, NodeStatus.limited): await self.disconnect_single_node(db_node.id) else: @@ -250,6 +281,56 @@ async def modify_node( return node + async def _propagate_user_data_limit_change( + self, db: AsyncSession, node_id: int, data_limit: int, + reset_strategy, reset_time: int + ): + """ + Update node_user_limits for users without individual limits when Node.user_data_limit changes. + Users with individual limits (set via user edit or templates) are not affected. + """ + from app.db.crud import node_user_limit as node_limit_crud + from app.db.models import User, NodeUserLimit + from sqlalchemy import select + + # Ensure strategy is string + if hasattr(reset_strategy, "value"): + reset_strategy = reset_strategy.value + + # Get all active users + users_stmt = select(User.id) + users_result = await db.execute(users_stmt) + all_user_ids = set(row[0] for row in users_result.fetchall()) + + if not all_user_ids: + return + + # Get users who already have limits for this node + existing_limits = await node_limit_crud.get_user_limits_for_node(db, node_id) + users_with_limits = {limit.user_id for limit in existing_limits} + + # Users without limits for this node get the new node default + users_to_update = all_user_ids - users_with_limits + + if data_limit > 0: + # Create new limits for users who don't have one + for user_id in users_to_update: + new_limit = NodeUserLimit( + user_id=user_id, + node_id=node_id, + data_limit=data_limit, + data_limit_reset_strategy=reset_strategy, + reset_time=reset_time + ) + db.add(new_limit) + + await db.commit() + + logger.info( + f"Propagated user_data_limit={data_limit} to {len(users_to_update)} users " + f"without individual limits on node {node_id}" + ) + async def remove_node(self, db: AsyncSession, node_id: Node, admin: AdminDetails) -> None: db_node: Node = await self.get_validated_node(db=db, node_id=node_id) node_response = NodeResponse.model_validate(db_node) @@ -304,10 +385,18 @@ async def connect_nodes_bulk( db (AsyncSession): Database session. nodes (list[Node]): List of nodes to connect. """ + from app.db.crud.settings import get_settings + from app.models.settings import General, Subscription + if not nodes: return - # Fetch users ONCE for all nodes + # Fetch users ONCE for all nodes (without node_id filtering, as it's bulk) + # However, for per-node enforcement, they will be filtered later if needed. + # But wait, connect_nodes_bulk connects multiple nodes. + # If we use a shared users list, we might include users over limit for some nodes but not others. + # Actually, connect_nodes_bulk is usually for startup. + # Let's keep it as is for bulk or handle it inside the loop. users = await core_users(db=db) # Calculate max_message_size based on active users count (once for all nodes) @@ -315,6 +404,23 @@ async def connect_nodes_bulk( active_users_count = user_counts.get(UserStatus.active.value, 0) max_message_size = calculate_max_message_size(active_users_count) + # Get limit enforcer config from settings (once for all nodes) + limit_enforcer_config = None + try: + settings = await get_settings(db) + if settings and settings.general and settings.subscription: + general = General.model_validate(settings.general) + subscription = Subscription.model_validate(settings.subscription) + if general.limit_enforcer_enabled and subscription.url_prefix: + limit_enforcer_config = { + "enabled": True, + "panel_api_url": subscription.url_prefix.rstrip("/"), + "limit_check_interval": general.limit_check_interval, + "limit_refresh_interval": general.limit_refresh_interval, + } + except Exception as e: + logger.warning(f"Failed to get limit enforcer config: {e}") + async def connect_single(node: Node) -> dict | None: if node is None or node.status in (NodeStatus.disabled, NodeStatus.limited): return @@ -331,7 +437,9 @@ async def connect_single(node: Node) -> dict | None: "old_status": node.status, } - return await self.connect_node(node, users) + # For bulk, we still want to filter per node + node_users = await core_users(db=db, node_id=node.id) + return await self.connect_node(node, node_users, limit_enforcer_config) results = await asyncio.gather(*[connect_single(node) for node in nodes]) @@ -384,12 +492,15 @@ async def connect_single_node(self, db: AsyncSession, node_id: int) -> None: db (AsyncSession): Database session. node_id (int): ID of the node to connect. """ + from app.db.crud.settings import get_settings + from app.models.settings import General, Subscription + db_node = await get_node_by_id(db, node_id) if db_node is None or db_node.status in (NodeStatus.disabled, NodeStatus.limited): return - # Get core users once - users = await core_users(db=db) + # Get users for this specific node with limit filtering + users = await core_users(db=db, node_id=node_id) # Calculate max_message_size based on active users count user_counts = await get_users_count_by_status(db, [UserStatus.active]) @@ -417,8 +528,25 @@ async def connect_single_node(self, db: AsyncSession, node_id: int) -> None: asyncio.create_task(notification.error_node(node_notif)) return + # Get limit enforcer config from settings + limit_enforcer_config = None + try: + settings = await get_settings(db) + if settings and settings.general and settings.subscription: + general = General.model_validate(settings.general) + subscription = Subscription.model_validate(settings.subscription) + if general.limit_enforcer_enabled and subscription.url_prefix: + limit_enforcer_config = { + "enabled": True, + "panel_api_url": subscription.url_prefix.rstrip("/"), + "limit_check_interval": general.limit_check_interval, + "limit_refresh_interval": general.limit_refresh_interval, + } + except Exception as e: + logger.warning(f"Failed to get limit enforcer config: {e}") + # Connect the node - result = await NodeOperation.connect_node(db_node, users) + result = await NodeOperation.connect_node(db_node, users, limit_enforcer_config) if not result: return @@ -626,7 +754,8 @@ async def sync_node_users(self, db: AsyncSession, node_id: int, flush_users: boo await self.raise_error(message="Node is not connected", code=409) try: - await pg_node.sync_users(await core_users(db=db), flush_pending=flush_users) + users = await core_users(db=db, node_id=node_id) + await pg_node.sync_users(users, flush_pending=flush_users) except NodeAPIError as e: await update_node_status(db=db, db_node=db_node, status=NodeStatus.error, message=e.detail) await self.raise_error(message=e.detail, code=e.code) diff --git a/app/operation/user.py b/app/operation/user.py index 22e4e3c57..0a2255375 100644 --- a/app/operation/user.py +++ b/app/operation/user.py @@ -35,6 +35,7 @@ reset_user_data_usage, revoke_user_sub, set_owner, + reset_user_node_usage, ) from app.db.models import User, UserStatus, UserTemplate from app.models.admin import AdminDetails @@ -50,6 +51,8 @@ UserCreate, UserModify, UsernameGenerationStrategy, + UserNodeTraffic, + UserNodeTrafficResponse, UserNotificationResponse, UserResponse, UsersResponse, @@ -295,6 +298,24 @@ async def reset_user_data_usage(self, db: AsyncSession, username: str, admin: Ad return await self._reset_user_data_usage(db, db_user, admin) + async def _reset_user_node_usage(self, db: AsyncSession, db_user: User, node_ids: list[int], admin: AdminDetails): + old_status = db_user.status + + db_user = await reset_user_node_usage(db=db, db_user=db_user, node_ids=node_ids) + user = await self.update_user(db_user) + + if user.status != old_status: + asyncio.create_task(notification.user_status_change(user, admin)) + + logger.info(f'User "{db_user.username}" usage for nodes {node_ids} was reset by admin "{admin.username}"') + + return user + + async def reset_user_node_usage(self, db: AsyncSession, username: str, node_ids: list[int], admin: AdminDetails): + db_user = await self.get_validated_user(db, username, admin) + + return await self._reset_user_node_usage(db, db_user, node_ids, admin) + async def revoke_user_sub(self, db: AsyncSession, username: str, admin: AdminDetails) -> UserResponse: db_user = await self.get_validated_user(db, username, admin) @@ -371,6 +392,84 @@ async def get_user(self, db: AsyncSession, username: str, admin: AdminDetails) - db_user = await self.get_validated_user(db, username, admin) return await self.validate_user(db_user) + async def get_user_node_traffic( + self, db: AsyncSession, username: str, admin: AdminDetails + ) -> UserNodeTrafficResponse: + """ + Get traffic and limit breakdown by node for a specific user. + + Returns aggregated traffic usage per node along with configured limits from NodeUserLimit. + """ + from sqlalchemy import func, select + from app.db.models import Node, NodeUserLimit, NodeUserUsage + + # Validate user and permissions + db_user = await self.get_validated_user(db, username, admin) + + # Query to aggregate traffic usage per node + # SELECT node_id, SUM(used_traffic) as total_traffic + # FROM node_user_usages + # WHERE user_id = ? + # GROUP BY node_id + traffic_query = ( + select( + NodeUserUsage.node_id, + func.sum(NodeUserUsage.used_traffic).label("total_traffic") + ) + .where(NodeUserUsage.user_id == db_user.id) + .group_by(NodeUserUsage.node_id) + ) + + traffic_result = await db.execute(traffic_query) + traffic_by_node = {row.node_id: row.total_traffic for row in traffic_result} + + # Query to get all node limits for this user + limits_query = ( + select(NodeUserLimit) + .where(NodeUserLimit.user_id == db_user.id) + ) + + limits_result = await db.execute(limits_query) + limits_by_node = {limit.node_id: limit for limit in limits_result.scalars()} + + # Get all nodes that either have traffic or limits + all_node_ids = set(traffic_by_node.keys()) | set(limits_by_node.keys()) + + if not all_node_ids: + # No traffic and no limits, return empty + return UserNodeTrafficResponse(nodes=[]) + + # Fetch node information + nodes_query = select(Node).where(Node.id.in_(all_node_ids)) + nodes_result = await db.execute(nodes_query) + nodes_by_id = {node.id: node for node in nodes_result.scalars()} + + # Build response + node_traffic_list = [] + for node_id in all_node_ids: + node = nodes_by_id.get(node_id) + if not node: + # Skip if node was deleted + continue + + limit_record = limits_by_node.get(node_id) + used_traffic = traffic_by_node.get(node_id, 0) + + node_traffic_list.append( + UserNodeTraffic( + node_id=node.id, + node_name=node.name, + used_traffic=used_traffic, + data_limit=limit_record.data_limit if limit_record else None, + has_limit=limit_record is not None + ) + ) + + # Sort by node_id for consistent ordering + node_traffic_list.sort(key=lambda x: x.node_id) + + return UserNodeTrafficResponse(nodes=node_traffic_list) + async def get_user_by_id(self, db: AsyncSession, user_id: int, admin: AdminDetails) -> UserNotificationResponse: db_user = await self.get_validated_user_by_id(db, user_id, admin) return await self.validate_user(db_user) @@ -574,6 +673,55 @@ def _build_user_create_from_template( return new_user + + async def _apply_node_limits_from_template(self, db: AsyncSession, db_user: User, node_user_limits: dict, data_limit_reset_strategy): + """Apply per-node limits if configured in template. Uses upsert to override node defaults.""" + if not node_user_limits: + return + + from app.db.crud import node_user_limit as node_limit_crud + from app.models.node_user_limit import NodeUserLimitCreate + + for node_id, limit_info in node_user_limits.items(): + try: + # Handle Pydantic model, dict, or integer + if hasattr(limit_info, "data_limit"): + limit_bytes = limit_info.data_limit + strategy = limit_info.data_limit_reset_strategy + reset_time = limit_info.reset_time + elif isinstance(limit_info, dict): + limit_bytes = limit_info.get("data_limit") + strategy = limit_info.get("data_limit_reset_strategy") or data_limit_reset_strategy + reset_time = limit_info.get("reset_time") + if reset_time is None: + reset_time = -1 + else: + limit_bytes = limit_info + strategy = data_limit_reset_strategy + reset_time = -1 + + # Ensure strategy is string + if hasattr(strategy, "value"): + strategy = strategy.value + + if limit_bytes is not None: + # Use upsert to override any existing node default limits + await node_limit_crud.upsert_node_user_limit( + db, + NodeUserLimitCreate( + user_id=db_user.id, + node_id=int(node_id), + data_limit=limit_bytes, + data_limit_reset_strategy=strategy, + reset_time=reset_time + ) + ) + except Exception as e: + logger.warning( + f"Failed to apply template node limit for user {db_user.username} " + f"on node {node_id}: {str(e)}" + ) + async def create_user_from_template( self, db: AsyncSession, new_template_user: CreateUserFromTemplate, admin: AdminDetails ) -> UserResponse: @@ -587,7 +735,25 @@ async def create_user_from_template( except HTTPException as exc: raise exc - return await self.create_user(db, new_user, admin) + # Create the user + user_response = await self.create_user(db, new_user, admin) + + # Apply per-node limits if configured in template + # Load node_user_limits eagerly to avoid MissingGreenlet errors + node_user_limits = await user_template.awaitable_attrs.node_user_limits + if node_user_limits: + from app.db.crud.user import get_user + db_user = await get_user(db, user_response.username) + if db_user: + try: + await self._apply_node_limits_from_template( + db, db_user, node_user_limits, user_template.data_limit_reset_strategy + ) + except Exception as e: + logger.error(f"Error applying template node limits to user {user_response.username}: {e}", exc_info=True) + # We do not re-raise because the user is already created successfully. + + return user_response async def modify_user_with_template( self, db: AsyncSession, username: str, modified_template: ModifyUserByTemplate, admin: AdminDetails @@ -658,6 +824,14 @@ def builder(username: str): db_admin = await get_admin(db, admin.username) subscription_urls = await self._persist_bulk_users(db, admin, db_admin, users_to_create, groups) + # Apply per-node limits if configured in template + if user_template.node_user_limits and users_to_create: + from app.db.crud.user import get_user + for user in users_to_create: + db_user = await get_user(db, user.username) + if db_user: + await self._apply_node_limits_from_template(db, db_user, user_template) + return BulkUsersCreateResponse(subscription_urls=subscription_urls, created=len(subscription_urls)) async def bulk_modify_expire(self, db: AsyncSession, bulk_model: BulkUser): From 9cd735f71ac6809c39df699036ea8566229f1e03 Mon Sep 17 00:00:00 2001 From: Rerowros Date: Sat, 17 Jan 2026 21:33:21 +0700 Subject: [PATCH 07/11] feat(api): add per-node limits REST API endpoints - Add /api/node-user-limits router with CRUD endpoints - Add /api/user/{username}/reset-usage-by-node endpoint - Add /api/user/{username}/node-limits and node-traffic endpoints - Add ResetNodeUsageRequest model - Add limit_enforcer settings to General model --- app/models/settings.py | 5 + app/models/user.py | 22 +++ app/routers/__init__.py | 16 +- app/routers/node_user_limit.py | 281 +++++++++++++++++++++++++++++++++ app/routers/user.py | 64 ++++++++ app/utils/helpers.py | 14 +- 6 files changed, 398 insertions(+), 4 deletions(-) create mode 100644 app/routers/node_user_limit.py diff --git a/app/models/settings.py b/app/models/settings.py index 525a9da6e..fb74a0e01 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -283,6 +283,11 @@ def validate_recommended_apps(cls, v: list[Application]) -> list[Application]: class General(BaseModel): default_flow: XTLSFlows = Field(default=XTLSFlows.NONE) default_method: ShadowsocksMethods = Field(default=ShadowsocksMethods.CHACHA20_POLY1305) + + # Real-time limit enforcement settings + limit_enforcer_enabled: bool = Field(default=False) + limit_check_interval: int = Field(default=30, ge=10, le=300) # seconds + limit_refresh_interval: int = Field(default=60, ge=30, le=600) # seconds class SettingsSchema(BaseModel): diff --git a/app/models/user.py b/app/models/user.py index 4d9c6cba4..0209c8689 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -247,3 +247,25 @@ def validate_username_strategy(self): class BulkUsersCreateResponse(BaseModel): subscription_urls: list[str] = Field(default_factory=list) created: int = Field(default=0) + + +class UserNodeTraffic(BaseModel): + """Traffic and limit information for a user on a specific node""" + + node_id: int + node_name: str + used_traffic: int = Field(description="Total bytes used on this node") + data_limit: int | None = Field(default=None, description="Data limit in bytes for this node, if configured") + has_limit: bool = Field(default=False, description="Whether user has a specific limit on this node") + + model_config = ConfigDict(from_attributes=True) + + +class UserNodeTrafficResponse(BaseModel): + """Response containing traffic breakdown by node for a user""" + + nodes: list[UserNodeTraffic] = Field(default_factory=list) + + +class ResetNodeUsageRequest(BaseModel): + node_ids: list[int] diff --git a/app/routers/__init__.py b/app/routers/__init__.py index ccf06c431..1d4e962da 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -1,6 +1,19 @@ from fastapi import APIRouter -from . import admin, core, group, home, host, node, settings, subscription, system, user, user_template +from . import ( + admin, + core, + group, + home, + host, + node, + node_user_limit, + settings, + subscription, + system, + user, + user_template, +) api_router = APIRouter() @@ -13,6 +26,7 @@ core.router, host.router, node.router, + node_user_limit.router, user.router, subscription.router, user_template.router, diff --git a/app/routers/node_user_limit.py b/app/routers/node_user_limit.py new file mode 100644 index 000000000..c2fb22c24 --- /dev/null +++ b/app/routers/node_user_limit.py @@ -0,0 +1,281 @@ +import hashlib +import json + +from fastapi import APIRouter, Depends, Header, HTTPException, Response, status + +from app.db import AsyncSession, get_db +from app.db.crud import node_user_limit as crud +from app.db.crud.node import get_node_by_id +from app.db.crud.user import get_user_by_id +from app.models.admin import AdminDetails +from app.models.node_user_limit import ( + BulkSetLimitRequest, + NodeUserLimitCreate, + NodeUserLimitModify, + NodeUserLimitResponse, + NodeUserLimitsResponse, +) +from app.utils import responses + +from .authentication import check_sudo_admin + +router = APIRouter( + tags=["Node User Limits"], + prefix="/api/node-user-limits", + responses={401: responses._401, 403: responses._403}, +) + + +@router.post( + "", + response_model=NodeUserLimitResponse, + status_code=status.HTTP_201_CREATED, + responses={404: responses._404, 409: responses._409}, +) +async def create_node_user_limit( + limit: NodeUserLimitCreate, + db: AsyncSession = Depends(get_db), + _: AdminDetails = Depends(check_sudo_admin), +): + """ + Create a new per-user per-node traffic limit. + + Only accessible to sudo admins. + """ + # Validate user exists + user = await get_user_by_id(db, limit.user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Validate node exists + node = await get_node_by_id(db, limit.node_id) + if not node: + raise HTTPException(status_code=404, detail="Node not found") + + # Check if limit already exists + existing = await crud.get_node_user_limit(db, limit.user_id, limit.node_id) + if existing: + raise HTTPException( + status_code=409, + detail=f"Limit already exists for user {limit.user_id} on node {limit.node_id}", + ) + + return await crud.create_node_user_limit(db, limit) + + +@router.get( + "/user/{user_id}", + response_model=NodeUserLimitsResponse, + responses={404: responses._404}, +) +async def get_user_limits( + user_id: int, + db: AsyncSession = Depends(get_db), + _: AdminDetails = Depends(check_sudo_admin), +): + """ + Get all node limits for a specific user. + + Returns a list of all per-node traffic limits configured for the user. + """ + # Validate user exists + user = await get_user_by_id(db, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + limits = await crud.get_node_limits_for_user(db, user_id) + return NodeUserLimitsResponse(limits=limits, total=len(limits)) + + +@router.get( + "/node/{node_id}", + responses={404: responses._404, 304: {"description": "Not Modified"}}, +) +async def get_node_limits( + node_id: int, + response: Response, + db: AsyncSession = Depends(get_db), + _: AdminDetails = Depends(check_sudo_admin), + if_none_match: str | None = Header(None, alias="If-None-Match"), +): + """ + Get all user limits for a specific node. + + Returns a list of all per-user traffic limits configured for the node. + Supports ETag for conditional requests - returns 304 if data unchanged. + """ + # Validate node exists + node = await get_node_by_id(db, node_id) + if not node: + raise HTTPException(status_code=404, detail="Node not found") + + limits = await crud.get_user_limits_for_node(db, node_id) + + # Generate ETag from limits data + limits_data = [ + {"user_id": limit.user_id, "data_limit": limit.data_limit} + for limit in limits + ] + etag_content = json.dumps(limits_data, sort_keys=True) + etag = f'"{hashlib.md5(etag_content.encode()).hexdigest()}"' + + # Check if client has current version + if if_none_match and if_none_match == etag: + return Response(status_code=304) + + # Set response headers + response.headers["ETag"] = etag + response.headers["X-Limits-Version"] = etag.strip('"')[:8] # Short version for debugging + + return NodeUserLimitsResponse(limits=limits, total=len(limits)) + + +@router.get( + "/{limit_id}", + response_model=NodeUserLimitResponse, + responses={404: responses._404}, +) +async def get_limit( + limit_id: int, + db: AsyncSession = Depends(get_db), + _: AdminDetails = Depends(check_sudo_admin), +): + """ + Get a specific node user limit by ID. + """ + limit = await crud.get_node_user_limit_by_id(db, limit_id) + if not limit: + raise HTTPException(status_code=404, detail="Limit not found") + return limit + + +@router.put( + "/{limit_id}", + response_model=NodeUserLimitResponse, + responses={404: responses._404}, +) +async def modify_limit( + limit_id: int, + modify: NodeUserLimitModify, + db: AsyncSession = Depends(get_db), + _: AdminDetails = Depends(check_sudo_admin), +): + """ + Modify an existing node user limit. + + Updates the data_limit, reset_strategy, and reset_time for the specified limit. + """ + limit = await crud.get_node_user_limit_by_id(db, limit_id) + if not limit: + raise HTTPException(status_code=404, detail="Limit not found") + + return await crud.modify_node_user_limit( + db, limit, modify.data_limit, modify.data_limit_reset_strategy, modify.reset_time + ) + + +@router.delete( + "/{limit_id}", + status_code=status.HTTP_204_NO_CONTENT, + responses={404: responses._404}, +) +async def delete_limit( + limit_id: int, + db: AsyncSession = Depends(get_db), + _: AdminDetails = Depends(check_sudo_admin), +): + """ + Delete a node user limit. + + Removes the per-user per-node traffic limit. The user will then be subject + to the global node limit and user limit (if configured). + """ + limit = await crud.get_node_user_limit_by_id(db, limit_id) + if not limit: + raise HTTPException(status_code=404, detail="Limit not found") + + await crud.remove_node_user_limit(db, limit) + + +@router.post( + "/bulk-set", + response_model=NodeUserLimitsResponse, + status_code=status.HTTP_200_OK, + responses={404: responses._404}, +) +async def bulk_set_all_users_limit( + request: BulkSetLimitRequest, + db: AsyncSession = Depends(get_db), + _: AdminDetails = Depends(check_sudo_admin), +): + """ + Set the same data limit for ALL users on a specific node. + + This endpoint fetches all users and applies the specified data_limit, + reset_strategy, and reset_time to each user on the given node. + If a limit already exists, it updates it. If not, it creates a new one. + + Example request body: + ```json + { + "node_id": 1, + "data_limit": 10737418240, // 10 GB in bytes + "data_limit_reset_strategy": "month", + "reset_time": 1 + } + ``` + """ + from app.db.crud.user import get_users + + # Validate node exists + node = await get_node_by_id(db, request.node_id) + if not node: + raise HTTPException(status_code=404, detail="Node not found") + + # Get all users (get_users returns list[User] directly) + users = await get_users(db, offset=0, limit=10000) # Get up to 10k users + + # Build user_limits dict for all users + user_limits = {user.id: request.data_limit for user in users} + + # Use existing bulk set function with reset strategy + limits = await crud.bulk_set_user_limits_for_node( + db, request.node_id, user_limits, + request.data_limit_reset_strategy, request.reset_time + ) + return NodeUserLimitsResponse(limits=limits, total=len(limits)) + + +@router.post( + "/node/{node_id}/bulk", + response_model=NodeUserLimitsResponse, + responses={404: responses._404}, +) +async def bulk_set_limits( + node_id: int, + user_limits: dict[int, int], + db: AsyncSession = Depends(get_db), + _: AdminDetails = Depends(check_sudo_admin), +): + """ + Bulk set or update user limits for a node. + + Accepts a dictionary mapping user_id to data_limit. Creates new limits + or updates existing ones as needed. + + Example request body: + ```json + { + "1": 10737418240, + "2": 21474836480, + "3": 0 + } + ``` + """ + # Validate node exists + node = await get_node_by_id(db, node_id) + if not node: + raise HTTPException(status_code=404, detail="Node not found") + + limits = await crud.bulk_set_user_limits_for_node(db, node_id, user_limits) + return NodeUserLimitsResponse(limits=limits, total=len(limits)) diff --git a/app/routers/user.py b/app/routers/user.py index dfee9831d..a26b0aa83 100644 --- a/app/routers/user.py +++ b/app/routers/user.py @@ -16,10 +16,12 @@ RemoveUsersResponse, UserCreate, UserModify, + UserNodeTrafficResponse, UserResponse, UsersResponse, UserSubscriptionUpdateChart, UserSubscriptionUpdateList, + ResetNodeUsageRequest, ) from app.operation import OperatorType from app.operation.node import NodeOperation @@ -108,6 +110,17 @@ async def reset_user_data_usage( return await user_operator.reset_user_data_usage(db, username=username, admin=admin) +@router.post("/{username}/reset-usage-by-node", response_model=UserResponse, responses={403: responses._403, 404: responses._404}) +async def reset_user_node_usage( + username: str, + request: ResetNodeUsageRequest, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(get_current) +): + """Reset user data usage for specific nodes""" + return await user_operator.reset_user_node_usage(db, username=username, node_ids=request.node_ids, admin=admin) + + @router.post( "/{username}/revoke_sub", response_model=UserResponse, responses={403: responses._403, 404: responses._404} ) @@ -244,6 +257,57 @@ async def get_user_usage( ) +@router.get( + "/{username}/node-limits", + responses={403: responses._403, 404: responses._404} +) +async def get_user_node_limits( + username: str, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(get_current), +): + """Get per-node limits for a specific user""" + from app.db.crud import user as user_crud, node_user_limit as node_limit_crud + from app.models.node_user_limit import NodeUserLimitsResponse + + # Validate user exists + db_user = await user_crud.get_user(db, username) + if not db_user: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="User not found") + + # Check admin permission + if not admin.is_sudo and db_user.admin_id != admin.id: + from fastapi import HTTPException + raise HTTPException(status_code=403, detail="Permission denied") + + # Get all node limits for this user + limits = await node_limit_crud.get_node_limits_for_user(db, db_user.id) + return NodeUserLimitsResponse(limits=limits, total=len(limits)) + + +@router.get( + "/{username}/node-traffic", + response_model=UserNodeTrafficResponse, + responses={403: responses._403, 404: responses._404} +) +async def get_user_node_traffic( + username: str, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(get_current), +): + """ + Get user traffic and limits breakdown by node. + + Returns for each node: + - node_id, node_name + - used_traffic (total bytes used on this node) + - data_limit (limit for this node, if configured via NodeUserLimit) + - has_limit (boolean, whether user has specific limit on this node) + """ + return await user_operator.get_user_node_traffic(db=db, username=username, admin=admin) + + @router.get("s/usage", response_model=UserUsageStatsList) async def get_users_usage( period: Period, diff --git a/app/utils/helpers.py b/app/utils/helpers.py index 13e10b96d..7ca553c8e 100644 --- a/app/utils/helpers.py +++ b/app/utils/helpers.py @@ -30,7 +30,7 @@ def get_datetime_format(): return date_time.strftime(get_datetime_format()) if date_time else "-" -def fix_datetime_timezone(value: dt | int): +def fix_datetime_timezone(value: dt | int | str): if isinstance(value, dt): # If datetime is naive (no timezone), assume it's UTC if value.tzinfo is None: @@ -41,8 +41,16 @@ def fix_datetime_timezone(value: dt | int): elif isinstance(value, int): # Timestamp will be assume it's UTC return dt.fromtimestamp(value, tz=tz.utc) - - raise ValueError("input can be datetime or timestamp") + elif isinstance(value, str): + try: + val = dt.fromisoformat(value) + if val.tzinfo is None: + return val.replace(tzinfo=tz.utc) + return val.astimezone(tz.utc) + except ValueError: + pass + + raise ValueError(f"input can be datetime, timestamp or string, got {type(value)}") class UUIDEncoder(json.JSONEncoder): From d78467eed1230db9e4616966183295d161727b62 Mon Sep 17 00:00:00 2001 From: Rerowros Date: Sat, 17 Jan 2026 21:33:52 +0700 Subject: [PATCH 08/11] feat(ui): add per-node data limits UI components - Add NodeLimitsManager component for user modal - Add node-user-limits table and dialog components - Add reset-usage-dialog for per-node usage reset - Add traffic-modal for viewing node traffic breakdown - Update node-modal with user_data_limit fields - Update user-template-modal with node_user_limits support - Add translations for en and ru locales --- dashboard/public/statics/locales/en.json | 46 +++- dashboard/public/statics/locales/ru.json | 50 +++- .../src/components/dialogs/node-modal.tsx | 144 +++++++++++ .../components/dialogs/reset-usage-dialog.tsx | 166 ++++++++++++ .../src/components/dialogs/user-modal.tsx | 105 +++++--- .../dialogs/user-template-modal.tsx | 196 +++++++++++++- .../node-user-limit-dialog.tsx | 101 ++++++++ .../node-user-limits-table.tsx | 90 +++++++ .../src/components/users/action-buttons.tsx | 87 +++---- dashboard/src/components/users/columns.tsx | 8 +- dashboard/src/components/users/data-table.tsx | 2 +- .../components/users/node-limits-manager.tsx | 240 ++++++++++++++++++ .../src/components/users/traffic-modal.tsx | 115 +++++++++ .../components/users/usage-slider-compact.tsx | 32 ++- .../src/locales/en/node-user-limits.json | 31 +++ dashboard/src/pages/_dashboard.templates.tsx | 7 +- dashboard/src/service/api/index.ts | 75 +++++- 17 files changed, 1368 insertions(+), 127 deletions(-) create mode 100644 dashboard/src/components/dialogs/reset-usage-dialog.tsx create mode 100644 dashboard/src/components/node-user-limits/node-user-limit-dialog.tsx create mode 100644 dashboard/src/components/node-user-limits/node-user-limits-table.tsx create mode 100644 dashboard/src/components/users/node-limits-manager.tsx create mode 100644 dashboard/src/components/users/traffic-modal.tsx create mode 100644 dashboard/src/locales/en/node-user-limits.json diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index 503660b80..8d61ec1f9 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -91,6 +91,48 @@ "2h": "2 hours" } }, + "userLimits": { + "title": "User Limits", + "description": "Manage per-user traffic limits for each node", + "pageTitle": "Per-User Node Traffic Limits", + "pageDescription": "Set individual traffic limits for each user on each node", + "addLimit": "Add Limit", + "editLimit": "Edit Limit", + "filterByNode": "Filter by Node:", + "selectNode": "Select a node", + "table": { + "id": "ID", + "user": "User", + "node": "Node", + "dataLimit": "Data Limit", + "noLimits": "No limits configured", + "noLimitsHint": "Click 'Add Limit' to create your first traffic limit" + }, + "dialog": { + "createTitle": "Add Traffic Limit", + "editTitle": "Edit Traffic Limit", + "description": "Configure traffic limit for a specific user on a specific node", + "user": "User", + "selectUser": "Select a user", + "node": "Node", + "selectNode": "Select a node", + "dataLimit": "Data Limit", + "dataLimitHint": "Enter 0 for unlimited traffic" + }, + "createSuccess": "Traffic limit created successfully", + "createFailed": "Failed to create traffic limit", + "deleteSuccess": "Traffic limit deleted successfully", + "deleteFailed": "Failed to delete traffic limit", + "deleteConfirm": "Are you sure you want to delete this limit?", + "dataLimitForAll": "Data Limit for All Users", + "applyToAll": "Apply to All Users", + "applying": "Applying...", + "bulkApplySuccess": "Limits applied to all users successfully", + "bulkApplyFailed": "Failed to apply limits", + "bulkHint": "Enter 0 for unlimited traffic. This will set the same limit for ALL users on this node.", + "currentLimits": "Current Limits", + "enterLimit": "Please enter a data limit" + }, "reconnectinfo": "Refresh all nodes connections to resolve connectivity issues", "reconnectAll": "Reconnect All Nodes", "reconnectingAll": "Reconnecting...", @@ -1904,8 +1946,8 @@ "selectStatus": "Select Status ... ", "byCore": "Core Configuration", "selectCore": "Select Core Configuration ... ", - "searchCore" : "Search Core Configurations...", - "selectedCore" : "Selected: {{name}}", + "searchCore": "Search Core Configurations...", + "selectedCore": "Selected: {{name}}", "noCoresFound": "No core configurations found", "noCoresAvailable": "No core configurations available" }, diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index df138424e..297c7f3cc 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -118,6 +118,48 @@ "2h": "2 часа" } }, + "userLimits": { + "title": "Лимиты пользователей", + "description": "Управление лимитами трафика для каждого пользователя на каждом узле", + "pageTitle": "Индивидуальные лимиты трафика", + "pageDescription": "Установите индивидуальные лимиты трафика для каждого пользователя на каждом узле", + "addLimit": "Добавить лимит", + "editLimit": "Редактировать лимит", + "filterByNode": "Фильтр по узлу:", + "selectNode": "Выберите узел", + "table": { + "id": "ID", + "user": "Пользователь", + "node": "Узел", + "dataLimit": "Лимит данных", + "noLimits": "Лимиты не настроены", + "noLimitsHint": "Нажмите 'Добавить лимит' для создания первого лимита трафика" + }, + "dialog": { + "createTitle": "Добавить лимит трафика", + "editTitle": "Редактировать лимит трафика", + "description": "Настройка лимита трафика для конкретного пользователя на конкретном узле", + "user": "Пользователь", + "selectUser": "Выберите пользователя", + "node": "Узел", + "selectNode": "Выберите узел", + "dataLimit": "Лимит данных", + "dataLimitHint": "Введите 0 для неограниченного трафика" + }, + "createSuccess": "Лимит трафика успешно создан", + "createFailed": "Не удалось создать лимит трафика", + "deleteSuccess": "Лимит трафика успешно удален", + "deleteFailed": "Не удалось удалить лимит трафика", + "deleteConfirm": "Вы уверены, что хотите удалить этот лимит?", + "dataLimitForAll": "Лимит данных для всех пользователей", + "applyToAll": "Применить ко всем", + "applying": "Применение...", + "bulkApplySuccess": "Лимиты успешно применены ко всем пользователям", + "bulkApplyFailed": "Не удалось применить лимиты", + "bulkHint": "Введите 0 для неограниченного трафика. Этот лимит будет установлен для ВСЕХ пользователей на данном узле.", + "currentLimits": "Текущие лимиты", + "enterLimit": "Пожалуйста, введите лимит данных" + }, "reconnectinfo": "Обновить все соединения узлов для устранения проблем с подключением", "reconnectAll": "Переподключить все узлы", "reconnectingAll": "Переподключение...", @@ -589,8 +631,8 @@ }, "resetUsersUsage.prompt": "Вы уверены, что хотите сбросить использование пользователя-админа {{name}}?", "removeAllUsers.prompt": "Вы уверены, что хотите удалить всех пользователей под управлением администратора {{name}}? Это действие нельзя отменить.", - "admin.disable": "Отключить администратора", - "admin.enable": "Включить администратора", + "admin.disable": "Отключить администратора", + "admin.enable": "Включить администратора", "deleteAdmin.prompt": "Вы уверены, что хотите удалить администратора {{name}}?", "activeUsers.prompt": "Вы хотите активировать всех пользователей под управлением {{name}}?", "disableUsers.prompt": "Вы хотите отключить всех пользователей под управлением {{name}}?", @@ -1844,8 +1886,8 @@ "selectStatus": "Выберите статус ...", "byCore": "Ядро", "selectCore": "Выберите ядро ...", - "searchCore" : "Поиск ядер...", - "selectedCore" : "Выбрано ядер: {{name}}", + "searchCore": "Поиск ядер...", + "selectedCore": "Выбрано ядер: {{name}}", "noCoresFound": "Ядра не найдены", "noCoresAvailable": "Нет доступных ядер" }, diff --git a/dashboard/src/components/dialogs/node-modal.tsx b/dashboard/src/components/dialogs/node-modal.tsx index 48642b868..c6d69481d 100644 --- a/dashboard/src/components/dialogs/node-modal.tsx +++ b/dashboard/src/components/dialogs/node-modal.tsx @@ -38,6 +38,9 @@ export const nodeFormSchema = z.object({ reset_time: z.union([z.null(), z.undefined(), z.number().min(-1)]), default_timeout: z.number().min(3, 'Default timeout must be 3 or greater').max(60, 'Default timeout must be 60 or lower').optional(), internal_timeout: z.number().min(3, 'Internal timeout must be 3 or greater').max(60, 'Internal timeout must be 60 or lower').optional(), + user_data_limit: z.number().min(0).optional().nullable(), + user_data_limit_reset_strategy: z.nativeEnum(DataLimitResetStrategy).optional().nullable(), + user_reset_time: z.union([z.null(), z.undefined(), z.number().min(-1)]), }) export type NodeFormValues = z.infer @@ -65,6 +68,7 @@ export default function NodeModal({ isDialogOpen, onOpenChange, form, editingNod const [debouncedValues, setDebouncedValues] = useState(null) const [isFetchingNodeData, setIsFetchingNodeData] = useState(false) const dataLimitInputRef = React.useRef('') + const userDataLimitInputRef = React.useRef('') const { data: node, refetch: refetchNode } = useGetNode( editingNodeId || 0, @@ -149,6 +153,7 @@ export default function NodeModal({ isDialogOpen, onOpenChange, form, editingNod reset_time: node.reset_time ?? null, default_timeout: node.default_timeout ?? 10, internal_timeout: node.internal_timeout ?? 15, + user_data_limit: null, }, { keepDirty: false, keepValues: false }, ) @@ -194,6 +199,9 @@ export default function NodeModal({ isDialogOpen, onOpenChange, form, editingNod dataLimitInputRef.current = '' } + const userDataLimitBytes = nodeData.user_data_limit ?? null + const userDataLimitGB = userDataLimitBytes !== null && userDataLimitBytes !== undefined && userDataLimitBytes > 0 ? userDataLimitBytes / (1024 * 1024 * 1024) : 0 + form.reset({ name: nodeData.name, address: nodeData.address, @@ -210,6 +218,9 @@ export default function NodeModal({ isDialogOpen, onOpenChange, form, editingNod reset_time: nodeData.reset_time ?? null, default_timeout: nodeData.default_timeout ?? 10, internal_timeout: nodeData.internal_timeout ?? 15, + user_data_limit: userDataLimitGB, + user_data_limit_reset_strategy: nodeData.user_data_limit_reset_strategy ?? DataLimitResetStrategy.no_reset, + user_reset_time: nodeData.user_reset_time ?? -1, }) lastSyncedNodeRef.current = nodeData setIsFetchingNodeData(false) @@ -229,6 +240,9 @@ export default function NodeModal({ isDialogOpen, onOpenChange, form, editingNod dataLimitInputRef.current = '' } + const userDataLimitBytes = nodeData.user_data_limit ?? null + const userDataLimitGB = userDataLimitBytes !== null && userDataLimitBytes !== undefined && userDataLimitBytes > 0 ? userDataLimitBytes / (1024 * 1024 * 1024) : 0 + form.reset({ name: nodeData.name, address: nodeData.address, @@ -245,6 +259,9 @@ export default function NodeModal({ isDialogOpen, onOpenChange, form, editingNod reset_time: nodeData.reset_time ?? null, default_timeout: nodeData.default_timeout ?? 10, internal_timeout: nodeData.internal_timeout ?? 15, + user_data_limit: userDataLimitGB, + user_data_limit_reset_strategy: nodeData.user_data_limit_reset_strategy ?? DataLimitResetStrategy.no_reset, + user_reset_time: nodeData.user_reset_time ?? -1, }) lastSyncedNodeRef.current = nodeData } catch (error) { @@ -339,6 +356,9 @@ export default function NodeModal({ isDialogOpen, onOpenChange, form, editingNod data_limit: gbToBytes(values.data_limit), reset_time: values.reset_time !== null && values.reset_time !== undefined ? values.reset_time : -1, api_port: values.api_port ?? undefined, + user_data_limit: gbToBytes(values.user_data_limit) ?? null, + user_data_limit_reset_strategy: values.user_data_limit_reset_strategy ?? DataLimitResetStrategy.no_reset, + user_reset_time: values.user_reset_time !== null && values.user_reset_time !== undefined ? values.user_reset_time : -1, } let nodeId: number | undefined @@ -382,6 +402,26 @@ export default function NodeModal({ isDialogOpen, onOpenChange, form, editingNod lastSyncedNodeRef.current = null } queryClient.invalidateQueries({ queryKey: ['/api/nodes'] }) + + // Apply bulk user limits if set + if (nodeId && values.user_data_limit !== null && values.user_data_limit !== undefined) { + const token = localStorage.getItem('token') + await fetch(`/api/node-user-limits/bulk-set`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + node_id: nodeId, + data_limit: Math.floor(values.user_data_limit * 1073741824), // GB to bytes + data_limit_reset_strategy: values.user_data_limit_reset_strategy || 'no_reset', + reset_time: values.user_reset_time ?? -1, + }), + }) + toast.success(t('nodes.userLimits.bulkApplySuccess')) + } + onOpenChange(false) form.reset() } catch (error: any) { @@ -852,6 +892,110 @@ export default function NodeModal({ isDialogOpen, onOpenChange, form, editingNod /> )} + { + if (userDataLimitInputRef.current === '' && field.value !== null && field.value !== undefined && field.value > 0) { + userDataLimitInputRef.current = String(field.value) + } else if ((field.value === null || field.value === undefined) && userDataLimitInputRef.current !== '') { + userDataLimitInputRef.current = '' + } + + const displayValue = + userDataLimitInputRef.current !== '' ? userDataLimitInputRef.current : field.value !== null && field.value !== undefined && field.value > 0 ? String(field.value) : '' + + return ( + + {t('nodes.userLimits.perUserDataLimit', 'Data Limit Per User (GB)')} + + { + const rawValue = e.target.value.trim() + userDataLimitInputRef.current = rawValue + if (rawValue === '') { + field.onChange(null) + return + } + const validNumberPattern = /^-?\d*\.?\d*$/ + if (validNumberPattern.test(rawValue)) { + if (rawValue.endsWith('.') && rawValue.length > 1) { + field.onChange(field.value) + } else if (rawValue === '.') { + field.onChange(0) + } else { + const numValue = parseFloat(rawValue) + if (!isNaN(numValue) && numValue >= 0) { + field.onChange(numValue) + } + } + } + }} + onBlur={() => { + const rawValue = userDataLimitInputRef.current.trim() + if (rawValue === '' || rawValue === '.' || rawValue === '0') { + userDataLimitInputRef.current = '' + field.onChange(null) + } else { + const numValue = parseFloat(rawValue) + if (!isNaN(numValue) && numValue >= 0) { + userDataLimitInputRef.current = String(numValue) + field.onChange(numValue) + } else { + userDataLimitInputRef.current = '' + field.onChange(null) + } + } + }} + /> + + + + ) + }} + /> + + {form.watch('user_data_limit') !== null && form.watch('user_data_limit') !== undefined && Number(form.watch('user_data_limit')) > 0 && ( + { + const selectValue = (field.value === null || field.value === undefined || field.value === DataLimitResetStrategy.no_reset ? 'none' : field.value) || 'none' + + return ( + + {t('nodeModal.perUserResetStrategy', 'Per-User Reset Strategy')} + + + + ) + }} + /> + )} + void + username: string + onSuccess: () => void +} + +interface NodeItem { + node_id: number + node_name: string + used_traffic: number +} + +export function ResetUsageDialog({ open, onOpenChange, username, onSuccess }: ResetUsageDialogProps) { + const { t } = useTranslation() + const [loading, setLoading] = useState(false) + const [nodes, setNodes] = useState([]) + const [selectedNodes, setSelectedNodes] = useState([]) + const [resetMode, setResetMode] = useState<'all' | 'custom'>('all') + const [fetchingError, setFetchingError] = useState(null) + + const resetAllMutation = useResetUserDataUsage({ + mutation: { + onSuccess: () => { + toast.success(t('usersTable.resetUsageSuccess', { name: username })) + onSuccess() + onOpenChange(false) + }, + onError: (error: any) => { + toast.error(t('usersTable.resetUsageFailed', { name: username, error: error?.message || '' })) + }, + }, + }) + + const resetNodeUsageMutation = useResetUserNodeUsage({ + mutation: { + onSuccess: () => { + toast.success(t('usersTable.resetUsageSuccess', { name: username })) + onSuccess() + onOpenChange(false) + }, + onError: (error: any) => { + toast.error(t('usersTable.resetUsageFailed', { name: username, error: error?.message || '' })) + }, + }, + }) + + const fetchNodes = useCallback(async () => { + setLoading(true) + setFetchingError(null) + try { + const token = localStorage.getItem('token') + const response = await fetch(`/api/user/${username}/node-traffic`, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (!response.ok) throw new Error('Failed to load nodes') + const data = await response.json() + setNodes(data.nodes || []) + } catch (err) { + console.error(err) + setFetchingError('Failed to load node data') + } finally { + setLoading(false) + } + }, [username]) + + useEffect(() => { + if (open) { + fetchNodes() + setResetMode('all') + setSelectedNodes([]) + } + }, [open, fetchNodes]) + + const handleConfirm = () => { + if (resetMode === 'all') { + resetAllMutation.mutate({ username }) + } else { + if (selectedNodes.length === 0) { + toast.error(t('select_at_least_one_node')) + return + } + resetNodeUsageMutation.mutate({ username, data: { node_ids: selectedNodes } }) + } + } + + const toggleNode = (nodeId: number) => { + setSelectedNodes(prev => (prev.includes(nodeId) ? prev.filter(id => id !== nodeId) : [...prev, nodeId])) + } + + const isPending = resetAllMutation.isPending || resetNodeUsageMutation.isPending + + return ( + + + + {t('usersTable.resetUsageTitle')} + {t('usersTable.resetUsagePrompt', { name: username })} + + +
+
+ +
+
+ setResetMode('all')} /> + +
+
+ setResetMode('custom')} /> + +
+
+
+ + {resetMode === 'custom' && ( +
+ {loading ? ( +
+ +
+ ) : fetchingError ? ( +
{fetchingError}
+ ) : nodes.length === 0 ? ( +
No traffic data found
+ ) : ( + nodes.map(node => ( +
+ toggleNode(node.node_id)} /> + + {(node.used_traffic / (1024 * 1024 * 1024)).toFixed(2)} GB +
+ )) + )} +
+ )} +
+ + + onOpenChange(false)} disabled={isPending}> + {t('usersTable.cancel')} + + + {isPending && } + {t('usersTable.resetUsageSubmit')} + + +
+
+ ) +} diff --git a/dashboard/src/components/dialogs/user-modal.tsx b/dashboard/src/components/dialogs/user-modal.tsx index 89eda2297..31dcf2368 100644 --- a/dashboard/src/components/dialogs/user-modal.tsx +++ b/dashboard/src/components/dialogs/user-modal.tsx @@ -36,6 +36,7 @@ import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { v4 as uuidv4 } from 'uuid' import { z } from 'zod' +import { NodeLimitsManager } from '@/components/users/node-limits-manager' interface UserModalProps { isDialogOpen: boolean @@ -61,7 +62,6 @@ const templateModifySchema = z.object({ user_template_id: z.number(), }) - // Helper function to get local ISO time string with timezone offset // This is kept for backward compatibility with normalizeExpire function function getLocalISOTime(date: Date): string { @@ -330,11 +330,11 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse const hasNextPlanData = React.useMemo(() => { const nextPlan = form.getValues('next_plan') - + if (!nextPlan || nextPlan === null || nextPlan === undefined) { return false } - + return ( (nextPlan.user_template_id !== undefined && nextPlan.user_template_id !== null) || (nextPlan.expire !== undefined && nextPlan.expire !== null) || @@ -361,22 +361,18 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse // Prevent stale form data from overriding explicit nulls. form.setValue('next_plan', undefined, { shouldValidate: false, shouldDirty: false }) } - + // Use editingUserData if form doesn't have it yet, otherwise use form value - const nextPlan = nextPlanFromData === null - ? null - : (nextPlanFromForm !== null && nextPlanFromForm !== undefined - ? nextPlanFromForm - : nextPlanFromData) - - const hasData = nextPlan !== null && - nextPlan !== undefined && - typeof nextPlan === 'object' && ( - (nextPlan.user_template_id !== undefined && nextPlan.user_template_id !== null) || - (nextPlan.expire !== undefined && nextPlan.expire !== null) || - (nextPlan.data_limit !== undefined && nextPlan.data_limit !== null) || - (nextPlan.add_remaining_traffic !== undefined && nextPlan.add_remaining_traffic !== null) - ) + const nextPlan = nextPlanFromData === null ? null : nextPlanFromForm !== null && nextPlanFromForm !== undefined ? nextPlanFromForm : nextPlanFromData + + const hasData = + nextPlan !== null && + nextPlan !== undefined && + typeof nextPlan === 'object' && + ((nextPlan.user_template_id !== undefined && nextPlan.user_template_id !== null) || + (nextPlan.expire !== undefined && nextPlan.expire !== null) || + (nextPlan.data_limit !== undefined && nextPlan.data_limit !== null) || + (nextPlan.add_remaining_traffic !== undefined && nextPlan.add_remaining_traffic !== null)) setNextPlanEnabled(!!hasData) } else { // For create mode, always start with switch off @@ -663,11 +659,15 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse } else { const currentNextPlan = form.getValues('next_plan') if (currentNextPlan === null || currentNextPlan === undefined) { - form.setValue('next_plan', { - expire: 0, - data_limit: 0, - add_remaining_traffic: false, - }, { shouldValidate: false, shouldDirty: false }) + form.setValue( + 'next_plan', + { + expire: 0, + data_limit: 0, + add_remaining_traffic: false, + }, + { shouldValidate: false, shouldDirty: false }, + ) } else { if (!currentNextPlan.user_template_id) { const updatedPlan = { @@ -689,27 +689,24 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse // Check both form values and editingUserData prop for next_plan const nextPlanFromForm = form.getValues('next_plan') const nextPlanFromData = editingUserData?.next_plan - + // Use editingUserData if form doesn't have it yet, otherwise use form value - const nextPlan = nextPlanFromForm !== null && nextPlanFromForm !== undefined - ? nextPlanFromForm - : nextPlanFromData - - const hasDataFromForm = nextPlan !== null && - nextPlan !== undefined && - typeof nextPlan === 'object' && ( - (nextPlan.user_template_id !== undefined && nextPlan.user_template_id !== null) || - (nextPlan.expire !== undefined && nextPlan.expire !== null) || - (nextPlan.data_limit !== undefined && nextPlan.data_limit !== null) || - (nextPlan.add_remaining_traffic !== undefined && nextPlan.add_remaining_traffic !== null) - ) + const nextPlan = nextPlanFromForm !== null && nextPlanFromForm !== undefined ? nextPlanFromForm : nextPlanFromData + + const hasDataFromForm = + nextPlan !== null && + nextPlan !== undefined && + typeof nextPlan === 'object' && + ((nextPlan.user_template_id !== undefined && nextPlan.user_template_id !== null) || + (nextPlan.expire !== undefined && nextPlan.expire !== null) || + (nextPlan.data_limit !== undefined && nextPlan.data_limit !== null) || + (nextPlan.add_remaining_traffic !== undefined && nextPlan.add_remaining_traffic !== null)) const hasData = hasDataFromForm if (!hasData && nextPlanEnabled && !nextPlanManuallyDisabled) { setNextPlanEnabled(false) - } - else if (hasData && !nextPlanEnabled && !nextPlanManuallyDisabled) { + } else if (hasData && !nextPlanEnabled && !nextPlanManuallyDisabled) { setNextPlanEnabled(true) setNextPlanManuallyDisabled(false) } @@ -743,7 +740,6 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse // Helper to clear template selection const clearTemplate = () => setSelectedTemplateId(null) - // Update validateAllFields function const validateAllFields = (currentValues: any, touchedFields: any, isSubmit: boolean = false) => { try { @@ -1073,7 +1069,7 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse if (nextPlanEnabled) { // Switch is ON - always send next_plan with defaults or existing data const nextPlan = values.next_plan || form.getValues('next_plan') || {} - + if (nextPlan.user_template_id) { // Template selected - only send template_id and add_remaining_traffic sendValues.next_plan = { @@ -1929,6 +1925,21 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse /> + + {/* Per-Node Data Limits */} + {editingUser && ( + + +
+ + {t('per_node_limits', { defaultValue: 'Per-Node Data Limits' })} +
+
+ + + +
+ )} )} {/* Next Plan Section (toggleable) */} @@ -2013,7 +2024,12 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse if (nextPlanExpireInputRef.current === '' && field.value !== null && field.value !== undefined && field.value > 0) { nextPlanExpireInputRef.current = String(dateUtils.secondsToDays(field.value)) } - const displayValue = nextPlanExpireInputRef.current !== '' ? nextPlanExpireInputRef.current : (field.value !== null && field.value !== undefined && field.value > 0 ? String(dateUtils.secondsToDays(field.value)) : '') + const displayValue = + nextPlanExpireInputRef.current !== '' + ? nextPlanExpireInputRef.current + : field.value !== null && field.value !== undefined && field.value > 0 + ? String(dateUtils.secondsToDays(field.value)) + : '' return ( {t('userDialog.nextPlanExpire', { defaultValue: 'Expire' })} @@ -2090,7 +2106,12 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse if (nextPlanDataLimitInputRef.current === '' && field.value !== null && field.value !== undefined && field.value > 0) { nextPlanDataLimitInputRef.current = String(Math.round(field.value / (1024 * 1024 * 1024))) } - const displayValue = nextPlanDataLimitInputRef.current !== '' ? nextPlanDataLimitInputRef.current : (field.value !== null && field.value !== undefined && field.value > 0 ? String(Math.round(field.value / (1024 * 1024 * 1024))) : '') + const displayValue = + nextPlanDataLimitInputRef.current !== '' + ? nextPlanDataLimitInputRef.current + : field.value !== null && field.value !== undefined && field.value > 0 + ? String(Math.round(field.value / (1024 * 1024 * 1024))) + : '' return ( {t('userDialog.nextPlanDataLimit', { defaultValue: 'Data Limit' })} diff --git a/dashboard/src/components/dialogs/user-template-modal.tsx b/dashboard/src/components/dialogs/user-template-modal.tsx index 8b4ff1097..4f5633676 100644 --- a/dashboard/src/components/dialogs/user-template-modal.tsx +++ b/dashboard/src/components/dialogs/user-template-modal.tsx @@ -9,7 +9,19 @@ import { Switch } from '@/components/ui/switch.tsx' import useDirDetection from '@/hooks/use-dir-detection' import useDynamicErrorHandler from '@/hooks/use-dynamic-errors.ts' import { cn } from '@/lib/utils.ts' -import { DataLimitResetStrategy, ShadowsocksMethods, useCreateUserTemplate, useModifyUserTemplate, UserStatusCreate, XTLSFlows } from '@/service/api' +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' +import { + DataLimitResetStrategy, + NodeLimitSettings, + ShadowsocksMethods, + useCreateUserTemplate, + useGetNodes, + useModifyUserTemplate, + UserStatusCreate, + UserTemplateCreate, + UserTemplateModify, + XTLSFlows, +} from '@/service/api' import { queryClient } from '@/utils/query-client.ts' import { useState } from 'react' import { UseFormReturn } from 'react-hook-form' @@ -31,16 +43,10 @@ export const userTemplateFormSchema = z.object({ flow: z.enum([XTLSFlows[''], XTLSFlows['xtls-rprx-vision']]).default(XTLSFlows['']), groups: z.array(z.number()).min(1, 'Groups is required'), data_limit_reset_strategy: z - .enum([ - DataLimitResetStrategy['month'], - DataLimitResetStrategy['day'], - DataLimitResetStrategy['week'], - DataLimitResetStrategy['no_reset'], - DataLimitResetStrategy['week'], - DataLimitResetStrategy['year'], - ]) + .enum([DataLimitResetStrategy['month'], DataLimitResetStrategy['day'], DataLimitResetStrategy['week'], DataLimitResetStrategy['no_reset'], DataLimitResetStrategy['year']]) .optional(), reset_usages: z.boolean().optional(), + node_user_limits: z.any().optional(), }) export type UserTemplatesFromValue = z.infer @@ -62,11 +68,34 @@ export default function UserTemplateModal({ isDialogOpen, onOpenChange, form, ed const [timeType, setTimeType] = useState<'seconds' | 'hours' | 'days'>('seconds') const [loading, setLoading] = useState(false) + const { data: nodesData } = useGetNodes(undefined, { + query: { + enabled: isDialogOpen, + }, + }) + const nodes = nodesData?.nodes || [] + const onSubmit = async (values: UserTemplatesFromValue) => { setLoading(true) try { + // Map node_user_limits from record with string keys to record with number keys + const nodeUserLimits: { [key: number]: NodeLimitSettings | number } = {} + if (values.node_user_limits) { + Object.entries(values.node_user_limits).forEach(([key, val]: [string, any]) => { + if (typeof val === 'object' && val !== null) { + nodeUserLimits[Number(key)] = { + data_limit: val.data_limit ?? undefined, + data_limit_reset_strategy: val.data_limit_reset_strategy ?? undefined, + reset_time: val.reset_time ?? undefined, + } + } else if (typeof val === 'number') { + nodeUserLimits[Number(key)] = val + } + }) + } + // Build payload according to UserTemplateCreate interface - const submitData = { + const submitData: UserTemplateCreate = { name: values.name, data_limit: values.data_limit, expire_duration: values.expire_duration, @@ -76,6 +105,7 @@ export default function UserTemplateModal({ isDialogOpen, onOpenChange, form, ed status: values.status, on_hold_timeout: values.status === UserStatusCreate.on_hold ? values.on_hold_timeout : undefined, data_limit_reset_strategy: values.data_limit ? values.data_limit_reset_strategy : undefined, + node_user_limits: Object.keys(nodeUserLimits).length > 0 ? nodeUserLimits : undefined, reset_usages: values.reset_usages, extra_settings: values.method || values.flow @@ -89,7 +119,7 @@ export default function UserTemplateModal({ isDialogOpen, onOpenChange, form, ed if (editingUserTemplate && editingUserTemplateId) { await modifyUserTemplateMutation.mutateAsync({ templateId: editingUserTemplateId, - data: submitData, + data: submitData as UserTemplateModify, }) toast.success( t('templates.editSuccess', { @@ -112,7 +142,7 @@ export default function UserTemplateModal({ isDialogOpen, onOpenChange, form, ed queryClient.invalidateQueries({ queryKey: ['/api/user_templates'] }) onOpenChange(false) form.reset() - } catch (error: any) { + } catch (error: unknown) { const fields = [ 'name', 'data_limit', @@ -126,6 +156,7 @@ export default function UserTemplateModal({ isDialogOpen, onOpenChange, form, ed 'method', 'flow', 'reset_usages', + 'node_user_limits', ] handleError({ error, fields, form, contextKey: 'groups' }) } finally { @@ -133,6 +164,9 @@ export default function UserTemplateModal({ isDialogOpen, onOpenChange, form, ed } } + // Debug validation errors + console.log('Form Errors:', form.formState.errors) + return ( e.preventDefault()}> @@ -438,6 +472,144 @@ export default function UserTemplateModal({ isDialogOpen, onOpenChange, form, ed )} /> } /> + + {nodes.length > 0 && ( + + + {t('userDialog.nodeLimits', { defaultValue: 'Per-Node Data Limits' })} + + {nodes.map(node => { + const nodeKey = String(node.id) + const limits = form.watch('node_user_limits') || {} + const nodeLimit = limits[nodeKey] + + // Normalize limit info + const nodeLimitRaw: any = nodeLimit + const limitObj: NodeLimitSettings = + typeof nodeLimitRaw === 'object' && nodeLimitRaw !== null + ? { + data_limit: nodeLimitRaw.data_limit ?? undefined, + data_limit_reset_strategy: nodeLimitRaw.data_limit_reset_strategy ?? undefined, + reset_time: nodeLimitRaw.reset_time ?? undefined, + } + : { data_limit: typeof nodeLimitRaw === 'number' ? nodeLimitRaw : undefined } + + const dataLimitGB = limitObj.data_limit ? limitObj.data_limit / (1024 * 1024 * 1024) : '' + + return ( +
+
+ {node.name} + +
+ +
+
+ {t('templates.dataLimit')} +
+ { + const val = parseFloat(e.target.value) + const bytes = val ? Math.round(val * 1024 * 1024 * 1024) : null + form.setValue(`node_user_limits.${nodeKey}`, { ...limitObj, data_limit: bytes }, { shouldDirty: true }) + }} + className="h-8 pr-8 text-xs" + min="0" + /> + GB +
+
+ +
+ {t('templates.userDataLimitStrategy')} + +
+ + {limitObj.data_limit_reset_strategy && limitObj.data_limit_reset_strategy !== DataLimitResetStrategy.no_reset && ( +
+ {t('nodeModal.resetTime', { defaultValue: 'Reset Time' })} +
+ + : + +
+
+ )} +
+
+ ) + })} +
+
+
+ )}
diff --git a/dashboard/src/components/node-user-limits/node-user-limit-dialog.tsx b/dashboard/src/components/node-user-limits/node-user-limit-dialog.tsx new file mode 100644 index 000000000..8d4059a32 --- /dev/null +++ b/dashboard/src/components/node-user-limits/node-user-limit-dialog.tsx @@ -0,0 +1,101 @@ +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface NodeUserLimitDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onSubmit: (data: { userId: number; nodeId: number; dataLimitGB: number }) => void + users?: Array<{ id: number; username: string }> + nodes?: Array<{ id: number; name: string }> + initialData?: { userId?: number; nodeId?: number; dataLimitGB?: number } + mode?: 'create' | 'edit' +} + +export function NodeUserLimitDialog({ open, onOpenChange, onSubmit, users = [], nodes = [], initialData, mode = 'create' }: NodeUserLimitDialogProps) { + const { t } = useTranslation() + const [userId, setUserId] = useState(initialData?.userId) + const [nodeId, setNodeId] = useState(initialData?.nodeId) + const [dataLimitGB, setDataLimitGB] = useState(initialData?.dataLimitGB?.toString() || '') + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!userId || !nodeId || !dataLimitGB) return + + onSubmit({ + userId, + nodeId, + dataLimitGB: parseFloat(dataLimitGB), + }) + + // Reset form + if (mode === 'create') { + setUserId(undefined) + setNodeId(undefined) + setDataLimitGB('') + } + onOpenChange(false) + } + + return ( + + +
+ + {mode === 'create' ? t('nodes.userLimits.dialog.createTitle') : t('nodes.userLimits.dialog.editTitle')} + {t('nodes.userLimits.dialog.description')} + +
+
+ + +
+
+ + +
+
+ + setDataLimitGB(e.target.value)} required /> +

{t('nodes.userLimits.dialog.dataLimitHint')}

+
+
+ + + + +
+
+
+ ) +} diff --git a/dashboard/src/components/node-user-limits/node-user-limits-table.tsx b/dashboard/src/components/node-user-limits/node-user-limits-table.tsx new file mode 100644 index 000000000..8b596312a --- /dev/null +++ b/dashboard/src/components/node-user-limits/node-user-limits-table.tsx @@ -0,0 +1,90 @@ +import { Button } from '@/components/ui/button' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { Pencil, Trash2 } from 'lucide-react' +import { useTranslation } from 'react-i18next' + +interface NodeUserLimit { + id: number + userId: number + nodeId: number + dataLimit: number + username?: string + nodeName?: string +} + +interface NodeUserLimitsTableProps { + limits: NodeUserLimit[] + onEdit?: (limit: NodeUserLimit) => void + onDelete?: (limitId: number) => void + isLoading?: boolean + showUser?: boolean + showNode?: boolean +} + +function bytesToGB(bytes: number): string { + if (bytes === 0) return '∞' + return (bytes / 1073741824).toFixed(2) +} + +export function NodeUserLimitsTable({ limits, onEdit, onDelete, isLoading = false, showUser = true, showNode = true }: NodeUserLimitsTableProps) { + const { t } = useTranslation() + + if (isLoading) { + return ( +
+
{t('loading')}
+
+ ) + } + + if (limits.length === 0) { + return ( +
+

{t('nodes.userLimits.table.noLimits')}

+

{t('nodes.userLimits.table.noLimitsHint')}

+
+ ) + } + + return ( +
+ + + + {t('nodes.userLimits.table.id')} + {showUser && {t('nodes.userLimits.table.user')}} + {showNode && {t('nodes.userLimits.table.node')}} + {t('nodes.userLimits.table.dataLimit')} + {t('actions')} + + + + {limits.map(limit => ( + + #{limit.id} + {showUser && {limit.username || `User ID: ${limit.userId}`}} + {showNode && {limit.nodeName || `Node ID: ${limit.nodeId}`}} + + {bytesToGB(limit.dataLimit)} GB + + +
+ {onEdit && ( + + )} + {onDelete && ( + + )} +
+
+
+ ))} +
+
+
+ ) +} diff --git a/dashboard/src/components/users/action-buttons.tsx b/dashboard/src/components/users/action-buttons.tsx index e30e43902..2830525d0 100644 --- a/dashboard/src/components/users/action-buttons.tsx +++ b/dashboard/src/components/users/action-buttons.tsx @@ -4,7 +4,7 @@ import { useClipboard } from '@/hooks/use-clipboard' import useDirDetection from '@/hooks/use-dir-detection' import { cn } from '@/lib/utils' import { UseEditFormValues } from '@/pages/_dashboard.users' -import { useActiveNextPlan, useGetCurrentAdmin, useRemoveUser, useResetUserDataUsage, useRevokeUserSubscription, UserResponse, UsersResponse } from '@/service/api' +import { useActiveNextPlan, useGetCurrentAdmin, useRemoveUser, useRevokeUserSubscription, UserResponse, UsersResponse } from '@/service/api' import { useQueryClient } from '@tanstack/react-query' import { Check, Copy, Cpu, EllipsisVertical, ListStart, Network, Pencil, PieChart, QrCode, RefreshCcw, Trash2, User, Users } from 'lucide-react' import { FC, useCallback, useEffect, useState } from 'react' @@ -18,8 +18,10 @@ import UsageModal from '@/components/dialogs/usage-modal' import UserModal from '@/components/dialogs/user-modal' import { UserSubscriptionClientsModal } from '@/components/dialogs/user-subscription-clients-modal' import UserAllIPsModal from '@/components/dialogs/user-all-ips-modal' +import { ResetUsageDialog } from '@/components/dialogs/reset-usage-dialog' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { TrafficModal } from './traffic-modal' type ActionButtonsProps = { user: UserResponse @@ -45,6 +47,7 @@ const ActionButtons: FC = ({ user }) => { const [isActiveNextPlanModalOpen, setIsActiveNextPlanModalOpen] = useState(false) const [isSubscriptionClientsModalOpen, setSubscriptionClientsModalOpen] = useState(false) const [isUserAllIPsModalOpen, setUserAllIPsModalOpen] = useState(false) + const [isTrafficModalOpen, setTrafficModalOpen] = useState(false) const queryClient = useQueryClient() const { t } = useTranslation() const dir = useDirDetection() @@ -76,18 +79,10 @@ const ActionButtons: FC = ({ user }) => { } const removeUserMutation = useRemoveUser() - const resetUserDataUsageMutation = useResetUserDataUsage({ - mutation: { - onSuccess: (updatedUser) => { - if (updatedUser) { - updateUserInCache(updatedUser) - } - }, - }, - }) + const revokeUserSubscriptionMutation = useRevokeUserSubscription({ mutation: { - onSuccess: (updatedUser) => { + onSuccess: updatedUser => { if (updatedUser) { updateUserInCache(updatedUser) } @@ -96,7 +91,7 @@ const ActionButtons: FC = ({ user }) => { }) const activeNextMutation = useActiveNextPlan({ mutation: { - onSuccess: (updatedUser) => { + onSuccess: updatedUser => { if (updatedUser) { updateUserInCache(updatedUser) } @@ -126,11 +121,11 @@ const ActionButtons: FC = ({ user }) => { proxy_settings: user.proxy_settings || undefined, next_plan: user.next_plan ? { - user_template_id: user.next_plan.user_template_id ? Number(user.next_plan.user_template_id) : undefined, - data_limit: user.next_plan.data_limit ? Number(user.next_plan.data_limit) : 0, - expire: user.next_plan.expire ? Number(user.next_plan.expire) : 0, - add_remaining_traffic: user.next_plan.add_remaining_traffic || false, - } + user_template_id: user.next_plan.user_template_id ? Number(user.next_plan.user_template_id) : undefined, + data_limit: user.next_plan.data_limit ? Number(user.next_plan.data_limit) : 0, + expire: user.next_plan.expire ? Number(user.next_plan.expire) : 0, + add_remaining_traffic: user.next_plan.add_remaining_traffic || false, + } : undefined, }, }) @@ -150,11 +145,11 @@ const ActionButtons: FC = ({ user }) => { proxy_settings: user.proxy_settings || undefined, next_plan: user.next_plan ? { - user_template_id: user.next_plan.user_template_id ? Number(user.next_plan.user_template_id) : undefined, - data_limit: user.next_plan.data_limit ? Number(user.next_plan.data_limit) : 0, - expire: user.next_plan.expire ? Number(user.next_plan.expire) : 0, - add_remaining_traffic: user.next_plan.add_remaining_traffic || false, - } + user_template_id: user.next_plan.user_template_id ? Number(user.next_plan.user_template_id) : undefined, + data_limit: user.next_plan.data_limit ? Number(user.next_plan.data_limit) : 0, + expire: user.next_plan.expire ? Number(user.next_plan.expire) : 0, + add_remaining_traffic: user.next_plan.add_remaining_traffic || false, + } : undefined, } @@ -228,11 +223,11 @@ const ActionButtons: FC = ({ user }) => { proxy_settings: latestUser.proxy_settings || undefined, next_plan: latestUser.next_plan ? { - user_template_id: latestUser.next_plan.user_template_id ? Number(latestUser.next_plan.user_template_id) : undefined, - data_limit: latestUser.next_plan.data_limit ? Number(latestUser.next_plan.data_limit) : 0, - expire: latestUser.next_plan.expire ? Number(latestUser.next_plan.expire) : 0, - add_remaining_traffic: latestUser.next_plan.add_remaining_traffic || false, - } + user_template_id: latestUser.next_plan.user_template_id ? Number(latestUser.next_plan.user_template_id) : undefined, + data_limit: latestUser.next_plan.data_limit ? Number(latestUser.next_plan.data_limit) : 0, + expire: latestUser.next_plan.expire ? Number(latestUser.next_plan.expire) : 0, + add_remaining_traffic: latestUser.next_plan.add_remaining_traffic || false, + } : undefined, } @@ -277,16 +272,6 @@ const ActionButtons: FC = ({ user }) => { setResetUsageDialogOpen(true) } - const confirmResetUsage = async () => { - try { - await resetUserDataUsageMutation.mutateAsync({ username: user.username }) - toast.success(t('usersTable.resetUsageSuccess', { name: user.username })) - setResetUsageDialogOpen(false) - } catch (error: any) { - toast.error(t('usersTable.resetUsageFailed', { name: user.username, error: error?.message || '' })) - } - } - const handleUsageState = () => { setUsageModalOpen(true) } @@ -432,7 +417,7 @@ const ActionButtons: FC = ({ user }) => { {subscribeLinks.map(subLink => ( - handleCopyOrDownload(subLink)}> + handleCopyOrDownload(subLink)}> {subLink.icon} {subLink.protocol} @@ -451,6 +436,11 @@ const ActionButtons: FC = ({ user }) => { + setTrafficModalOpen(true)}> + + {t('traffic_by_node')} + + {/* Edit */} @@ -568,20 +558,8 @@ const ActionButtons: FC = ({ user }) => { {/* Reset Usage Confirm Dialog */} - - - - {t('usersTable.resetUsageTitle')} - {t('usersTable.resetUsagePrompt', { name: user.username })} - - - setResetUsageDialogOpen(false)}>{t('usersTable.cancel')} - - {t('usersTable.resetUsageSubmit')} - - - - + {/* Reset Usage Confirm Dialog - Replaced with ResetUsageDialog */} + {/* Revoke Subscription Confirm Dialog */} @@ -603,7 +581,7 @@ const ActionButtons: FC = ({ user }) => { {selectedUser && ( { + onOpenChange={open => { setEditModalOpen(open) if (!open) { setSelectedUser(null) @@ -623,6 +601,9 @@ const ActionButtons: FC = ({ user }) => { setUsageModalOpen(false)} username={user.username} /> + {/* Traffic by Node Modal */} + setTrafficModalOpen(false)} username={user.username} /> + {/* SetOwnerModal: only for sudo admins */} {currentAdmin?.is_sudo && ( (
- +
diff --git a/dashboard/src/components/users/data-table.tsx b/dashboard/src/components/users/data-table.tsx index 2dee61c1f..51f0344eb 100644 --- a/dashboard/src/components/users/data-table.tsx +++ b/dashboard/src/components/users/data-table.tsx @@ -43,7 +43,7 @@ export const DataTable = memo(({ columns, da const handleEditModal = useCallback( (e: React.MouseEvent, user: UserResponse) => { - if ((e.target as HTMLElement).closest('.chevron')) return + if ((e.target as HTMLElement).closest('.chevron, .prevent-edit')) return if (window.innerWidth < 768) { handleRowToggle(user.id) return diff --git a/dashboard/src/components/users/node-limits-manager.tsx b/dashboard/src/components/users/node-limits-manager.tsx new file mode 100644 index 000000000..3608673e6 --- /dev/null +++ b/dashboard/src/components/users/node-limits-manager.tsx @@ -0,0 +1,240 @@ +import { useState, useEffect } from 'react' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/button' +import { Loader2, Trash2 } from 'lucide-react' +import { gbToBytes } from '@/utils/formatByte' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +interface NodeLimit { + node_id: number + node_name: string + data_limit: number | null // in bytes, null = no limit + current_limit_id?: number // ID if limit already exists in DB +} + +interface NodeLimitsManagerProps { + userId?: number + username?: string + isEditMode: boolean +} + +// Helper to convert bytes to GB +const bytesToGb = (bytes: number): number => { + return bytes / (1024 * 1024 * 1024) +} + +export function NodeLimitsManager({ userId, isEditMode }: NodeLimitsManagerProps) { + const { t } = useTranslation() + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [nodeLimits, setNodeLimits] = useState([]) + const [hasChanges, setHasChanges] = useState(false) + + // Fetch nodes and existing limits + useEffect(() => { + if (!isEditMode || !userId) { + setLoading(false) + return + } + + const fetchData = async () => { + try { + const token = localStorage.getItem('token') + + // Fetch all nodes + const nodesResponse = await fetch('/api/nodes', { + headers: { Authorization: `Bearer ${token}` }, + }) + const nodesData = await nodesResponse.json() + + // Fetch existing limits for this user + const limitsResponse = await fetch(`/api/node-user-limits/user/${userId}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + const limitsData = await limitsResponse.json() + + // Combine data + interface LimitInfo { + data_limit: number + limit_id: number + } + + const limitsMap = new Map( + (limitsData.limits || []).map((limit: { node_id: number; data_limit: number; id: number }) => [limit.node_id, { data_limit: limit.data_limit, limit_id: limit.id }]), + ) + + interface NodeItem { + id: number + name: string + } + + // API returns {nodes: [...]} format + const nodesList = nodesData?.nodes || nodesData?.items || nodesData || [] + const combined: NodeLimit[] = (nodesList as NodeItem[]).map(node => ({ + node_id: node.id, + node_name: node.name, + data_limit: limitsMap.get(node.id)?.data_limit ?? null, + current_limit_id: limitsMap.get(node.id)?.limit_id, + })) + + setNodeLimits(combined) + } catch (error) { + console.error('Failed to load node limits:', error) + toast.error(t('failed_to_load_data')) + } finally { + setLoading(false) + } + } + + fetchData() + }, [userId, isEditMode, t]) + + const handleLimitChange = (nodeId: number, gbValue: string) => { + const numValue = parseFloat(gbValue) + const bytesValue: number | null = isNaN(numValue) || gbValue === '' ? null : (gbToBytes(numValue) ?? null) + + setNodeLimits(prev => prev.map(node => (node.node_id === nodeId ? { ...node, data_limit: bytesValue } : node))) + setHasChanges(true) + } + + const handleSave = async () => { + if (!userId) { + toast.error('User ID is required') + return + } + + setSaving(true) + try { + const token = localStorage.getItem('token') + + for (const node of nodeLimits) { + const hasLimit = node.data_limit !== null && node.data_limit > 0 + const hadLimit = node.current_limit_id !== undefined + + if (hasLimit && !hadLimit) { + // Create new limit + await fetch('/api/node-user-limits', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + user_id: userId, + node_id: node.node_id, + data_limit: node.data_limit, + data_limit_reset_strategy: 'no_reset', + reset_time: -1, + }), + }) + } else if (hasLimit && hadLimit) { + // Update existing limit + await fetch(`/api/node-user-limits/${node.current_limit_id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + data_limit: node.data_limit, + data_limit_reset_strategy: 'no_reset', + reset_time: -1, + }), + }) + } else if (!hasLimit && hadLimit) { + // Delete limit + await fetch(`/api/node-user-limits/${node.current_limit_id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }) + } + } + + toast.success(t('saved_successfully')) + setHasChanges(false) + + // Refresh data + const limitsResponse = await fetch(`/api/node-user-limits/user/${userId}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + const limitsData = await limitsResponse.json() + + interface LimitResult { + data_limit: number + limit_id: number + } + + const limitsMap = new Map( + (limitsData.limits || []).map((limit: { node_id: number; data_limit: number; id: number }) => [limit.node_id, { data_limit: limit.data_limit, limit_id: limit.id }]), + ) + setNodeLimits(prev => + prev.map(node => ({ + ...node, + data_limit: limitsMap.get(node.node_id)?.data_limit || null, + current_limit_id: limitsMap.get(node.node_id)?.limit_id, + })), + ) + } catch (error) { + console.error('Failed to save limits:', error) + toast.error(t('failed_to_save')) + } finally { + setSaving(false) + } + } + + if (!isEditMode) { + return
{t('per_node_limits_only_edit', { defaultValue: 'Per-node limits can only be set when editing an existing user.' })}
+ } + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+
+ {t('per_node_limits_description', { + defaultValue: 'Set individual data limits for each node. Leave empty for no limit.', + })} +
+ +
+ {nodeLimits.map(node => { + const gbValue = node.data_limit !== null ? bytesToGb(node.data_limit) : '' + + return ( +
+
+ +
+
+ handleLimitChange(node.node_id, e.target.value)} placeholder="No limit" className="h-8 w-24 text-sm" /> + GB + {node.data_limit !== null && ( + + )} +
+
+ ) + })} +
+ + {hasChanges && ( +
+ +
+ )} +
+ ) +} diff --git a/dashboard/src/components/users/traffic-modal.tsx b/dashboard/src/components/users/traffic-modal.tsx new file mode 100644 index 000000000..bd713a20b --- /dev/null +++ b/dashboard/src/components/users/traffic-modal.tsx @@ -0,0 +1,115 @@ +import React, { useState, useEffect } from 'react' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' +import { formatBytes } from '@/utils/formatByte' +import { Progress } from '@/components/ui/progress' +import { Loader2 } from 'lucide-react' +import { useTranslation } from 'react-i18next' + +interface NodeTrafficData { + node_id: number + node_name: string + used_traffic: number + data_limit: number | null + has_limit: boolean +} + +interface NodeTrafficResponse { + nodes: NodeTrafficData[] +} + +interface TrafficModalProps { + username: string + isOpen: boolean + onClose: () => void +} + +export const TrafficModal: React.FC = ({ username, isOpen, onClose }) => { + const { t } = useTranslation() + const [loading, setLoading] = useState(false) + const [nodeData, setNodeData] = useState([]) + const [error, setError] = useState(null) + const [dataLoaded, setDataLoaded] = useState(false) + + useEffect(() => { + if (!isOpen || dataLoaded) return + + const loadNodeTraffic = async () => { + setLoading(true) + setError(null) + try { + const token = localStorage.getItem('token') + const response = await fetch(`/api/user/${username}/node-traffic`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + + if (!response.ok) { + throw new Error('Failed to load node traffic') + } + + const data: NodeTrafficResponse = await response.json() + setNodeData(data.nodes || []) + setDataLoaded(true) + } catch (err) { + console.error('Failed to load node traffic:', err) + setError(t('failed_to_load_data')) + } finally { + setLoading(false) + } + } + + loadNodeTraffic() + }, [isOpen, dataLoaded, username, t]) + + return ( + !open && onClose()}> + e.stopPropagation()}> + + {t('traffic_by_node')} + {t('traffic_details_for', { username })} + + +
+ {loading && ( +
+ + {t('loading')}... +
+ )} + + {error &&
{error}
} + + {!loading && !error && nodeData.length === 0 &&
{t('no_traffic_data')}
} + + {!loading && !error && nodeData.length > 0 && ( +
+ {nodeData.map(node => { + const hasLimit = node.has_limit && node.data_limit !== null && node.data_limit > 0 + const percentage = hasLimit ? Math.min((node.used_traffic / node.data_limit!) * 100, 100) : 0 + + return ( +
+
+ {node.node_name} +
+ {formatBytes(node.used_traffic)} + {hasLimit && ( + <> + / + {formatBytes(node.data_limit!)} + + )} +
+
+ {hasLimit ? :
} +
+ ) + })} +
+ )} +
+ +
+ ) +} diff --git a/dashboard/src/components/users/usage-slider-compact.tsx b/dashboard/src/components/users/usage-slider-compact.tsx index ad94ddfc8..62a3b4716 100644 --- a/dashboard/src/components/users/usage-slider-compact.tsx +++ b/dashboard/src/components/users/usage-slider-compact.tsx @@ -4,6 +4,8 @@ import { formatBytes } from '@/utils/formatByte' import { useTranslation } from 'react-i18next' import { Progress } from '@/components/ui/progress' import useDirDetection from '@/hooks/use-dir-detection' +import { useState } from 'react' +import { TrafficModal } from './traffic-modal' type UsageSliderProps = { used: number @@ -11,26 +13,38 @@ type UsageSliderProps = { totalUsedTraffic: number | undefined status: string isMobile?: boolean + username?: string } -const UsageSliderCompact: React.FC = ({ used, total = 0, status, totalUsedTraffic, isMobile }) => { +const UsageSliderCompact: React.FC = ({ used, total = 0, status, totalUsedTraffic, isMobile, username }) => { const isUnlimited = total === 0 || total === null const progressValue = isUnlimited ? 100 : (used / total) * 100 const color = statusColors[status]?.sliderColor const { t } = useTranslation() const isRTL = useDirDetection() === 'rtl' + const [isModalOpen, setIsModalOpen] = useState(false) + return ( -
- -
- - {formatBytes(used)} / {isUnlimited ? : formatBytes(total)} - -
- {t('usersTable.total')}: {formatBytes(totalUsedTraffic || 0)} +
e.stopPropagation()}> +
{ + if (username) setIsModalOpen(true) + }} + > + +
+ + {formatBytes(used)} / {isUnlimited ? : formatBytes(total)} + +
+ {t('usersTable.total')}: {formatBytes(totalUsedTraffic || 0)} +
+ {username && setIsModalOpen(false)} />}
) } + export default UsageSliderCompact diff --git a/dashboard/src/locales/en/node-user-limits.json b/dashboard/src/locales/en/node-user-limits.json new file mode 100644 index 000000000..7384edca7 --- /dev/null +++ b/dashboard/src/locales/en/node-user-limits.json @@ -0,0 +1,31 @@ +{ + "nodeUserLimits": { + "title": "User Traffic Limits", + "description": "Manage per-user traffic limits for this node", + "dialog": { + "createTitle": "Create Traffic Limit", + "editTitle": "Edit Traffic Limit", + "description": "Set a traffic limit for a specific user on this node.", + "user": "User", + "selectUser": "Select a user", + "node": "Node", + "selectNode": "Select a node", + "dataLimit": "Data Limit", + "dataLimitHint": "Enter 0 for unlimited traffic" + }, + "table": { + "id": "ID", + "user": "User", + "node": "Node", + "dataLimit": "Data Limit", + "noLimits": "No traffic limits configured", + "noLimitsHint": "Click the button above to create a limit" + }, + "addLimit": "Add Limit", + "deleteConfirm": "Are you sure you want to delete this limit?", + "deleteSuccess": "Limit deleted successfully", + "createSuccess": "Limit created successfully", + "updateSuccess": "Limit updated successfully", + "error": "An error occurred" + } +} diff --git a/dashboard/src/pages/_dashboard.templates.tsx b/dashboard/src/pages/_dashboard.templates.tsx index 746dd4024..94d8544a7 100644 --- a/dashboard/src/pages/_dashboard.templates.tsx +++ b/dashboard/src/pages/_dashboard.templates.tsx @@ -59,6 +59,7 @@ export default function UserTemplates() { on_hold_timeout: typeof userTemplate.on_hold_timeout === 'number' ? userTemplate.on_hold_timeout : undefined, data_limit_reset_strategy: userTemplate.data_limit_reset_strategy || undefined, reset_usages: userTemplate.reset_usages || false, + node_user_limits: userTemplate.node_user_limits ? (userTemplate.node_user_limits as any) : undefined, }) setIsDialogOpen(true) @@ -139,7 +140,7 @@ export default function UserTemplates() {
{/* Search Input */} -
+
setSearchQuery(e.target.value)} className={cn('pl-8 pr-10', dir === 'rtl' && 'pl-10 pr-8')} /> @@ -210,7 +211,9 @@ export default function UserTemplates() { className="mb-12 grid transform-gpu animate-slide-up grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3" style={{ animationDuration: '500ms', animationDelay: '100ms', animationFillMode: 'both' }} > - {filteredTemplates?.map((template: UserTemplateResponse) => )} + {filteredTemplates?.map((template: UserTemplateResponse) => ( + + ))}
)}
diff --git a/dashboard/src/service/api/index.ts b/dashboard/src/service/api/index.ts index adc10b115..4cb51d29c 100644 --- a/dashboard/src/service/api/index.ts +++ b/dashboard/src/service/api/index.ts @@ -392,6 +392,12 @@ export interface UserUsageStatsList { stats: UserUsageStatsListStats } +export interface NodeLimitSettings { + data_limit?: number | null + data_limit_reset_strategy?: DataLimitResetStrategy + reset_time?: number +} + export type UserTemplateResponseIsDisabled = boolean | null export type UserTemplateResponseOnHoldTimeout = number | null @@ -432,6 +438,7 @@ export interface UserTemplateResponse { reset_usages?: UserTemplateResponseResetUsages on_hold_timeout?: UserTemplateResponseOnHoldTimeout data_limit_reset_strategy?: DataLimitResetStrategy + node_user_limits?: { [key: number]: NodeLimitSettings | number } | null is_disabled?: UserTemplateResponseIsDisabled id: number } @@ -478,6 +485,7 @@ export interface UserTemplateModify { reset_usages?: UserTemplateModifyResetUsages on_hold_timeout?: UserTemplateModifyOnHoldTimeout data_limit_reset_strategy?: DataLimitResetStrategy + node_user_limits?: { [key: number]: NodeLimitSettings | number } | null is_disabled?: UserTemplateModifyIsDisabled } @@ -521,6 +529,7 @@ export interface UserTemplateCreate { reset_usages?: UserTemplateCreateResetUsages on_hold_timeout?: UserTemplateCreateOnHoldTimeout data_limit_reset_strategy?: DataLimitResetStrategy + node_user_limits?: { [key: number]: NodeLimitSettings | number } | null is_disabled?: UserTemplateCreateIsDisabled } @@ -1301,6 +1310,9 @@ export interface NodeResponse { downlink?: number lifetime_uplink?: NodeResponseLifetimeUplink lifetime_downlink?: NodeResponseLifetimeDownlink + user_data_limit?: number + user_data_limit_reset_strategy?: DataLimitResetStrategy + user_reset_time?: number } export interface NodesResponse { @@ -1373,7 +1385,11 @@ export interface NodeModify { reset_time?: NodeModifyResetTime default_timeout?: NodeModifyDefaultTimeout internal_timeout?: NodeModifyInternalTimeout + status?: NodeModifyStatus + user_data_limit?: number | null + user_data_limit_reset_strategy?: DataLimitResetStrategy | null + user_reset_time?: number | null } export interface NodeGeoFilesUpdate { @@ -1405,7 +1421,7 @@ export interface NodeCreate { keep_alive: number core_config_id: number api_key: string - data_limit?: number + data_limit?: number | null data_limit_reset_strategy?: DataLimitResetStrategy reset_time?: number /** @@ -1418,6 +1434,9 @@ export interface NodeCreate { * @maximum 60 */ internal_timeout?: number + user_data_limit?: number | null + user_data_limit_reset_strategy?: DataLimitResetStrategy + user_reset_time?: number } export type NextPlanModelExpire = number | null @@ -5463,6 +5482,60 @@ export const useResetUserDataUsage = < return useMutation(mutationOptions) } +export interface ResetNodeUsageRequest { + node_ids: number[] +} + +/** + * Reset user data usage for specific nodes + * @summary Reset User Node Usage + */ +export const resetUserNodeUsage = (username: string, resetNodeUsageRequest: BodyType, signal?: AbortSignal) => { + return orvalFetcher({ url: `/api/user/${username}/reset-usage-by-node`, method: 'POST', headers: { 'Content-Type': 'application/json' }, data: resetNodeUsageRequest, signal }) +} + +export const getResetUserNodeUsageMutationOptions = < + TData = Awaited>, + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions }, TContext> +}) => { + const mutationKey = ['resetUserNodeUsage'] + const { mutation: mutationOptions } = options + ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } } + + const mutationFn: MutationFunction>, { username: string; data: BodyType }> = props => { + const { username, data } = props ?? {} + + return resetUserNodeUsage(username, data) + } + + return { mutationFn, ...mutationOptions } as UseMutationOptions }, TContext> +} + +export type ResetUserNodeUsageMutationResult = NonNullable>> +export type ResetUserNodeUsageMutationBody = BodyType +export type ResetUserNodeUsageMutationError = ErrorType + +/** + * @summary Reset User Node Usage + */ +export const useResetUserNodeUsage = < + TData = Awaited>, + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions }, TContext> +}): UseMutationResult }, TContext> => { + const mutationOptions = getResetUserNodeUsageMutationOptions(options) + + return useMutation(mutationOptions) +} + /** * Revoke users subscription (Subscription link and proxies) * @summary Revoke User Subscription From 22a1f981398c2f1e7aa26d7fe40d77840507f106 Mon Sep 17 00:00:00 2001 From: Rerowros Date: Sat, 17 Jan 2026 21:34:26 +0700 Subject: [PATCH 09/11] test: add integration tests for per-node limits - Add test_template_fix.py for node_user_limits template tests - Add test_limits_fix.py for node data limit tests - Update conftest and helpers for test infrastructure --- docker-compose.yml | 1 + tests/api/__init__.py | 41 ++++------ tests/api/conftest.py | 6 ++ tests/api/helpers.py | 13 ++- tests/api/test_limits_fix.py | 139 ++++++++++++++++++++++++++++++++ tests/api/test_template_fix.py | 95 ++++++++++++++++++++++ tests/api/test_user_template.py | 3 + 7 files changed, 270 insertions(+), 28 deletions(-) create mode 100644 tests/api/test_limits_fix.py create mode 100644 tests/api/test_template_fix.py diff --git a/docker-compose.yml b/docker-compose.yml index 0d6d1ab2e..e8fbf52e5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,6 @@ services: pasarguard: + build: . image: pasarguard/panel:latest restart: always env_file: .env diff --git a/tests/api/__init__.py b/tests/api/__init__.py index 837de430c..ff2d3a6df 100644 --- a/tests/api/__init__.py +++ b/tests/api/__init__.py @@ -3,45 +3,27 @@ from decouple import config from fastapi.testclient import TestClient -from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -from sqlalchemy.pool import NullPool, StaticPool +from sqlalchemy.exc import SQLAlchemyError from app.db import base -from config import SQLALCHEMY_DATABASE_URL +from app.db.base import SessionLocal as TestSession + +from .helpers import create_admin, unique_name XRAY_JSON_TEST_FILE = "tests/api/xray_config-test.json" TEST_FROM = config("TEST_FROM", default="local") -DATABASE_URL = "sqlite+aiosqlite:///:memory:" if TEST_FROM == "local" else SQLALCHEMY_DATABASE_URL +DATABASE_URL = "sqlite+aiosqlite://?cache=shared" print(f"TEST_FROM: {TEST_FROM}") print(f"DATABASE_URL: {DATABASE_URL}") -IS_SQLITE = DATABASE_URL.startswith("sqlite") - -if IS_SQLITE: - engine = create_async_engine( - DATABASE_URL, - connect_args={"check_same_thread": False, "uri": True}, - poolclass=StaticPool, - # echo=True, - ) -else: - engine = create_async_engine( - DATABASE_URL, - poolclass=NullPool, # Important for tests - # echo=True, # For debugging - ) -TestSession = async_sessionmaker(autocommit=False, autoflush=False, bind=engine) - async def create_tables(): - async with engine.begin() as conn: - await conn.run_sync(base.Base.metadata.create_all) + from app.db import base + from app.db.models import Admin # noqa - -if TEST_FROM == "local": - asyncio.run(create_tables()) + async with base.engine.begin() as conn: + await conn.run_sync(base.Base.metadata.create_all) class GetTestDB: @@ -68,6 +50,11 @@ async def get_test_db(): app.dependency_overrides[base.get_db] = get_test_db +if TEST_FROM == "local": + import app.db.models as _models # noqa + print(f"Metadata tables: {base.Base.metadata.tables.keys()}") + asyncio.run(create_tables()) + with open(XRAY_JSON_TEST_FILE, "w") as f: f.write( diff --git a/tests/api/conftest.py b/tests/api/conftest.py index cf283c92e..3bbba7b82 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -23,6 +23,12 @@ def mock_lock(monkeypatch: pytest.MonkeyPatch): @pytest.fixture(autouse=True) def mock_settings(monkeypatch: pytest.MonkeyPatch): + async def mock_get_jwt_secret_key(db): + return "test_secret_key" + + monkeypatch.setattr("app.utils.jwt.get_jwt_secret_key", mock_get_jwt_secret_key) + monkeypatch.setattr("app.db.crud.general.get_jwt_secret_key", mock_get_jwt_secret_key) + settings = { "telegram": {"enable": False, "token": "", "webhook_url": "", "webhook_secret": None, "proxy_url": None}, "discord": None, diff --git a/tests/api/helpers.py b/tests/api/helpers.py index 79b7fe53c..8cead9766 100644 --- a/tests/api/helpers.py +++ b/tests/api/helpers.py @@ -5,7 +5,6 @@ from fastapi import status -from tests.api import client from tests.api.sample_data import XRAY_CONFIG @@ -26,6 +25,7 @@ def strong_password(prefix: str) -> str: def create_admin( access_token: str, *, username: str | None = None, password: str | None = None, is_sudo: bool = False ) -> dict: + from tests.api import client username = username or unique_name("admin") # Ensure password always meets complexity rules (>=2 digits, 2 uppercase, 2 lowercase, special char) password = password or strong_password("TestAdmincreate") @@ -41,6 +41,7 @@ def create_admin( def delete_admin(access_token: str, username: str) -> None: + from tests.api import client response = client.delete(f"/api/admin/{username}", headers=auth_headers(access_token)) assert response.status_code == status.HTTP_204_NO_CONTENT @@ -53,6 +54,7 @@ def create_core( exclude: Iterable[str] | None = None, fallbacks: Iterable[str] | None = None, ) -> dict: + from tests.api import client payload = { "config": config or XRAY_CONFIG, "name": name or unique_name("core"), @@ -65,12 +67,14 @@ def create_core( def delete_core(access_token: str, core_id: int) -> None: + from tests.api import client response = client.delete(f"/api/core/{core_id}", headers=auth_headers(access_token)) assert response.status_code in (status.HTTP_204_NO_CONTENT, status.HTTP_403_FORBIDDEN) def get_inbounds(access_token: str) -> list[str]: + from tests.api import client response = client.get("/api/inbounds", headers=auth_headers(access_token)) if response.status_code == status.HTTP_200_OK: return response.json() @@ -88,6 +92,7 @@ def get_inbounds(access_token: str) -> list[str]: def create_hosts_for_inbounds(access_token: str, *, address: list[str] | None = None, port: int = 443) -> list[dict]: + from tests.api import client inbounds = get_inbounds(access_token) hosts: list[dict] = [] for idx, inbound in enumerate(inbounds): @@ -106,6 +111,7 @@ def create_hosts_for_inbounds(access_token: str, *, address: list[str] | None = def create_group(access_token: str, *, name: str | None = None, inbound_tags: Iterable[str] | None = None) -> dict: + from tests.api import client tags = list(inbound_tags or []) if not tags: tags = get_inbounds(access_token) @@ -119,6 +125,7 @@ def create_group(access_token: str, *, name: str | None = None, inbound_tags: It def delete_group(access_token: str, group_id: int) -> None: + from tests.api import client response = client.delete(f"/api/group/{group_id}", headers=auth_headers(access_token)) assert response.status_code == status.HTTP_204_NO_CONTENT @@ -130,6 +137,7 @@ def create_user( group_ids: Iterable[int] | None = None, payload: dict[str, Any] | None = None, ) -> dict: + from tests.api import client body = { "username": username or unique_name("user"), "proxy_settings": {}, @@ -147,6 +155,7 @@ def create_user( def delete_user(access_token: str, username: str) -> None: + from tests.api import client response = client.delete(f"/api/user/{username}", headers=auth_headers(access_token)) assert response.status_code == status.HTTP_204_NO_CONTENT @@ -162,6 +171,7 @@ def create_user_template( status_value: str = "active", reset_usages: bool = True, ) -> dict: + from tests.api import client payload = { "name": name or unique_name("user_template"), "group_ids": list(group_ids), @@ -177,5 +187,6 @@ def create_user_template( def delete_user_template(access_token: str, template_id: int) -> None: + from tests.api import client response = client.delete(f"/api/user_template/{template_id}", headers=auth_headers(access_token)) assert response.status_code == status.HTTP_204_NO_CONTENT diff --git a/tests/api/test_limits_fix.py b/tests/api/test_limits_fix.py new file mode 100644 index 000000000..c8e440138 --- /dev/null +++ b/tests/api/test_limits_fix.py @@ -0,0 +1,139 @@ + +import pytest +from app.models.node import Node, NodeCreate, NodeModify, DataLimitResetStrategy +from app.models.user_template import UserTemplateCreate, UserTemplate +from httpx import AsyncClient + +# Test script to verify data limit fixes (User 0GB issue and Node Modify issue) + +@pytest.mark.asyncio +async def test_node_limits_defaults(client: AsyncClient, get_panel_api_headers): + """ + Test 1: Create a Node. Verify user_data_limit is None (Unlimited) by default. + """ + # Create a dummy node payload + node_data = { + "name": "Test Node Limit", + "address": "127.0.0.1", + "port": 8081, + "api_port": 8082, + "connection_type": "grpc", + "server_ca": "test-ca", + "keep_alive": 40, + "api_key": "test-key", + "core_config_id": 1 # Assuming core config 1 exists, if not we might need to create it or mock + } + + # Needs a core config. Let's assume standard test setup provides it or we catch error. + # In integration tests, we usually have `setup_core_config` fixture or similar. + # We will try to rely on existing fixtures or just see if it works. + # If standard fixtures are needed, we might need to import them or use what's available. + + # We will assume a core config 1 might not exist. + # Lets try to create one first if we stick to strict integration + + # Create Core Config + core_payload = { + "name": "Test Core Limits", + "config": {} + } + core_res = await client.post("/api/core", json=core_payload, headers=get_panel_api_headers) + if core_res.status_code == 200: + core_id = core_res.json()["id"] + node_data["core_config_id"] = core_id + else: + # Fallback or fail. If fail, maybe core 1 exists. + pass + + response = await client.post("/api/node", json=node_data, headers=get_panel_api_headers) + assert response.status_code == 200, f"Node creation failed: {response.text}" + + node = response.json() + node_id = node["id"] + + # VERIFY: user_data_limit should be None + assert node["user_data_limit"] is None, f"Expected user_data_limit to be None, got {node['user_data_limit']}" + assert node["data_limit"] is None, f"Expected data_limit to be None, got {node['data_limit']}" + + # Test 2: Modify Node. Set user_data_limit to 10GB (10 * 1024^3). Verify. + limit_10gb = 10 * 1024 * 1024 * 1024 + modify_payload = { + "user_data_limit": limit_10gb + } + res_mod = await client.put(f"/api/node/{node_id}", json=modify_payload, headers=get_panel_api_headers) + assert res_mod.status_code == 200 + node_mod = res_mod.json() + assert node_mod["user_data_limit"] == limit_10gb, "Failed to set user_data_limit" + + # Test 3: Modify Node. Set user_data_limit to None. Verify it goes back to None. + # The frontend sends null. + modify_payload_none = { + "user_data_limit": None + } + res_mod_none = await client.put(f"/api/node/{node_id}", json=modify_payload_none, headers=get_panel_api_headers) + assert res_mod_none.status_code == 200, f"Failed to unset limit: {res_mod_none.text}" + node_mod_none = res_mod_none.json() + assert node_mod_none["user_data_limit"] is None, "Failed to revert user_data_limit to None" + + # Cleanup + await client.delete(f"/api/node/{node_id}", headers=get_panel_api_headers) + +@pytest.mark.asyncio +async def test_user_template_node_limits(client: AsyncClient, get_panel_api_headers): + """ + Test 4: Create User Template with node_user_limits. Verify success. + """ + # Create User Template with node limits + # Assuming node 1 exists or we can use a random ID (the validation might check existence?) + # Usually template validation doesn't check if node exists strictly unless FK enforced? + # But let's create a node to be safe. + + # Recreate node logic from above roughly + core_payload = {"name": "Test Core Tpl", "config": {}} + core_res = await client.post("/api/core", json=core_payload, headers=get_panel_api_headers) + try: + core_id = core_res.json().get("id", 1) + except: + core_id = 1 + + node_data = { + "name": "Test Node Tpl", + "address": "127.0.0.1", + "port": 8083, + "connection_type": "grpc", + "server_ca": "test-ca", + "keep_alive": 40, + "api_key": "test-key", + "core_config_id": core_id + } + node_res = await client.post("/api/node", json=node_data, headers=get_panel_api_headers) + if node_res.status_code == 200: + node_id = node_res.json()["id"] + else: + # Fallback + node_id = 1 + + template_payload = { + "name": "Test Limit Template", + "data_limit": 5 * 1024 * 1024 * 1024, # 5GB + "expire_duration": 30 * 24 * 3600, + "group_ids": [], + "node_user_limits": { + str(node_id): { + "data_limit": 1024 * 1024 * 1024, # 1GB + "data_limit_reset_strategy": "month" + } + } + } + + res_tpl = await client.post("/api/user_template", json=template_payload, headers=get_panel_api_headers) + assert res_tpl.status_code == 200, f"Template creation failed: {res_tpl.text}" + + tpl = res_tpl.json() + assert str(node_id) in tpl["node_user_limits"] or node_id in tpl["node_user_limits"] + + # Cleanup + await client.delete(f"/api/user_template/{tpl['id']}", headers=get_panel_api_headers) + if node_res.status_code == 200: + await client.delete(f"/api/node/{node_id}", headers=get_panel_api_headers) + diff --git a/tests/api/test_template_fix.py b/tests/api/test_template_fix.py new file mode 100644 index 000000000..652b2fc2f --- /dev/null +++ b/tests/api/test_template_fix.py @@ -0,0 +1,95 @@ + +import pytest +from tests.api import client +from fastapi import status + +def test_user_template_node_limits_persistence(access_token): + headers = {"Authorization": f"Bearer {access_token}"} + + # 1. Create a dummy node (if not exists) + # We can use the existing /api/node endpoint synchronously + node_data = { + "name": "Test Node Tpl Fix Sync", + "address": "127.0.0.1", + "port": 9091, + "connection_type": "grpc", + "server_ca": "test-ca", + "keep_alive": 40, + "api_key": "test-key-tpl-sync", + "core_config_id": 1 + } + + # Check if we can list nodes + nodes_res = client.get("/api/node", headers=headers) + node_id = None + if nodes_res.status_code == 200 and nodes_res.json()["total"] > 0: + node_id = nodes_res.json()["nodes"][0]["id"] + else: + # Create core first + core_payload = {"name": "Test Core Tpl Sync", "config": {}} + c_res = client.post("/api/core", json=core_payload, headers=headers) + print(f"DEBUG CORE CREATE: {c_res.status_code} {c_res.text}") + + node_res = client.post("/api/node", json=node_data, headers=headers) + print(f"DEBUG NODE CREATE: {node_res.status_code} {node_res.text}") + if node_res.status_code == 200: + node_id = node_res.json()["id"] + + if not node_id: + print("DEBUG: SKIPPING DUE TO NO NODE ID") + pytest.skip("Could not create or find a node for testing") + + # 2. Create User Template with limits + limit_bytes = 1024 * 1024 * 1024 # 1GB + template_payload = { + "name": "Test Limit Persistence Sync", + "group_ids": [], + "node_user_limits": { + str(node_id): { + "data_limit": limit_bytes, + "data_limit_reset_strategy": "month" + } + } + } + + res = client.post("/api/user_template", json=template_payload, headers=headers) + assert res.status_code == 201, f"Create failed: {res.text}" + tpl = res.json() + print(f"DEBUG RESPONSE: {tpl}") + + # 3. Verify Response has limits + assert tpl.get("node_user_limits") is not None, "node_user_limits is None in response" + assert str(node_id) in tpl["node_user_limits"], "node_id not in node_user_limits" + + saved_limit = tpl["node_user_limits"][str(node_id)] + if isinstance(saved_limit, dict): + assert saved_limit["data_limit"] == limit_bytes + else: + assert saved_limit == limit_bytes + + # 4. Verify Fetch has limits + res_get = client.get(f"/api/user_template/{tpl['id']}", headers=headers) + assert res_get.status_code == 200 + tpl_get = res_get.json() + + assert tpl_get.get("node_user_limits") is not None, "node_user_limits is None in GET response" + assert str(node_id) in tpl_get["node_user_limits"] + + # 5. Modify Template (Change limit) + new_limit_bytes = 2 * 1024 * 1024 * 1024 + modify_payload = { + "node_user_limits": { + str(node_id): { + "data_limit": new_limit_bytes, + "data_limit_reset_strategy": "week" + } + } + } + res_mod = client.put(f"/api/user_template/{tpl['id']}", json=modify_payload, headers=headers) + assert res_mod.status_code == 200 + tpl_mod = res_mod.json() + + assert tpl_mod["node_user_limits"][str(node_id)]["data_limit"] == new_limit_bytes + + # Cleanup + client.delete(f"/api/user_template/{tpl['id']}", headers=headers) diff --git a/tests/api/test_user_template.py b/tests/api/test_user_template.py index 69ccd2e27..f8c98851c 100644 --- a/tests/api/test_user_template.py +++ b/tests/api/test_user_template.py @@ -2,6 +2,7 @@ from tests.api import client from tests.api.helpers import ( + auth_headers, create_core, create_group, create_user_template, @@ -117,3 +118,5 @@ def test_user_template_delete(access_token): assert response.status_code == status.HTTP_204_NO_CONTENT finally: cleanup_groups(access_token, core, groups) + + cleanup_groups(access_token, core, groups) From f9e14bc972751c00c9030f08e371ee1b293fb87f Mon Sep 17 00:00:00 2001 From: Rerowros Date: Sat, 17 Jan 2026 21:43:11 +0700 Subject: [PATCH 10/11] fix: apply per-node limits when modifying user via template - Add _apply_node_limits_from_template call to modify_user_with_template - Add traffic_by_node translation to en.json and ru.json --- app/operation/user.py | 17 ++++++++++++++++- dashboard/public/statics/locales/en.json | 3 ++- dashboard/public/statics/locales/ru.json | 3 ++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/app/operation/user.py b/app/operation/user.py index 0a2255375..b35518c3e 100644 --- a/app/operation/user.py +++ b/app/operation/user.py @@ -778,7 +778,22 @@ async def modify_user_with_template( if user_template.reset_usages: await self._reset_user_data_usage(db, db_user, admin) - return await self._modify_user(db, db_user, modify_user, admin) + user_response = await self._modify_user(db, db_user, modify_user, admin) + + # Apply per-node limits from template (if configured) + node_user_limits = await user_template.awaitable_attrs.node_user_limits + if node_user_limits: + from app.db.crud.user import get_user + db_user_updated = await get_user(db, user_response.username) + if db_user_updated: + try: + await self._apply_node_limits_from_template( + db, db_user_updated, node_user_limits, user_template.data_limit_reset_strategy + ) + except Exception as e: + logger.error(f"Error applying template node limits to user {user_response.username}: {e}", exc_info=True) + + return user_response async def bulk_create_users_from_template( self, db: AsyncSession, bulk_users: BulkUsersFromTemplate, admin: AdminDetails diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index 8d61ec1f9..ebaeea762 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -1982,5 +1982,6 @@ "updateCommandLabel": "Connect via SSH and run:", "closeBanner": "Close update notification", "needsUpdate": "Needs update" - } + }, + "traffic_by_node": "Traffic by Node" } diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index 297c7f3cc..6879bdd39 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -1937,5 +1937,6 @@ "updateCommandLabel": "Подключитесь по SSH и выполните:", "closeBanner": "Закрыть уведомление об обновлении", "needsUpdate": "Требуется обновление" - } + }, + "traffic_by_node": "Трафик по узлам" } From a134d05e57707ffd68a8c6398596a6f9fbe208da Mon Sep 17 00:00:00 2001 From: Rerowros Date: Sat, 17 Jan 2026 21:59:34 +0700 Subject: [PATCH 11/11] feat(subscription): add per-node usage variables for hosts - Add NODE_USAGE_N, NODE_LIMIT_N, NODE_LEFT_N, NODE_NAME_N variables - Add node_traffic field to UsersResponseWithInbounds - Load per-node usage and limits in validated_user - Variables can be used in host remarks like {NODE_USAGE_1}/{ NODE_LIMIT_1} --- app/models/user.py | 1 + app/operation/subscription.py | 43 +++++++++++++++++++++++++++++++---- app/subscription/share.py | 17 ++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/app/models/user.py b/app/models/user.py index 0209c8689..1ad6ffc1d 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -132,6 +132,7 @@ class SubscriptionUserResponse(UserResponse): class UsersResponseWithInbounds(SubscriptionUserResponse): inbounds: list[str] | None = Field(default_factory=list) + node_traffic: list["UserNodeTraffic"] | None = Field(default_factory=list) model_config = ConfigDict(from_attributes=True) diff --git a/app/operation/subscription.py b/app/operation/subscription.py index 28231b948..62e06a496 100644 --- a/app/operation/subscription.py +++ b/app/operation/subscription.py @@ -31,12 +31,47 @@ class SubscriptionOperation(BaseOperation): @staticmethod - async def validated_user(db_user: User) -> UsersResponseWithInbounds: + async def validated_user(db_user: User, db: AsyncSession | None = None) -> UsersResponseWithInbounds: user = UsersResponseWithInbounds.model_validate(db_user.__dict__) user.inbounds = await db_user.inbounds() user.expire = db_user.expire user.lifetime_used_traffic = db_user.lifetime_used_traffic + # Load per-node traffic data if db session available + if db: + from app.db.crud import node_user_limit as limit_crud + from app.db.models import NodeUserUsage, Node + from app.models.user import UserNodeTraffic + from sqlalchemy import select + from sqlalchemy.orm import selectinload + + # Get per-node usage + usage_stmt = select(NodeUserUsage).where(NodeUserUsage.user_id == db_user.id) + usage_result = await db.execute(usage_stmt) + usages = {u.node_id: u.used_traffic for u in usage_result.scalars().all()} + + # Get per-node limits + limits = await limit_crud.get_node_limits_for_user(db, db_user.id) + limits_map = {l.node_id: l.data_limit for l in limits} + + # Get node names + nodes_stmt = select(Node.id, Node.name) + nodes_result = await db.execute(nodes_stmt) + nodes_map = {row[0]: row[1] for row in nodes_result.fetchall()} + + # Build node_traffic list + all_node_ids = set(usages.keys()) | set(limits_map.keys()) + node_traffic = [] + for node_id in all_node_ids: + node_traffic.append(UserNodeTraffic( + node_id=node_id, + node_name=nodes_map.get(node_id, f"Node {node_id}"), + used_traffic=usages.get(node_id, 0), + data_limit=limits_map.get(node_id), + has_limit=node_id in limits_map + )) + user.node_traffic = node_traffic + return user @staticmethod @@ -143,7 +178,7 @@ async def user_subscription( # Handle HTML request (subscription page) sub_settings: SubSettings = await subscription_settings() db_user = await self.get_validated_sub(db, token) - user = await self.validated_user(db_user) + user = await self.validated_user(db_user, db) is_browser_request = "text/html" in accept_header @@ -207,7 +242,7 @@ async def user_subscription_with_client_type( if client_type == ConfigFormat.block or not getattr(sub_settings.manual_sub_request, client_type): await self.raise_error(message="Client not supported", code=406) db_user = await self.get_validated_sub(db, token=token) - user = await self.validated_user(db_user) + user = await self.validated_user(db_user, db) response_headers = self.create_response_headers(user, request_url, sub_settings) conf, media_type = await self.fetch_config(user, client_type) @@ -219,7 +254,7 @@ async def user_subscription_info(self, db: AsyncSession, token: str) -> tuple[Su """Retrieves detailed information about the user's subscription.""" sub_settings: SubSettings = await subscription_settings() db_user = await self.get_validated_sub(db, token=token) - user = await self.validated_user(db_user) + user = await self.validated_user(db_user, db) response_headers = self.create_info_response_headers(user, sub_settings) user_response = SubscriptionUserResponse.model_validate(db_user) diff --git a/app/subscription/share.py b/app/subscription/share.py index 31e4bf6db..bd17a9399 100644 --- a/app/subscription/share.py +++ b/app/subscription/share.py @@ -158,6 +158,23 @@ def setup_format_variables(user: UsersResponseWithInbounds) -> dict: }, ) + # Add per-node variables: NODE_USAGE_1, NODE_LIMIT_1, NODE_LEFT_1, NODE_NAME_1, etc. + if hasattr(user, 'node_traffic') and user.node_traffic: + for node_data in user.node_traffic: + node_id = node_data.node_id + node_usage = readable_size(node_data.used_traffic) + node_limit = readable_size(node_data.data_limit) if node_data.data_limit else "∞" + if node_data.data_limit: + node_left_bytes = node_data.data_limit - node_data.used_traffic + node_left = readable_size(max(0, node_left_bytes)) + else: + node_left = "∞" + + format_variables[f"NODE_USAGE_{node_id}"] = node_usage + format_variables[f"NODE_LIMIT_{node_id}"] = node_limit + format_variables[f"NODE_LEFT_{node_id}"] = node_left + format_variables[f"NODE_NAME_{node_id}"] = node_data.node_name + return format_variables