diff --git a/app/db/crud.py b/app/db/crud.py index 94b21cf..1878726 100644 --- a/app/db/crud.py +++ b/app/db/crud.py @@ -309,7 +309,7 @@ def create_user(db: Session, user: UserCreate, admin: Admin = None): # proxies=proxies, key=user.key, services=db.query(Service) - .filter(Service.id.in_(user.services)) + .filter(Service.id.in_(user.service_ids)) .all(), # user.services, status=user.status, data_limit=(user.data_limit or None), @@ -383,9 +383,9 @@ def update_user(db: Session, dbuser: User, modify: UserModify): if modify.on_hold_expire_duration is not None: dbuser.on_hold_expire_duration = modify.on_hold_expire_duration - if modify.services is not None: + if modify.service_ids is not None: dbuser.services = ( - db.query(Service).filter(Service.id.in_(modify.services)).all() + db.query(Service).filter(Service.id.in_(modify.service_ids)).all() ) dbuser.edit_at = datetime.utcnow() @@ -547,8 +547,8 @@ def get_admins( def create_service(db: Session, service: ServiceCreate) -> Service: dbservice = Service( name=service.name, - inbounds=db.query(Inbound).filter(Inbound.id.in_(service.inbounds)).all(), - users=db.query(User).filter(User.id.in_(service.users)).all(), + inbounds=db.query(Inbound).filter(Inbound.id.in_(service.inbound_ids)).all(), + users=[], ) db.add(dbservice) db.commit() @@ -568,9 +568,9 @@ def update_service(db: Session, db_service: Service, modification: ServiceModify if modification.name is not None: db_service.name = modification.name - if modification.inbounds is not None: + if modification.inbound_ids is not None: db_service.inbounds = ( - db.query(Inbound).filter(Inbound.id.in_(modification.inbounds)).all() + db.query(Inbound).filter(Inbound.id.in_(modification.inbound_ids)).all() ) db.commit() diff --git a/app/db/models.py b/app/db/models.py index f322f58..255d5b8 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -61,12 +61,19 @@ class Service(Base): __tablename__ = "services" id = Column(Integer, primary_key=True) name = Column(String(64)) - # modifications = Column(String(1024)) users = relationship("User", secondary=users_services, back_populates="services") inbounds = relationship( "Inbound", secondary=inbounds_services, back_populates="services" ) + @property + def inbound_ids(self): + return [inbound.id for inbound in self.inbounds] + + @property + def user_ids(self): + return [user.id for user in self.users] + class User(Base): __tablename__ = "users" @@ -86,7 +93,6 @@ class User(Base): ".join(Inbound, Inbound.id == inbounds_services.c.inbound_id)", viewonly=True, distinct_target_key=True, - lazy="joined", ) # proxies = relationship("Proxy", back_populates="user", cascade="all, delete-orphan") status = Column(Enum(UserStatus), nullable=False, default=UserStatus.active) @@ -99,7 +105,6 @@ class User(Base): "NodeUserUsage", back_populates="user", cascade="all,delete,delete-orphan", - lazy="joined", ) notification_reminders = relationship( "NotificationReminder", @@ -126,6 +131,10 @@ class User(Base): on_hold_timeout = Column(DateTime) edit_at = Column(DateTime) + @property + def service_ids(self): + return [service.id for service in self.services] + class Inbound(Base): __tablename__ = "inbounds" @@ -144,6 +153,10 @@ class Inbound(Base): "InboundHost", back_populates="inbound", cascade="all, delete, delete-orphan" ) + @property + def service_ids(self): + return [service.id for service in self.services] + class InboundHost(Base): __tablename__ = "hosts" @@ -235,6 +248,10 @@ class Node(Base): Float, nullable=False, server_default=text("1.0"), default=1 ) + @property + def inbound_ids(self): + return [inbound.id for inbound in self.inbounds] + class NodeUserUsage(Base): __tablename__ = "node_user_usages" diff --git a/app/models/node.py b/app/models/node.py index 4928d92..b10804d 100644 --- a/app/models/node.py +++ b/app/models/node.py @@ -1,5 +1,4 @@ from enum import Enum -from typing import List, Optional from pydantic import ConfigDict, BaseModel, Field @@ -20,8 +19,8 @@ class NodeSettings(BaseModel): certificate: str -class NodeBase(BaseModel): - id: Optional[int] = Field(None) +class Node(BaseModel): + id: int | None = Field(None) name: str address: str port: int = 53042 @@ -32,20 +31,13 @@ class NodeBase(BaseModel): model_config = ConfigDict(from_attributes=True) -from app.models.proxy import InboundBase - - -class Node(NodeBase): - inbounds: Optional[List[InboundBase]] = None - - class NodeCreate(Node): model_config = ConfigDict( json_schema_extra={ "example": { "name": "DE node", "address": "192.168.1.1", - "port": 62050, + "port": 53042, "usage_coefficient": 1, } } @@ -53,19 +45,18 @@ class NodeCreate(Node): class NodeModify(Node): - name: Optional[str] = Field(None) - address: Optional[str] = Field(None) - port: Optional[int] = Field(None) - connection_backend: Optional[NodeConnectionBackend] = Field(None) - status: Optional[NodeStatus] = Field(None) - usage_coefficient: Optional[float] = Field(None, ge=0) + name: str | None = Field(None) + address: str | None = Field(None) + port: int | None = Field(None) + connection_backend: NodeConnectionBackend | None = Field(None) + status: NodeStatus | None = Field(None) + usage_coefficient: float | None = Field(None, ge=0) model_config = ConfigDict( json_schema_extra={ "example": { "name": "DE node", "address": "192.168.1.1", - "port": 62050, - "api_port": 62051, + "port": 53042, "status": "disabled", "usage_coefficient": 1.0, } @@ -74,18 +65,19 @@ class NodeModify(Node): class NodeResponse(Node): - xray_version: Optional[str] = None + xray_version: str | None = None status: NodeStatus - message: Optional[str] = None + message: str | None = None model_config = ConfigDict(from_attributes=True) + inbound_ids: list[int] | None = None class NodeUsageResponse(BaseModel): - node_id: Optional[int] = None + node_id: int | None = None node_name: str uplink: int downlink: int class NodesUsageResponse(BaseModel): - usages: List[NodeUsageResponse] + usages: list[NodeUsageResponse] diff --git a/app/models/proxy.py b/app/models/proxy.py index 08a8213..802a8a3 100644 --- a/app/models/proxy.py +++ b/app/models/proxy.py @@ -1,69 +1,17 @@ -import json from enum import Enum -from typing import Optional, List from pydantic import ConfigDict, BaseModel, Field, field_validator - -class XTLSFlows(Enum): - NONE = "" - VISION = "xtls-rprx-vision" - - -class ShadowsocksMethods(Enum): - AES_128_GCM = "aes-128-gcm" - AES_256_GCM = "aes-256-gcm" - CHACHA20_POLY1305 = "chacha20-ietf-poly1305" +from app.models.node import Node class ProxyTypes(str, Enum): # proxy_type = protocol - VMess = "vmess" VLESS = "vless" Trojan = "trojan" Shadowsocks = "shadowsocks" - @property - def settings_model(self): - if self == self.VMess: - return VMessSettings - if self == self.VLESS: - return VLESSSettings - if self == self.Trojan: - return TrojanSettings - if self == self.Shadowsocks: - return ShadowsocksSettings - - -class ProxySettings(BaseModel): - @classmethod - def from_dict(cls, proxy_type: ProxyTypes, _dict: dict): - return ProxyTypes(proxy_type).settings_model.parse_obj(_dict) - - def dict(self, *, no_obj=False, **kwargs): - if no_obj: - return json.loads(self.json()) - return super().dict(**kwargs) - - -class VMessSettings(ProxySettings): - id: str = Field(nullable=False) - - -class VLESSSettings(ProxySettings): - id: str = Field(nullable=False) - flow: XTLSFlows = XTLSFlows.NONE - - -class TrojanSettings(ProxySettings): - password: str = Field(nullable=False) - - -class ShadowsocksSettings(ProxySettings): - password: str = Field(nullable=False) - method: ShadowsocksMethods = ShadowsocksMethods.CHACHA20_POLY1305 - class InboundHostSecurity(str, Enum): inbound_default = "inbound_default" @@ -114,17 +62,17 @@ class FragmentSettings(BaseModel): class InboundHost(BaseModel): remark: str address: str - port: Optional[int] = Field(None) - sni: Optional[str] = Field(None) - host: Optional[str] = Field(None) - path: Optional[str] = Field(None) + port: int | None = Field(None) + sni: str | None = Field(None) + host: str | None = Field(None) + path: str | None = Field(None) security: InboundHostSecurity = InboundHostSecurity.inbound_default alpn: InboundHostALPN = InboundHostALPN.none fingerprint: InboundHostFingerprint = InboundHostFingerprint.none - allowinsecure: Optional[bool] = None - is_disabled: Optional[bool] = None + allowinsecure: bool | None = None + is_disabled: bool | None = None mux: bool = Field(False) - fragment: Optional[FragmentSettings] = Field(None) + fragment: FragmentSettings | None = Field(None) model_config = ConfigDict(from_attributes=True) @field_validator("remark", "address", "path") @@ -142,19 +90,11 @@ class InboundHostResponse(InboundHost): id: int -class InboundBase(BaseModel): - id: Optional[int] = Field(None) - tag: str = Field() - protocol: ProxyTypes = Field() +class Inbound(BaseModel): + id: int + tag: str + protocol: ProxyTypes config: str - node_id: Optional[int] = Field(None) + node: Node + service_ids: list[int] model_config = ConfigDict(from_attributes=True) - - -from app.models.service import ServiceBase -from app.models.node import NodeBase - - -class Inbound(InboundBase): - services: Optional[List[ServiceBase]] = None - node: Optional[NodeBase] = None diff --git a/app/models/service.py b/app/models/service.py index 9bae7f0..5a8f119 100644 --- a/app/models/service.py +++ b/app/models/service.py @@ -1,57 +1,35 @@ -from typing import List, Optional +from pydantic import ConfigDict, BaseModel, Field -from pydantic import ConfigDict, BaseModel, Field, computed_field - -class ServiceBase(BaseModel): - id: Optional[int] = None - name: Optional[str] = Field(None) +class Service(BaseModel): + id: int | None = None + name: str | None = Field(None) model_config = ConfigDict(from_attributes=True) -from app.models.proxy import InboundBase -from app.models.user import UserBase - - -class Service(ServiceBase): - users: List[UserBase] = Field([]) - inbounds: List[InboundBase] = Field([]) - - -class ServiceCreate(ServiceBase): - users: List[int] = Field([]) - inbounds: List[int] = Field([]) +class ServiceCreate(Service): + inbound_ids: list[int] = Field([]) model_config = ConfigDict( json_schema_extra={ "example": { "name": "my service 1", - "inbounds": [1, 5, 9], - "users": [1, 2, 3], + "inbound_ids": [1, 5, 9], } } ) -class ServiceModify(ServiceBase): - inbounds: Optional[List[int]] = Field(None) +class ServiceModify(Service): + inbound_ids: list[int] | None = Field(None) model_config = ConfigDict( json_schema_extra={ - "example": {"id": 2, "name": "my service 2", "inbounds": [1, 2, 3]} + "example": {"id": 2, "name": "my service 2", "inbound_ids": [1, 2, 3]} } ) class ServiceResponse(Service): - id: int - - @computed_field - @property - def user_ids(self) -> List[int]: - return [u.id for u in self.users] - - @computed_field - @property - def inbound_ids(self) -> List[int]: - return [i.id for i in self.inbounds] + inbound_ids: list[int] + user_ids: list[int] model_config = ConfigDict(from_attributes=True) diff --git a/app/models/user.py b/app/models/user.py index 3f6ef82..478d416 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -1,7 +1,7 @@ import secrets from datetime import datetime from enum import Enum -from typing import List, Optional, Union, Annotated, Literal +from typing import List, Union, Annotated, Literal from pydantic import ( field_validator, @@ -48,39 +48,31 @@ class UserDataLimitResetStrategy(str, Enum): year = "year" -class UserBase(BaseModel): - id: Optional[int] = None +class User(BaseModel): + id: int | None = None username: Annotated[str, StringConstraints(to_lower=True, pattern=USERNAME_REGEXP)] expire: Union[datetime, None, Literal[0]] = Field(None) key: str = Field(default_factory=lambda: secrets.token_hex(16)) - data_limit: Optional[int] = Field( + data_limit: int | None = Field( ge=0, default=None, description="data_limit can be 0 or greater" ) enabled: bool = Field(default=True) data_limit_reset_strategy: UserDataLimitResetStrategy = ( UserDataLimitResetStrategy.no_reset ) - note: Optional[Annotated[str, Field(max_length=500)]] = None - sub_updated_at: Optional[datetime] = Field(None) - sub_last_user_agent: Optional[str] = Field(None) - online_at: Optional[datetime] = Field(None) - on_hold_expire_duration: Optional[int] = Field(None) - on_hold_timeout: Optional[datetime] = Field(None) + note: Annotated[str, Field(max_length=500)] | None = None + sub_updated_at: datetime | None = Field(None) + sub_last_user_agent: str | None = Field(None) + online_at: datetime | None = Field(None) + on_hold_expire_duration: int | None = Field(None) + on_hold_timeout: datetime | None = Field(None) model_config = ConfigDict(from_attributes=True) -from app.models.service import ServiceBase -from app.models.proxy import InboundBase - - -class User(UserBase): - pass - - class UserCreate(User): status: UserStatusCreate = Field(UserStatusCreate.active) - services: List[int] = Field([]) + service_ids: list[int] = Field([]) model_config = ConfigDict( json_schema_extra={ "example": { @@ -125,9 +117,9 @@ class UserCreate(User): class UserModify(User): - status: Optional[UserStatusModify] = Field(None) - services: List[int] = Field([]) - data_limit_reset_strategy: Optional[UserDataLimitResetStrategy] = Field(None) + status: UserStatusModify | None = Field(None) + service_ids: list[int] | None = Field(None) + data_limit_reset_strategy: UserDataLimitResetStrategy | None = Field(None) model_config = ConfigDict( json_schema_extra={ "example": { @@ -170,18 +162,10 @@ class UserResponse(User): used_traffic: int lifetime_used_traffic: int = 0 created_at: datetime - inbounds: List[InboundBase] = Field([]) - services: List[ServiceBase] = Field([]) - links: List[str] | None = [] # Field(None) + service_ids: list[int] - # subscription_url: str = "" model_config = ConfigDict(from_attributes=True) - @computed_field - @property - def service_ids(self) -> List[int]: - return [s.id for s in self.services] - @computed_field @property def subscription_url(self) -> str: diff --git a/app/routes/service.py b/app/routes/service.py index 60f3dfe..7f977a8 100644 --- a/app/routes/service.py +++ b/app/routes/service.py @@ -6,9 +6,11 @@ from fastapi_pagination.links import Page from app import marznode from app.db import crud -from app.db.models import Service +from app.db.models import Service, User from app.dependencies import DBDep, sudo_admin +from app.models.proxy import Inbound from app.models.service import ServiceCreate, ServiceModify, ServiceResponse +from app.models.user import UserResponse router = APIRouter( prefix="/services", dependencies=[Depends(sudo_admin)], tags=["Service"] @@ -52,6 +54,36 @@ def get_service(id: int, db: DBDep): return dbservice +@router.get("/{id}/users", response_model=Page[UserResponse]) +def get_service_users(id: int, db: DBDep): + """ + Get service users + """ + service = crud.get_service(db, id) + + if not service: + raise HTTPException(status_code=404, detail="Service not found") + + query = db.query(User).join(User.services).where(Service.id == service.id) + + return paginate(query) + + +@router.get("/{id}/inbounds", response_model=Page[Inbound]) +def get_service_inbounds(id: int, db: DBDep): + """ + Get service inbounds + """ + service = crud.get_service(db, id) + + if not service: + raise HTTPException(status_code=404, detail="Service not found") + + query = db.query(Inbound).join(Inbound.services).where(Service.id == service.id) + + return paginate(query) + + @router.put("/{id}", response_model=ServiceResponse) async def modify_service(id: int, modification: ServiceModify, db: DBDep): """ diff --git a/app/routes/subscription.py b/app/routes/subscription.py index bf08877..75e5ea6 100644 --- a/app/routes/subscription.py +++ b/app/routes/subscription.py @@ -43,7 +43,7 @@ def user_subscription( user: UserResponse = UserResponse.model_validate(db_user) if "text/html" in accept_header: - links = generate_subscription(user=user, config_format="v2ray").split() + links = generate_subscription(user=db_user, config_format="v2ray").split() return HTMLResponse( render_template(SUBSCRIPTION_PAGE_TEMPLATE, {"user": user, "links": links}) ) @@ -64,18 +64,20 @@ def user_subscription( crud.update_user_sub(db, db_user, user_agent) if re.match("^([Cc]lash-verge|[Cc]lash-?[Mm]eta)", user_agent): - conf = generate_subscription(user=user, config_format="clash-meta") + conf = generate_subscription(user=db_user, config_format="clash-meta") return Response(content=conf, media_type="text/yaml", headers=response_headers) elif re.match("^([Cc]lash|[Ss]tash)", user_agent): - conf = generate_subscription(user=user, config_format="clash") + conf = generate_subscription(user=db_user, config_format="clash") return Response(content=conf, media_type="text/yaml", headers=response_headers) elif re.match("^(SFA|SFI|SFM|SFT)", user_agent): - conf = generate_subscription(user=user, config_format="sing-box") + conf = generate_subscription(user=db_user, config_format="sing-box") return Response( content=conf, media_type="application/json", headers=response_headers ) else: - conf = generate_subscription(user=user, config_format="v2ray", as_base64=True) + conf = generate_subscription( + user=db_user, config_format="v2ray", as_base64=True + ) return Response(content=conf, media_type="text/plain", headers=response_headers) @@ -119,24 +121,26 @@ def user_subscription_with_client_type( } if client_type == "clash-meta": - conf = generate_subscription(user=user, config_format="clash-meta") + conf = generate_subscription(user=db_user, config_format="clash-meta") return Response(content=conf, media_type="text/yaml", headers=response_headers) elif client_type == "sing-box": - conf = generate_subscription(user=user, config_format="sing-box") + conf = generate_subscription(user=db_user, config_format="sing-box") return Response( content=conf, media_type="application/json", headers=response_headers ) elif client_type == "clash": - conf = generate_subscription(user=user, config_format="clash") + conf = generate_subscription(user=db_user, config_format="clash") return Response(content=conf, media_type="text/yaml", headers=response_headers) elif client_type == "v2ray": - conf = generate_subscription(user=user, config_format="v2ray", as_base64=True) + conf = generate_subscription( + user=db_user, config_format="v2ray", as_base64=True + ) return Response(content=conf, media_type="text/plain", headers=response_headers) elif client_type == "xray": return Response( - content=generate_subscription(user=user, config_format="xray"), + content=generate_subscription(user=db_user, config_format="xray"), headers=response_headers, ) else: diff --git a/app/routes/user.py b/app/routes/user.py index c6894ba..b160b8c 100644 --- a/app/routes/user.py +++ b/app/routes/user.py @@ -11,6 +11,7 @@ from fastapi_pagination.links import Page from app import marznode from app.db import crud, User +from app.db.models import Service from app.dependencies import ( DBDep, AdminDep, @@ -19,6 +20,7 @@ from app.dependencies import ( StartDateDep, EndDateDep, ) +from app.models.service import ServiceResponse from app.models.user import ( UserCreate, UserModify, @@ -89,7 +91,6 @@ async def add_user(new_user: UserCreate, db: DBDep, admin: AdminDep): - **data_limit** must be in Bytes, e.g. 1073741824B = 1GB - **services** list of service ids """ - # TODO expire should be datetime instead of timestamp try: db_user = crud.create_user( @@ -225,6 +226,21 @@ async def remove_user(db_user: UserDep, db: DBDep, admin: AdminDep): return {} +@router.get("/{username}/services", response_model=Page[ServiceResponse]) +def get_user_services(username: str, db: DBDep): + """ + Get user services + """ + user = crud.get_user(db, username) + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + query = db.query(Service).join(Service.users).where(User.username == username) + + return paginate(query) + + @router.post("/{username}/reset", response_model=UserResponse) async def reset_user_data_usage(db_user: UserDep, db: DBDep, admin: AdminDep): """ diff --git a/app/utils/share.py b/app/utils/share.py index af8fc9a..f1e2e61 100644 --- a/app/utils/share.py +++ b/app/utils/share.py @@ -5,7 +5,7 @@ import secrets from collections import defaultdict from datetime import datetime as dt, timedelta from importlib import resources -from typing import TYPE_CHECKING, Literal, Union, List +from typing import Literal, Union, List from uuid import UUID from jdatetime import date as jd @@ -16,6 +16,7 @@ from app.db import GetDB, crud from app.models.proxy import ( InboundHostSecurity, ) +from app.models.user import UserResponse from app.models.user import UserStatus from app.utils.keygen import gen_uuid, gen_password from app.utils.system import get_public_ip, readable_size @@ -25,9 +26,6 @@ from config import ( CLASH_SUBSCRIPTION_TEMPLATE, ) -if TYPE_CHECKING: - from app.models.user import UserResponse - SERVER_IP = get_public_ip() STATUS_EMOJIS = { @@ -65,7 +63,9 @@ def generate_subscription( inbounds = user.inbounds key = user.key - extra_data = user.model_dump(exclude={"subscription_url", "services", "inbounds"}) + extra_data = UserResponse.model_validate(user).model_dump( + exclude={"subscription_url", "services", "inbounds"} + ) format_variables = setup_format_variables(extra_data) if config_format not in subscription_handlers.keys(): diff --git a/dashboard/src/components/entity-field-card/index.tsx b/dashboard/src/components/entity-field-card/index.tsx new file mode 100644 index 0000000..f6755a2 --- /dev/null +++ b/dashboard/src/components/entity-field-card/index.tsx @@ -0,0 +1,34 @@ +import { LucideIcon } from 'lucide-react'; +import { Label, Separator } from '@marzneshin/components'; +import { FC } from 'react' + +interface EntityFieldCardProps { + FirstIcon: LucideIcon; + SecondIcon: LucideIcon; + firstAmount: number | string; + secondAmount: number | string; + name: string; +} + + +export const EntityCardProperty = ({ Icon, amount }: { Icon: LucideIcon, amount: number | string }) => ( +
+ + {amount} +
+) + +export const EntityFieldCard: FC = ( + { FirstIcon, SecondIcon, firstAmount, secondAmount, name } +) => { + return ( +
+ +
+ + + +
+
+ ) +}; diff --git a/dashboard/src/components/index.ts b/dashboard/src/components/index.ts index 5356af5..89c04dd 100644 --- a/dashboard/src/components/index.ts +++ b/dashboard/src/components/index.ts @@ -12,3 +12,4 @@ export * from "./stacks"; export * from "./widgets"; export * from "./charts"; export * from "./support-us"; +export * from "./entity-field-card"; diff --git a/dashboard/src/features/services/dialogs/mutation/fields/inbounds.tsx b/dashboard/src/features/services/dialogs/mutation/fields/inbounds.tsx index 510b489..f0ed4d3 100644 --- a/dashboard/src/features/services/dialogs/mutation/fields/inbounds.tsx +++ b/dashboard/src/features/services/dialogs/mutation/fields/inbounds.tsx @@ -5,7 +5,7 @@ import { FormLabel, FormField, Checkbox, - Badge, + EntityFieldCard, } from "@marzneshin/components"; import { useInboundsQuery, @@ -25,7 +25,7 @@ export const InboundCard = ({ field, }: { inbound: InboundType; - field: ControllerRenderProps; + field: ControllerRenderProps; }) => { return (
- {inbound.tag} -
- - {" "} - {inbound.protocol}{" "} - - - {" "} - {inbound.node.name}{" "} - -
+
); @@ -71,7 +67,7 @@ export const InboundsField = () => { return ( ( {t("inbounds")} @@ -79,7 +75,7 @@ export const InboundsField = () => { ( diff --git a/dashboard/src/features/services/dialogs/mutation/index.tsx b/dashboard/src/features/services/dialogs/mutation/index.tsx index cfdadd3..910bea4 100644 --- a/dashboard/src/features/services/dialogs/mutation/index.tsx +++ b/dashboard/src/features/services/dialogs/mutation/index.tsx @@ -19,11 +19,12 @@ import { NameField, InboundsField } from "./fields"; export const ServiceSchema = z.object({ id: z.number().optional(), - inbounds: z.array(z.number()).refine((value) => value.some((item) => item), { + inbound_ids: z.array(z.number()).refine((value) => value.some((item) => item), { message: "You have to select at least one inbound.", }), name: z.string().trim().min(1), }); + type ServiceCreateType = z.infer; interface MutationDialogProps { @@ -34,7 +35,7 @@ interface MutationDialogProps { const getDefaultValues = (): ServiceCreateType => ({ name: "", - inbounds: [], + inbound_ids: [], }); export const MutationDialog: FC = ({ diff --git a/dashboard/src/features/services/services/service-inbounds.query.ts b/dashboard/src/features/services/services/service-inbounds.query.ts new file mode 100644 index 0000000..9fed833 --- /dev/null +++ b/dashboard/src/features/services/services/service-inbounds.query.ts @@ -0,0 +1,38 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetch } from "@marzneshin/utils"; +import { InboundType } from "@marzneshin/features/inbounds"; + +interface FetchServiceInboundsType { + queryKey: [string, number, number, number] +} + +interface UseServiceInboundsQueryProps { + serviceId: number; + page?: number; + size?: number; +} + +export async function fetchServiceInbounds({ + queryKey +}: FetchServiceInboundsType): Promise { + return fetch(`/services/${queryKey[1]}/inbounds`, { + query: { + page: queryKey[2], + size: queryKey[3] + } + }).then((result) => { + return result.items; + }); +} + +export const ServicesQueryFetchKey = "services"; + +export const useInboundsServiceQuery = ({ + serviceId, page = 1, size = 50 +}: UseServiceInboundsQueryProps) => { + return useQuery({ + queryKey: [ServicesQueryFetchKey, serviceId, page, size], + queryFn: fetchServiceInbounds, + initialData: [] + }) +} diff --git a/dashboard/src/features/services/services/service-users.query.ts b/dashboard/src/features/services/services/service-users.query.ts index 256645c..9b3a3c1 100644 --- a/dashboard/src/features/services/services/service-users.query.ts +++ b/dashboard/src/features/services/services/service-users.query.ts @@ -1,20 +1,36 @@ import { useQuery } from "@tanstack/react-query"; import { fetch } from "@marzneshin/utils"; -import { ServiceType } from "../types"; import { UserType } from "@marzneshin/features/users"; -export async function fetchUsersService({ queryKey }: { queryKey: [string, number] }): Promise { - return fetch(`/services/${queryKey[1]}`).then((result) => { - return result.users; +interface FetchServiceUsersType { + queryKey: [string, number, number, number] +} + +interface UseServiceUsersQueryProps { + serviceId: number; + page?: number; + size?: number; +} + +export async function fetchServiceUsers({ queryKey }: FetchServiceUsersType): Promise { + return fetch(`/services/${queryKey[1]}/users`, { + query: { + page: queryKey[2], + size: queryKey[3] + } + }).then((result) => { + return result.items; }); } export const ServicesQueryFetchKey = "services"; -export const useUsersServiceQuery = (service: ServiceType) => { +export const useUsersServiceQuery = ({ + serviceId, page = 1, size = 50 +}: UseServiceUsersQueryProps) => { return useQuery({ - queryKey: [ServicesQueryFetchKey, service.id], - queryFn: fetchUsersService, + queryKey: [ServicesQueryFetchKey, serviceId, page, size], + queryFn: fetchServiceUsers, initialData: [] }) } diff --git a/dashboard/src/features/services/services/services.query.ts b/dashboard/src/features/services/services/services.query.ts index aa0cfc2..1736bd0 100644 --- a/dashboard/src/features/services/services/services.query.ts +++ b/dashboard/src/features/services/services/services.query.ts @@ -7,23 +7,14 @@ import { } from "@marzneshin/components"; import { useQuery } from "@tanstack/react-query"; -interface ServiceResponse { - id: number - name: string - users: any[] - inbounds: any[] - user_ids: number[] - inbound_ids: number[] -} - export async function fetchServices({ queryKey }: EntityQueryKeyType): FetchEntityReturn { - return fetch(`/services?page=${queryKey[1]}&size=${queryKey[2]}&name=${queryKey[3]}`).then((result) => { - const services: ServiceType[] = result.items.map((fetchedService: ServiceResponse) => { - const service: ServiceType = fetchedService; - service.users = fetchedService.user_ids - service.inbounds = fetchedService.inbound_ids - return service - }) + return fetch(`/services`, { + query: { + page: queryKey[1], + size: queryKey[2] + } + }).then((result) => { + const services: ServiceType[] = result.items; return { entity: services, pageCount: result.pages @@ -33,9 +24,9 @@ export async function fetchServices({ queryKey }: EntityQueryKeyType): FetchEnti export const ServicesQueryFetchKey = "services"; -export const useServicesQuery = ({ page, size, search }: UseEntityQueryProps) => { +export const useServicesQuery = ({ page, size, search = ""}: UseEntityQueryProps) => { return useQuery({ - queryKey: [ServicesQueryFetchKey, page, size, search ? search : ""], + queryKey: [ServicesQueryFetchKey, page, size, search], queryFn: fetchServices, initialData: { entity: [], pageCount: 0 } }) diff --git a/dashboard/src/features/services/tables/inbounds/index.tsx b/dashboard/src/features/services/tables/inbounds/index.tsx index 0605c8e..5d7cebb 100644 --- a/dashboard/src/features/services/tables/inbounds/index.tsx +++ b/dashboard/src/features/services/tables/inbounds/index.tsx @@ -23,7 +23,7 @@ export const ServiceInboundsTable: FC = ({ useEffect(() => { setSelectedInbound((prevSelected) => { const updatedSelected: RowSelectionState = { ...prevSelected }; - for (const inboundId of service.inbounds) { + for (const inboundId of service.inbound_ids) { for (const [i, fetchedInbound] of data.entries()) { if (fetchedInbound.id === inboundId) { updatedSelected[i] = true; @@ -32,13 +32,13 @@ export const ServiceInboundsTable: FC = ({ } return updatedSelected; }); - }, [data, service.inbounds]); + }, [data, service.inbound_ids]); const handleApply = useCallback(() => { const selectedInboundIds = Object.keys(selectedInbound) .filter((key) => selectedInbound[Number.parseInt(key)]) .map((key) => data[Number.parseInt(key)].id); - updateService({ ...service, inbounds: selectedInboundIds }); + updateService({ ...service, inbound_ids: selectedInboundIds }); }, [data, selectedInbound, service, updateService]); const disabled = Object.keys(selectedInbound).length < 1; diff --git a/dashboard/src/features/services/tables/services/columns.tsx b/dashboard/src/features/services/tables/services/columns.tsx index ce19109..f8b92f4 100644 --- a/dashboard/src/features/services/tables/services/columns.tsx +++ b/dashboard/src/features/services/tables/services/columns.tsx @@ -19,12 +19,12 @@ export const columns = (actions: ColumnAction): ColumnDef[] => ([ { accessorKey: "users", header: ({ column }) => , - cell: ({ row }) => `${row.original.users.length}` + cell: ({ row }) => `${row.original.user_ids.length}` }, { accessorKey: "inbounds", header: ({ column }) => , - cell: ({ row }) => `${row.original.inbounds.length}` + cell: ({ row }) => `${row.original.inbound_ids.length}` }, { id: "actions", diff --git a/dashboard/src/features/services/tables/users/index.tsx b/dashboard/src/features/services/tables/users/index.tsx index 76eda2a..4fb842f 100644 --- a/dashboard/src/features/services/tables/users/index.tsx +++ b/dashboard/src/features/services/tables/users/index.tsx @@ -10,7 +10,7 @@ interface ServicesUsersTableProps { } export const ServicesUsersTable: FC = ({ service }) => { - const { data } = useUsersServiceQuery(service); + const { data } = useUsersServiceQuery({ serviceId: service.id }); return (
diff --git a/dashboard/src/features/services/types/services-mutation.type.ts b/dashboard/src/features/services/types/services-mutation.type.ts index d6decb1..c3990ed 100644 --- a/dashboard/src/features/services/types/services-mutation.type.ts +++ b/dashboard/src/features/services/types/services-mutation.type.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const ServiceCreateSchema = z.object({ - inbounds: z.array(z.number()), + inbound_ids: z.array(z.number()), name: z.string().trim().min(1), id: z.number().optional() }) diff --git a/dashboard/src/features/services/types/services.type.ts b/dashboard/src/features/services/types/services.type.ts index 42c6877..4175f57 100644 --- a/dashboard/src/features/services/types/services.type.ts +++ b/dashboard/src/features/services/types/services.type.ts @@ -2,6 +2,6 @@ export interface ServiceType { id: number; name: string; - users: number[]; - inbounds: number[]; + user_ids: number[]; + inbound_ids: number[]; } diff --git a/dashboard/src/features/users/components/services-card.tsx b/dashboard/src/features/users/components/services-card.tsx index e941cc3..093b655 100644 --- a/dashboard/src/features/users/components/services-card.tsx +++ b/dashboard/src/features/users/components/services-card.tsx @@ -1,33 +1,25 @@ - -import { Label, Separator } from '@marzneshin/components'; +import { EntityFieldCard } from '@marzneshin/components'; import { ServiceType } from '@marzneshin/features/services'; -import { LucideIcon, ServerIcon, UserIcon } from 'lucide-react'; +import { ServerIcon, UserIcon } from 'lucide-react'; import { FC } from 'react' interface ServiceCardProps { service: ServiceType } -const ServiceProperty = ({ Icon, amount }: { Icon: LucideIcon, amount: number }) => ( -
- - {amount} -
-) export const ServiceCard: FC = ( { service } ) => { - if (service.users && service.inbounds) { + if (service.user_ids && service.inbound_ids) { return ( -
- -
- - - -
-
+ ) } }; diff --git a/dashboard/src/features/users/dialogs/mutation/fields/services.tsx b/dashboard/src/features/users/dialogs/mutation/fields/services.tsx index 0fcd47b..e51443b 100644 --- a/dashboard/src/features/users/dialogs/mutation/fields/services.tsx +++ b/dashboard/src/features/users/dialogs/mutation/fields/services.tsx @@ -23,7 +23,7 @@ import { useTranslation } from "react-i18next"; interface ServiceCheckboxCardProps { service: ServiceType; - field: ControllerRenderProps; + field: ControllerRenderProps; } const ServiceCheckboxCard: FC = ({ @@ -62,7 +62,7 @@ export const ServicesField: FC = () => { return ( ( {t("services")} @@ -71,7 +71,7 @@ export const ServicesField: FC = () => { ( diff --git a/dashboard/src/features/users/dialogs/mutation/index.tsx b/dashboard/src/features/users/dialogs/mutation/index.tsx index 4a3b23f..4fd0669 100644 --- a/dashboard/src/features/users/dialogs/mutation/index.tsx +++ b/dashboard/src/features/users/dialogs/mutation/index.tsx @@ -37,7 +37,7 @@ export const UsersMutationDialog: FC = ({ const { t } = useTranslation(); const getDefaultValues = useCallback( (): UserMutationType => ({ - services: [], + service_ids: [], data_limit: undefined, expire: null, username: "", diff --git a/dashboard/src/features/users/dialogs/mutation/schema.ts b/dashboard/src/features/users/dialogs/mutation/schema.ts index 507c910..223f5a9 100644 --- a/dashboard/src/features/users/dialogs/mutation/schema.ts +++ b/dashboard/src/features/users/dialogs/mutation/schema.ts @@ -19,6 +19,6 @@ export const UserSchema = z.object({ status: z.string(), on_hold_expire_duration: z.number().nullable().optional(), on_hold_timeout: z.string().nullable().optional(), - services: z.array(z.number().or(z.string())).nonempty({ message: 'At least one service is required' }) + service_ids: z.array(z.number().or(z.string())).nonempty({ message: 'At least one service is required' }) .transform((v) => v.map(Number)), }); diff --git a/dashboard/src/features/users/services/users.query.ts b/dashboard/src/features/users/services/users.query.ts index bcb86b8..3ac702b 100644 --- a/dashboard/src/features/users/services/users.query.ts +++ b/dashboard/src/features/users/services/users.query.ts @@ -10,11 +10,6 @@ import { export type SortUserBy = "username" | "used_traffic" | "data_limit" | "expire" | "created_at" -interface UserResponse extends UserType { - service_ids: number[] - services: any[] -} - export async function fetchUsers({ queryKey }: EntityQueryKeyType): FetchEntityReturn { return fetch(`/users`, { query: { @@ -25,13 +20,8 @@ export async function fetchUsers({ queryKey }: EntityQueryKeyType): FetchEntityR order_by: queryKey[4] } }).then((result) => { - const users: UserType[] = result.items.map((fetchedUser: UserResponse) => { - const user: UserType = fetchedUser; - user.services = fetchedUser.service_ids - return user - }) return { - entity: users, + entity: result.items, pageCount: result.pages }; }); diff --git a/dashboard/src/features/users/tables/services/columns.tsx b/dashboard/src/features/users/tables/services/columns.tsx index 93253a3..4c0dc5e 100644 --- a/dashboard/src/features/users/tables/services/columns.tsx +++ b/dashboard/src/features/users/tables/services/columns.tsx @@ -35,12 +35,12 @@ export const columns: ColumnDef[] = [ }, { accessorKey: 'users', - header: ({ column }) => , - cell: ({ row }) => row.original.users.length + header: ({ column }) => , + cell: ({ row }) => row.original.user_ids.length }, { accessorKey: 'inbounds', header: ({ column }) => , - cell: ({ row }) => row.original.inbounds.length + cell: ({ row }) => row.original.inbound_ids.length } ]; diff --git a/dashboard/src/features/users/tables/services/index.tsx b/dashboard/src/features/users/tables/services/index.tsx index edf614d..d75bb1f 100644 --- a/dashboard/src/features/users/tables/services/index.tsx +++ b/dashboard/src/features/users/tables/services/index.tsx @@ -26,7 +26,7 @@ export const UserServicesTable: FC = ({ user }) => { useEffect(() => { setSelectedService((prevSelected) => { const updatedSelected: RowSelectionState = { ...prevSelected }; - for (const serviceId of user.services) { + for (const serviceId of user.service_ids) { for (const [i, fetchedService] of data.entity.entries()) { if (fetchedService.id === serviceId) { updatedSelected[i] = true; @@ -35,13 +35,13 @@ export const UserServicesTable: FC = ({ user }) => { } return updatedSelected; }); - }, [data, user.services]); + }, [data, user.service_ids]); const handleApply = useCallback(() => { const selectedServiceIds = Object.keys(selectedService) .filter((key) => selectedService[Number.parseInt(key)]) .map((key) => data.entity[Number.parseInt(key)].id); - updateUser({ ...user, services: selectedServiceIds }); + updateUser({ ...user, service_ids: selectedServiceIds }); }, [data, selectedService, user, updateUser]); const disabled = Object.keys(selectedService).length < 1; diff --git a/dashboard/src/features/users/types/index.ts b/dashboard/src/features/users/types/index.ts index 0309c57..6229374 100644 --- a/dashboard/src/features/users/types/index.ts +++ b/dashboard/src/features/users/types/index.ts @@ -30,14 +30,14 @@ export interface UserType { status: Status; links: string[]; subscription_url: string; - services: number[]; + service_ids: number[]; note: string; online_at: string; } export type UserMutationType = Pick< UserType, - | 'services' + | 'service_ids' | 'expire' | 'data_limit' | 'data_limit_reset_strategy'