diff --git a/app/db/crud.py b/app/db/crud.py index 8ae7cfa..0a391e6 100644 --- a/app/db/crud.py +++ b/app/db/crud.py @@ -152,6 +152,8 @@ def add_host(db: Session, inbound: Inbound, host: InboundHostModify): security=host.security, alpn=host.alpn, fingerprint=host.fingerprint, + fragment=host.fragment.model_dump() if host.fragment else None, + mux=host.mux, ) inbound.hosts.append(host) db.commit() @@ -169,6 +171,8 @@ def update_host(db: Session, db_host: InboundHost, host: InboundHostModify): db_host.security = host.security db_host.alpn = host.alpn db_host.fingerprint = host.fingerprint + db_host.fragment = host.fragment.model_dump() if host.fragment else None + db_host.mux = host.mux db.commit() db.refresh(db_host) return db_host diff --git a/app/db/migrations/versions/74e234a3bd4b_add_mux_fragment_to_hosts.py b/app/db/migrations/versions/74e234a3bd4b_add_mux_fragment_to_hosts.py new file mode 100644 index 0000000..043cbef --- /dev/null +++ b/app/db/migrations/versions/74e234a3bd4b_add_mux_fragment_to_hosts.py @@ -0,0 +1,34 @@ +"""add fragment settings and mux columns to hosts table + +Revision ID: 74e234a3bd4b +Revises: e8325f600a49 +Create Date: 2024-05-22 00:26:26.865255 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "74e234a3bd4b" +down_revision = "e8325f600a49" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "hosts", + sa.Column("mux", sa.Boolean(), server_default=sa.sql.true(), nullable=False), + ) + op.add_column("hosts", sa.Column("fragment", sa.JSON(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("hosts", "fragment") + op.drop_column("hosts", "mux") + # ### end Alembic commands ### diff --git a/app/db/models.py b/app/db/models.py index 7c80bad..6c44a12 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -14,6 +14,7 @@ from sqlalchemy import ( String, Table, UniqueConstraint, + JSON, ) from sqlalchemy.orm import relationship from sqlalchemy.sql.expression import text @@ -192,6 +193,11 @@ class InboundHost(Base): server_default=InboundHostSecurity.none.name, ) + mux = Column( + Boolean, default=False, nullable=False, server_default=sqlalchemy.sql.true() + ) + fragment = Column(JSON()) + inbound_id = Column(Integer, ForeignKey("inbounds.id"), nullable=False) inbound = relationship("Inbound", back_populates="hosts") allowinsecure = Column(Boolean, default=False) diff --git a/app/models/proxy.py b/app/models/proxy.py index 670d01f..6bd5b36 100644 --- a/app/models/proxy.py +++ b/app/models/proxy.py @@ -2,8 +2,6 @@ import json from enum import Enum from typing import Optional, Union, List -# from uuid import UUID, uuid4 - from pydantic import ConfigDict, BaseModel, Field, validator @@ -107,18 +105,26 @@ class FormatVariables(dict): return key.join("{}") +class FragmentSettings(BaseModel): + packets: str = Field(pattern=r"^(:?tlshello|[\d-]{1,32})$") + length: str = Field(pattern=r"^[\d-]{1,32}$") + interval: str = Field(pattern=r"^[\d-]{1,32}$") + + class InboundHost(BaseModel): remark: str address: str - port: Optional[int] = Field(None, nullable=True) - sni: Optional[str] = Field(None, nullable=True) - host: Optional[str] = Field(None, nullable=True) - path: Optional[str] = Field(None, nullable=True) + port: Optional[int] = Field(None) + sni: Optional[str] = Field(None) + host: Optional[str] = Field(None) + path: Optional[str] = Field(None) security: InboundHostSecurity = InboundHostSecurity.inbound_default alpn: InboundHostALPN = InboundHostALPN.none fingerprint: InboundHostFingerprint = InboundHostFingerprint.none allowinsecure: Union[bool, None] = None is_disabled: Union[bool, None] = None + mux: bool = Field(False) + fragment: Optional[FragmentSettings] = Field(None) model_config = ConfigDict(from_attributes=True) # TODO[pydantic]: We couldn't refactor the `validator`, please replace it by `field_validator` manually. diff --git a/dashboard/package.json b/dashboard/package.json index 16e9b3d..8578441 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -25,6 +25,7 @@ "@nextui-org/react": "^2.3.6", "@nextui-org/system": "^2.1.2", "@nextui-org/theme": "^2.2.3", + "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-aspect-ratio": "^1.0.3", "@radix-ui/react-checkbox": "^1.0.4", diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml index 69029f4..6722a89 100644 --- a/dashboard/pnpm-lock.yaml +++ b/dashboard/pnpm-lock.yaml @@ -35,6 +35,9 @@ dependencies: '@nextui-org/theme': specifier: ^2.2.3 version: 2.2.3(tailwindcss@3.4.3) + '@radix-ui/react-accordion': + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-alert-dialog': specifier: ^1.0.5 version: 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.3.1)(react@18.3.1) @@ -2795,6 +2798,35 @@ packages: '@babel/runtime': 7.24.5 dev: false + /@radix-ui/react-accordion@1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-fDG7jcoNKVjSK6yfmuAs0EnPDro0WMXIhMtXdTBWqEioVW206ku+4Lw07e+13lUkFkpoEQ2PdeMIAGpdqEAmDg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.5 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-collapsible': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.2)(react@18.3.1) + '@radix-ui/react-context': 1.0.1(@types/react@18.3.2)(react@18.3.1) + '@radix-ui/react-direction': 1.0.1(@types/react@18.3.2)(react@18.3.1) + '@radix-ui/react-id': 1.0.1(@types/react@18.3.2)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.2)(react@18.3.1) + '@types/react': 18.3.2 + '@types/react-dom': 18.3.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /@radix-ui/react-alert-dialog@1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.2)(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-OrVIOcZL0tl6xibeuGt5/+UxoT2N27KCFOPjFyfXMnchxSHZ/OW7cCX2nGlIYJrbHK/fczPcFzAwvNBB6XBNMA==} peerDependencies: diff --git a/dashboard/public/locales/en.json b/dashboard/public/locales/en.json index 3033e21..d580870 100644 --- a/dashboard/public/locales/en.json +++ b/dashboard/public/locales/en.json @@ -1,279 +1,295 @@ { - "name": "Name", - "status": "Status", - "address": "Address", - "users": "Users", - "nodes": "Nodes", - "services": "Services", - "hosts": "Hosts", - "username": "Username", - "password": "Password", - "inbounds": "Inbounds", - "submit": "Submit", - "login": "Login", - "edit": "Edit", - "actions": "Actions", - "settings": "Settings", - "logs": "Logs", - "note": "Note", - "config": "Config", - "tag": "Tag", - "protocol": "Protocol", - "online": "Online", - "offline": "Offline", - "not_connected": "Not Connected", - "apply": "Apply", - "open": "Open", - "delete": "Delete", - "port": "Port", - "create": "Create", - "path": "Path", - "host": "Host", - "alpn": "ALPN", - "security": "Security", - "user_info": "Users Info", - "subscription": "Subscription", - "subscription_link": "Subscription Link", - "fingerprint": "Fingerprint", - "enable": "Enable", - "disable": "Disable", - "close": "Close", - "save": "Save", - "days": "Days", - "home": "Malê", - "page": { - "settings": { - "certificate": { - "copy": "Copy Certificate", - "error": "Certificate Unavailable", - "copied": "Certificate Copied" - } - }, - "login": { - "username": { - "error": { - "isRequired": "Username is required" - } - }, - "password": { - "error": { - "is-required": "Password is required" - } - } - }, - "nodes": { - "settings": { - "title": "Nodes Settings", - "detail": "Details", - "log-container": { - "title": "Marznode Xray config logs" - } - }, - "dialogs": { - "creation": { - "title": "Nodes Creation" - }, - "deletion": { - "title": "Nodes Deletion" - }, - "edition": { - "title": "Nodes Edition" - } - }, - "usage_coefficient": "Usage Coefficient", - "add_as_new_host": "Add as new hosts", - "certificate-alert": { - "title": "Nodes Certificate Configuration", - "desc": "If you aim to configure your nodes certificate, checkout settings page.", - "click": "Click Here" - } - }, - "services": { - "settings": { - "title": "Servicecs Settings", - "detail": "Details" - }, - "dialogs": { - "creation": { - "title": "Services Creation" - }, - "deletion": { - "title": "Services Deletion" - }, - "edition": { - "title": "Services Edition" - } - } - }, - "users": { - "data_limit": "Data Limit", - "used_traffic": "Used Traffic", - "data_limit_reset_strategy": "Data Limit Reset Strategy", - "online_at": "Online At", - "expire_date": "Expire Date", - "unlimited": "Unlimited", - "limited": "Limited", - "data_limit_method": "Data limit type", - "determined_expire": "Determined", - "expire_method": "Expiration method", - "onhold_expire": "On Hold", - "reset_usage": "Reset Usage", - "revoke_subscription": "Revoke Subscription", - "on_hold_expire_duration": "On Hold", - "on_hold_timeout": "On Hold Timeout", - "settings": { - "subscription_link": { - "copied": "Subscription Link Copied", - "copy": "Copy Subscription Link", - "error": "Unable to copy Subscription Link" - } - }, - "services-alert": { - "title": "No Service were found", - "desc": "Make sure you create services to enable your users with inbounds to connect with.", - "click": "Click Here" - }, - "dialogs": { - "edition": { - "title": "Users Edition" - }, - "creation": { - "title": "Users Creation" - }, - "deletion": { - "title": "Users Deletion" - }, - "mutation": { - "note": { - "desc": "Leave a note about your user" - } - } - } - }, - "hosts": { - "popover-guide": { - "title": "Using this setting, you are able to assign specific address for each inbound.", - "desc": "Use these variables to make it dynamic", - "username": "The username of the user", - "dataUsage": "The current usage of the user", - "remainingData": "Remaining data of the user", - "dataLimit": "The usage limit of the user", - "remainingDays": "Remaining days of the user", - "expireDate": "Expiry date of the user", - "jalaliExpireDate": "Expiry date of the user in solar calendar", - "remainingTime": "Remaining time of the user", - "statusEmoji": "User status as an emoji (✅,⌛️,🪫,❌,🔌)", - "proxyProtocol": "Proxy protocol (e.g. VMess)", - "proxyMethod": "Proxy transport method (e.g. ws)", - "currentServer": "Current server ip address", - "security": "Security Layer", - "host": "Request Host", - "port": "Port", - "path": "Path", - "sni": "SNI", - "advancedOptions": "Advanced options", - "addHost": "Add host", - "savedSuccess": "Hosts saved successfully", - "loading": "loading...", - "apply": "Apply", - "port.info": "By default, a host uses the default port of the inbound. You can set a custom port in case this host is a server that forwards traffic from a port that differs from your server's port. For example, the server may forward traffic from port 8443 to the default port of your inbound server.", - "path.info": "Set a path for host users, useful behind a reverse proxy.", - "sni.info": "By default, a host uses the default SNI of the inbound. You can set a custom SNI in case this host is a server that has a different SNI. For example, the server may receive traffic with a different SSL certificate, perform SSL termination, and forward it to your inbound server.", - "host.info": "By default, if a request host is set in the Xray config, this host is used. However, you can set a custom request host here if needed.", - "security.info": "If the middleware server of this host uses a different security layer than the inbound's default, you can set a custom security layer here.", - "alpn": "ALPN", - "fingerprint": "Fingerprint", - "host.multiHost": "To set multiple addresses, separate them with , Each time an address is chosen randomly.", - "host.wildcard": "Use * to generate a random string (works for wildcard domain names)" - }, - "no-inbound-selection-alert": { - "title": "No inbound is selected", - "desc": "You must select an inbound inorder to create host." - }, - "allowinsecure": "Allow insecure", - "dialogs": { - "edition": { - "title": "Hosts Edition" - }, - "creation": { - "title": "Hosts Creation" - }, - "deletion": { - "title": "Hosts Deletion" - }, - "mutation": { - "allowinsecure": { - "alert": "Due to security concerns, it is not recommended to allow Insecure" - } - } - } - } - }, - "events": { - "delete": { - "error": "Failed to delete {{name}}", - "success": { - "title": "{{name}} is successfuly deleted", - "desc": "" - } - }, - "create": { - "error": "Failed to create {{name}}", - "success": { - "title": "{{name}} is successfuly created", - "desc": "" - } - }, - "update": { - "error": "Failed to update {{name}}", - "success": { - "title": "{{name}} is successfuly updated", - "desc": "" - } - }, - "user_revoke": { - "error": "Failed to revoke {{name}} subscription", - "success": { - "title": "{{name}} is successfuly created", - "desc": "" - } - }, - "user_reset": { - "error": "Failed to reset {{name}} data usage", - "success": { - "title": "{{name}} data usage is successfuly reseted", - "desc": "" - } - }, - "user_status": { - "error": "Failed to toggle {{name}} enable status", - "success": { - "title": "{{name}} enable status is successfuly updated", - "desc": "" - } - } - }, - "delete-confirmation": { - "title": "Are you sure of this?", - "desc": "This action cannot be undone. This will permanently delete your account and remove your data from our servers." - }, - "table": { - "row-per-page": "Rows per page", - "no-result": "No results", - "selected": "Selected", - "toggle-column": "Toggle columns", - "filter-placeholder": "Filter {{name}}" - }, - "statuses": { - "active": "Active", - "connected": "Connected", - "healthy": "Healthy", - "disabled": "Disabled", - "expired": "Expired", - "on_hold": "On Hold", - "connecting": "Connecting", - "unhealthy": "Unhealthy", - "limited": "Limited", - "error": "Error" - } + "name": "Name", + "status": "Status", + "address": "Address", + "users": "Users", + "nodes": "Nodes", + "services": "Services", + "hosts": "Hosts", + "username": "Username", + "password": "Password", + "inbounds": "Inbounds", + "submit": "Submit", + "login": "Login", + "edit": "Edit", + "actions": "Actions", + "settings": "Settings", + "logs": "Logs", + "note": "Note", + "config": "Config", + "tag": "Tag", + "protocol": "Protocol", + "online": "Online", + "offline": "Offline", + "not_connected": "Not Connected", + "apply": "Apply", + "open": "Open", + "delete": "Delete", + "port": "Port", + "create": "Create", + "path": "Path", + "host": "Host", + "alpn": "ALPN", + "security": "Security", + "user_info": "Users Info", + "subscription": "Subscription", + "subscription_link": "Subscription Link", + "fingerprint": "Fingerprint", + "mux": "Mux", + "fragment": "Fragment", + "enable": "Enable", + "disable": "Disable", + "close": "Close", + "save": "Save", + "days": "Days", + "home": "Home", + "disabled": "Disabled", + "advanced-options": "Advanced Options", + "page": { + "settings": { + "certificate": { + "copy": "Copy Certificate", + "error": "Certificate Unavailable", + "copied": "Certificate Copied" + } + }, + "login": { + "username": { + "error": { + "isRequired": "Username is required" + } + }, + "password": { + "error": { + "is-required": "Password is required" + } + } + }, + "nodes": { + "settings": { + "title": "Nodes Settings", + "detail": "Details", + "log-container": { + "title": "Marznode Xray config logs" + } + }, + "dialogs": { + "creation": { + "title": "Nodes Creation" + }, + "deletion": { + "title": "Nodes Deletion" + }, + "edition": { + "title": "Nodes Edition" + } + }, + "usage_coefficient": "Usage Coefficient", + "add_as_new_host": "Add as new hosts", + "certificate-alert": { + "title": "Nodes Certificate Configuration", + "desc": "If you aim to configure your nodes certificate, checkout settings page.", + "click": "Click Here" + } + }, + "services": { + "settings": { + "title": "Servicecs Settings", + "detail": "Details" + }, + "dialogs": { + "creation": { + "title": "Services Creation" + }, + "deletion": { + "title": "Services Deletion" + }, + "edition": { + "title": "Services Edition" + } + } + }, + "users": { + "data_limit": "Data Limit", + "used_traffic": "Used Traffic", + "data_limit_reset_strategy": "Data Limit Reset Strategy", + "online_at": "Online At", + "expire_date": "Expire Date", + "unlimited": "Unlimited", + "limited": "Limited", + "data_limit_method": "Data limit type", + "determined_expire": "Determined", + "expire_method": "Expiration method", + "onhold_expire": "On Hold", + "reset_usage": "Reset Usage", + "revoke_subscription": "Revoke Subscription", + "on_hold_expire_duration": "On Hold", + "on_hold_timeout": "On Hold Timeout", + "settings": { + "subscription_link": { + "copied": "Subscription Link Copied", + "copy": "Copy Subscription Link", + "error": "Unable to copy Subscription Link" + } + }, + "services-alert": { + "title": "No Service were found", + "desc": "Make sure you create services to enable your users with inbounds to connect with.", + "click": "Click Here" + }, + "dialogs": { + "edition": { + "title": "Users Edition" + }, + "creation": { + "title": "Users Creation" + }, + "deletion": { + "title": "Users Deletion" + }, + "mutation": { + "note": { + "desc": "Leave a note about your user" + } + } + } + }, + "hosts": { + "fragment": { + "interval": "Interval", + "length": "Length", + "packets": "Packets", + "length-error": "Length can not exceed 32 char and must be number-number", + "interval-error": "Interval can not exceed 32 char and must be number-number", + "packets-error": "Packets can not exceed 32 char and must be number-number or be tlshello" + }, + "popover-guide": { + "title": "Using this setting, you are able to assign specific address for each inbound.", + "desc": "Use these variables to make it dynamic", + "username": "The username of the user", + "dataUsage": "The current usage of the user", + "remainingData": "Remaining data of the user", + "dataLimit": "The usage limit of the user", + "remainingDays": "Remaining days of the user", + "expireDate": "Expiry date of the user", + "jalaliExpireDate": "Expiry date of the user in solar calendar", + "remainingTime": "Remaining time of the user", + "statusEmoji": "User status as an emoji (✅,⌛,🪫,❌,🔌)", + "proxyProtocol": "Proxy protocol (e.g. VMess)", + "proxyMethod": "Proxy transport method (e.g. ws)", + "currentServer": "Current server ip address", + "security": "Security Layer", + "host": "Request Host", + "port": "Port", + "path": "Path", + "sni": "SNI", + "advancedOptions": "Advanced options", + "addHost": "Add host", + "savedSuccess": "Hosts saved successfully", + "loading": "loading...", + "apply": "Apply", + "port.info": "By default, a host uses the default port of the inbound. You can set a custom port in case this host is a server that forwards traffic from a port that differs from your server's port. For example, the server may forward traffic from port 8443 to the default port of your inbound server.", + "path.info": "Set a path for host users, useful behind a reverse proxy.", + "sni.info": "By default, a host uses the default SNI of the inbound. You can set a custom SNI in case this host is a server that has a different SNI. For example, the server may receive traffic with a different SSL certificate, perform SSL termination, and forward it to your inbound server.", + "host.info": "By default, if a request host is set in the Xray config, this host is used. However, you can set a custom request host here if needed.", + "security.info": "If the middleware server of this host uses a different security layer than the inbound's default, you can set a custom security layer here.", + "alpn": "ALPN", + "fingerprint": "Fingerprint", + "host.multiHost": "To set multiple addresses, separate them with , Each time an address is chosen randomly.", + "host.wildcard": "Use * to generate a random string (works for wildcard domain names)" + }, + "mux": { + "title": "Connection multiplexing", + "desc": "Currently supported by v2rayng while using custom config" + }, + "no-inbound-selection-alert": { + "title": "No inbound is selected", + "desc": "You must select an inbound inorder to create host." + }, + "allowinsecure": "Allow insecure", + "dialogs": { + "edition": { + "title": "Edit Host" + }, + "creation": { + "title": "Create Host" + }, + "deletion": { + "title": "Delete Host" + }, + "mutation": { + "allowinsecure": { + "alert": "Due to security concerns, it is not recommended to allow Insecure" + } + } + } + } + }, + "events": { + "delete": { + "error": "Failed to delete {{name}}", + "success": { + "title": "{{name}} is successfuly deleted", + "desc": "" + } + }, + "create": { + "error": "Failed to create {{name}}", + "success": { + "title": "{{name}} is successfuly created", + "desc": "" + } + }, + "update": { + "error": "Failed to update {{name}}", + "success": { + "title": "{{name}} is successfuly updated", + "desc": "" + } + }, + "user_revoke": { + "error": "Failed to revoke {{name}} subscription", + "success": { + "title": "{{name}} is successfuly created", + "desc": "" + } + }, + "user_reset": { + "error": "Failed to reset {{name}} data usage", + "success": { + "title": "{{name}} data usage is successfuly reseted", + "desc": "" + } + }, + "user_status": { + "error": "Failed to toggle {{name}} enable status", + "success": { + "title": "{{name}} enable status is successfuly updated", + "desc": "" + } + } + }, + "delete-confirmation": { + "title": "Are you sure of this?", + "desc": "This action cannot be undone. This will permanently delete your account and remove your data from our servers." + }, + "table": { + "row-per-page": "Rows per page", + "no-result": "No results", + "selected": "Selected", + "toggle-column": "Toggle columns", + "filter-placeholder": "Filter {{name}}" + }, + "statuses": { + "active": "Active", + "connected": "Connected", + "healthy": "Healthy", + "disabled": "Disabled", + "expired": "Expired", + "on_hold": "On Hold", + "connecting": "Connecting", + "unhealthy": "Unhealthy", + "limited": "Limited", + "error": "Error" + } } diff --git a/dashboard/public/locales/kur.json b/dashboard/public/locales/kur.json index f360e66..37dd92b 100644 --- a/dashboard/public/locales/kur.json +++ b/dashboard/public/locales/kur.json @@ -1,237 +1,286 @@ { - "name": "Naw", - "status": "Dox", - "address": "Edrês", - "users": "Bekarhêneran", - "nodes": "Girêkan", - "services": "Rajekan", - "hosts": "Hostkan", - "username": "Nawî Bekarhêneran", - "password": "Têperrewişe", - "inbounds": "înbawendkan", - "submit": "Nardin", - "login": "Çûnejûrewe", - "edit": "Desitkarîkirdn", - "actions": "Fermankan", - "settings": "Sazkarîyekan", - "logs": "Tomarekan", - "config": "Kanfîg", - "open": "Kirnewe", - "delete": "Sirrînewe", - "port": "Port", - "create": "Dirust bike", - "path": "Path", - "host": "Host", - "alpn": "ALPN", - "security": "Asayiş", - "user_info": "Zanyarî bekarhêneran", - "subscription": "Beşdarî", - "subscription_link": "Link Beşdarî", - "fingerprint": "Fingerprint", - "enable": "Çalakî ke", - "disable": "Naçalakî ke", - "close": "Dayixe", - "save": "Seyv Bike", - "days": "Roj", - "home": "Malê", - "page": { - "settings": { - "certificate": { - "copy": "Copy Certificate", - "error": "Certificate Unavailable", - "copied": "Certificate Copied" - } - }, - "login": { - "username": { - "error": { - "isRequired": "Username is required" - } - }, - "password": { - "error": { - "is-required": "Password is required" - } - } - }, - "nodes": { - "settings": { - "title": "Nodes Settings", - "detail": "Details", - "log-container": { - "title": "Marznode Xray config logs" - } - }, - "dialogs": { - "creation": { - "title": "Nodes Creation" - }, - "deletion": { - "title": "Nodes Deletion" - }, - "edition": { - "title": "Nodes Edition" - } - }, - "usage_coefficient": "Usage Coefficient", - "add_as_new_host": "Add as new hosts", - "certificate-alert": { - "title": "Nodes Certificate Configuration", - "desc": "If you aim to configure your nodes certificate, checkout settings page.", - "click": "Click Here" - } - }, - "services": { - "settings": { - "title": "Servicecs Settings", - "detail": "Details" - }, - "dialogs": { - "creation": { - "title": "Services Creation" - }, - "deletion": { - "title": "Services Deletion" - }, - "edition": { - "title": "Services Edition" - } - } - }, - "users": { - "data_limit": "Sinûrî data", - "used_traffic": "Traffic bekarhêndiraw", - "data_limit_reset_strategy": "Şêwazî sirrinewey sinûrî data", - "online_at": "Online Le", - "expire_date": "Katî beserçûn", - "unlimited": "Bêsinûr", - "limited": "Sinûrdar", - "data_limit_method": "Çonêtî Sinûrî Data", - "determined_expire": "Diyarî kiraw", - "expire_method": "Sêwazî beserçûn", - "onhold_expire": "Ragîraw", - "reset_usage": "Rêsitkirdinî bekarhênraw", - "revoke_subscription": "Hellweşandinewey Beşdarîkirdn", - "on_hold_expire_duration": "Ragîraw", - "on_hold_timeout": "Kotayî hatnî ragirtn", - "settings": { - "subscription_link": { - "copied": "Subscription Link Copied", - "copy": "Copy Subscription Link", - "error": "Unable to copy Subscription Link" - } - }, - "services-alert": { - "title": "No Service were found", - "desc": "Make sure you create services to enable your users with inbounds to connect with.", - "click": "Click Here" - }, - "dialogs": { - "edition": { - "title": "Users Edition" - }, - "creation": { - "title": "Users Creation" - }, - "deletion": { - "title": "Users Deletion" - }, - "mutation": { - "note": { - "desc": "Leave a note about your user" - } - } - } - }, - "hosts": { - "no-inbound-selection-alert": { - "title": "Hîç înbawendêk hell nebijardirawe", - "desc": "Debêt înbawendêk hell bibjêrî ta we ku hostêk dirust bikey." - }, - "allowinsecure": "Allow insecure", - "dialogs": { - "edition": { - "title": "Hosts Edition" - }, - "creation": { - "title": "Hosts Creation" - }, - "deletion": { - "title": "Hosts Deletion" - }, - "mutation": { - "allowinsecure": { - "alert": "Due to security concerns, it is not recommended to allow Insecure" - } - } - } - } - }, - "events": { - "delete": { - "error": "Failed to delete {{name}}", - "success": { - "title": "{{name}} is successfuly deleted", - "desc": "" - } - }, - "create": { - "error": "Failed to create {{name}}", - "success": { - "title": "{{name}} is successfuly created", - "desc": "" - } - }, - "update": { - "error": "Failed to update {{name}}", - "success": { - "title": "{{name}} is successfuly updated", - "desc": "" - } - }, - "user_revoke": { - "error": "Failed to revoke {{name}} subscription", - "success": { - "title": "{{name}} is successfuly created", - "desc": "" - } - }, - "user_reset": { - "error": "Failed to reset {{name}} data usage", - "success": { - "title": "{{name}} data usage is successfuly reseted", - "desc": "" - } - }, - "user_status": { - "error": "Şikistî hêna le gorrînî doxî çalak kirdinî {{name}}", - "success": { - "title": "Doxî çalakî {{name}} nwêkirawe", - "desc": "Doxî çalak kirdin be serkewtûyî nwêkirawe" - } - } - }, - "delete-confirmation": { - "title": "Are you sure of this?", - "desc": "This action cannot be undone. This will permanently delete your account and remove your data from our servers." - }, - "table": { - "row-per-page": "Rows per page", - "no-result": "No results", - "selected": "Selected", - "toggle-column": "Toggle columns", - "filter-placeholder": "Filter {{name}}" - }, - "statuses": { - "active": "Active", - "connected": "Connected", - "healthy": "Healthy", - "disabled": "Disabled", - "expired": "Expired", - "on_hold": "On Hold", - "connecting": "Connecting", - "unhealthy": "Unhealthy", - "limited": "Limited", - "error": "Error" - } + "name": "Naw", + "status": "Dox", + "address": "Edrês", + "users": "Bekarhêneran", + "nodes": "Girêkan", + "services": "Rajekan", + "hosts": "Hostkan", + "username": "Nawî Bekarhêneran", + "password": "Têperrewişe", + "inbounds": "înbawendkan", + "submit": "Nardin", + "login": "Çûnejûrewe", + "edit": "Desitkarîkirdn", + "actions": "Fermankan", + "settings": "Sazkarîyekan", + "logs": "Tomarekan", + "config": "Kanfîg", + "open": "Kirnewe", + "delete": "Sirrînewe", + "port": "Port", + "create": "Dirust bike", + "path": "Path", + "host": "Host", + "alpn": "ALPN", + "security": "Asayiş", + "sni": "SNI", + "user_info": "Zanyarî bekarhêneran", + "subscription": "Beşdarî", + "subscription_link": "Link Beşdarî", + "fingerprint": "Fingerprint", + "enable": "Çalakî ke", + "disable": "Naçalakî ke", + "close": "Dayixe", + "save": "Seyv Bike", + "days": "Roj", + "home": "Malê", + "advanced-options": "Bijardikanî pêşkewtû", + "page": { + "settings": { + "certificate": { + "copy": "Copy Certificate", + "error": "Certificate Unavailable", + "copied": "Certificate Copied" + } + }, + "login": { + "username": { + "error": { + "isRequired": "Username is required" + } + }, + "password": { + "error": { + "is-required": "Password is required" + } + } + }, + "nodes": { + "settings": { + "title": "Nodes Settings", + "detail": "Details", + "log-container": { + "title": "Marznode Xray config logs" + } + }, + "dialogs": { + "creation": { + "title": "Nodes Creation" + }, + "deletion": { + "title": "Nodes Deletion" + }, + "edition": { + "title": "Nodes Edition" + } + }, + "usage_coefficient": "Usage Coefficient", + "add_as_new_host": "Add as new hosts", + "certificate-alert": { + "title": "Nodes Certificate Configuration", + "desc": "If you aim to configure your nodes certificate, checkout settings page.", + "click": "Click Here" + } + }, + "services": { + "settings": { + "title": "Servicecs Settings", + "detail": "Details" + }, + "dialogs": { + "creation": { + "title": "Services Creation" + }, + "deletion": { + "title": "Services Deletion" + }, + "edition": { + "title": "Services Edition" + } + } + }, + "users": { + "data_limit": "Sinûrî data", + "used_traffic": "Traffic bekarhêndiraw", + "data_limit_reset_strategy": "Şêwazî sirrinewey sinûrî data", + "online_at": "Online Le", + "expire_date": "Katî beserçûn", + "unlimited": "Bêsinûr", + "limited": "Sinûrdar", + "data_limit_method": "Çonêtî Sinûrî Data", + "determined_expire": "Diyarî kiraw", + "expire_method": "Sêwazî beserçûn", + "onhold_expire": "Ragîraw", + "reset_usage": "Rêsitkirdinî bekarhênraw", + "revoke_subscription": "Hellweşandinewey Beşdarîkirdn", + "on_hold_expire_duration": "Ragîraw", + "on_hold_timeout": "Kotayî hatnî ragirtn", + "settings": { + "subscription_link": { + "copied": "Subscription Link Copied", + "copy": "Copy Subscription Link", + "error": "Unable to copy Subscription Link" + } + }, + "services-alert": { + "title": "No Service were found", + "desc": "Make sure you create services to enable your users with inbounds to connect with.", + "click": "Click Here" + }, + "dialogs": { + "edition": { + "title": "Users Edition" + }, + "creation": { + "title": "Users Creation" + }, + "deletion": { + "title": "Users Deletion" + }, + "mutation": { + "note": { + "desc": "Leave a note about your user" + } + } + } + }, + "hosts": { + "fragment": { + "interval": "Interval", + "length": "Length", + "packets": "Packets", + "length-error": "Length can not exceed 32 char and must be number-number", + "interval-error": "Interval can not exceed 32 char and must be number-number", + "packets-error": "Packets can not exceed 32 char and must be number-number or be tlshello" + }, + "popover-guide": { + "title": "Using this setting, you are able to assign specific address for each inbound.", + "desc": "Use these variables to make it dynamic", + "username": "The username of the user", + "dataUsage": "The current usage of the user", + "remainingData": "Remaining data of the user", + "dataLimit": "The usage limit of the user", + "remainingDays": "Remaining days of the user", + "expireDate": "Expiry date of the user", + "jalaliExpireDate": "Expiry date of the user in solar calendar", + "remainingTime": "Remaining time of the user", + "statusEmoji": "User status as an emoji (✅,⌛,🪫,❌,🔌)", + "proxyProtocol": "Proxy protocol (e.g. VMess)", + "proxyMethod": "Proxy transport method (e.g. ws)", + "currentServer": "Current server ip address", + "security": "Security Layer", + "host": "Request Host", + "port": "Port", + "path": "Path", + "sni": "SNI", + "advancedOptions": "Advanced options", + "addHost": "Add host", + "savedSuccess": "Hosts saved successfully", + "loading": "loading...", + "apply": "Apply", + "port.info": "By default, a host uses the default port of the inbound. You can set a custom port in case this host is a server that forwards traffic from a port that differs from your server's port. For example, the server may forward traffic from port 8443 to the default port of your inbound server.", + "path.info": "Set a path for host users, useful behind a reverse proxy.", + "sni.info": "By default, a host uses the default SNI of the inbound. You can set a custom SNI in case this host is a server that has a different SNI. For example, the server may receive traffic with a different SSL certificate, perform SSL termination, and forward it to your inbound server.", + "host.info": "By default, if a request host is set in the Xray config, this host is used. However, you can set a custom request host here if needed.", + "security.info": "If the middleware server of this host uses a different security layer than the inbound's default, you can set a custom security layer here.", + "alpn": "ALPN", + "fingerprint": "Fingerprint", + "host.multiHost": "To set multiple addresses, separate them with , Each time an address is chosen randomly.", + "host.wildcard": "Use * to generate a random string (works for wildcard domain names)" + }, + "mux": { + "title": "Connection multiplexing", + "desc": "Currently supported by v2rayng while using custom config" + }, + "no-inbound-selection-alert": { + "title": "Hîç înbawendêk hell nebijardirawe", + "desc": "Debêt înbawendêk hell bibjêrî ta we ku hostêk dirust bikey." + }, + "allowinsecure": "Allow insecure", + "dialogs": { + "edition": { + "title": "Gorînî Host" + }, + "creation": { + "title": "Dirust kirdin Host" + }, + "deletion": { + "title": "Sirînewey Host" + }, + "mutation": { + "allowinsecure": { + "alert": "Due to security concerns, it is not recommended to allow Insecure" + } + } + } + } + }, + "events": { + "delete": { + "error": "Failed to delete {{name}}", + "success": { + "title": "{{name}} is successfuly deleted", + "desc": "" + } + }, + "create": { + "error": "Failed to create {{name}}", + "success": { + "title": "{{name}} is successfuly created", + "desc": "" + } + }, + "update": { + "error": "Failed to update {{name}}", + "success": { + "title": "{{name}} is successfuly updated", + "desc": "" + } + }, + "user_revoke": { + "error": "Failed to revoke {{name}} subscription", + "success": { + "title": "{{name}} is successfuly created", + "desc": "" + } + }, + "user_reset": { + "error": "Failed to reset {{name}} data usage", + "success": { + "title": "{{name}} data usage is successfuly reseted", + "desc": "" + } + }, + "user_status": { + "error": "Şikistî hêna le gorrînî doxî çalak kirdinî {{name}}", + "success": { + "title": "Doxî çalakî {{name}} nwêkirawe", + "desc": "Doxî çalak kirdin be serkewtûyî nwêkirawe" + } + } + }, + "delete-confirmation": { + "title": "Are you sure of this?", + "desc": "This action cannot be undone. This will permanently delete your account and remove your data from our servers." + }, + "table": { + "row-per-page": "Rows per page", + "no-result": "No results", + "selected": "Selected", + "toggle-column": "Toggle columns", + "filter-placeholder": "Filter {{name}}" + }, + "statuses": { + "active": "Active", + "connected": "Connected", + "healthy": "Healthy", + "disabled": "Disabled", + "expired": "Expired", + "on_hold": "On Hold", + "connecting": "Connecting", + "unhealthy": "Unhealthy", + "limited": "Limited", + "error": "Error" + } } diff --git a/dashboard/src/components/entity-table/hooks/use-dialog.ts b/dashboard/src/components/entity-table/hooks/use-dialog.ts index eaee78b..d6eef62 100644 --- a/dashboard/src/components/entity-table/hooks/use-dialog.ts +++ b/dashboard/src/components/entity-table/hooks/use-dialog.ts @@ -1,4 +1,3 @@ -import { useState } from "react"; export interface UseDialogProps { open: boolean; @@ -6,4 +5,3 @@ export interface UseDialogProps { entity: T | null; } -export const useDialog = () => useState(false) diff --git a/dashboard/src/components/entity-table/index.tsx b/dashboard/src/components/entity-table/index.tsx index b11934e..2b92685 100644 --- a/dashboard/src/components/entity-table/index.tsx +++ b/dashboard/src/components/entity-table/index.tsx @@ -10,7 +10,6 @@ import { UseRowSelectionReturn, UseDialogProps, useFiltering, - useDialog, usePagination, FetchEntityReturn, useEntityTable, @@ -20,6 +19,7 @@ import { QueryKey, EntityQueryKeyType, } from "./hooks"; +import { useDialog } from "@marzneshin/hooks" interface EntityTableProps { fetchEntity: ({ queryKey }: EntityQueryKeyType) => FetchEntityReturn; diff --git a/dashboard/src/components/index.ts b/dashboard/src/components/index.ts index 156f660..f51a901 100644 --- a/dashboard/src/components/index.ts +++ b/dashboard/src/components/index.ts @@ -1,10 +1,12 @@ -export * from './ui' -export * from './header' -export * from './sidebar' -export * from './data-table' -export * from './delete-confirmation-dialog' -export * from './nav-dir' -export * from './header' -export * from './entity-table' -export * from './copy-button' -export * from './page' +export * from "./ui"; +export * from "./header"; +export * from "./sidebar"; +export * from "./data-table"; +export * from "./delete-confirmation-dialog"; +export * from "./nav-dir"; +export * from "./header"; +export * from "./entity-table"; +export * from "./copy-button"; +export * from "./page"; +export * from "./stacks"; +export * from "./stats-widgets"; diff --git a/dashboard/src/components/stacks/horizontal.tsx b/dashboard/src/components/stacks/horizontal.tsx new file mode 100644 index 0000000..f571ee5 --- /dev/null +++ b/dashboard/src/components/stacks/horizontal.tsx @@ -0,0 +1,5 @@ +import { FC, PropsWithChildren } from "react"; + +export const HStack: FC = ({ children }) => { + return
{children}
; +}; diff --git a/dashboard/src/components/stacks/index.ts b/dashboard/src/components/stacks/index.ts new file mode 100644 index 0000000..0038df5 --- /dev/null +++ b/dashboard/src/components/stacks/index.ts @@ -0,0 +1,2 @@ +export * from "./vertical"; +export * from "./horizontal"; diff --git a/dashboard/src/components/stacks/vertical.tsx b/dashboard/src/components/stacks/vertical.tsx new file mode 100644 index 0000000..8998b3e --- /dev/null +++ b/dashboard/src/components/stacks/vertical.tsx @@ -0,0 +1,5 @@ +import { FC, PropsWithChildren } from "react"; + +export const VStack: FC = ({ children }) => { + return
{children}
; +}; diff --git a/dashboard/src/components/ui/accordion.tsx b/dashboard/src/components/ui/accordion.tsx new file mode 100644 index 0000000..2075990 --- /dev/null +++ b/dashboard/src/components/ui/accordion.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@marzneshin/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) + +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/dashboard/src/components/ui/index.ts b/dashboard/src/components/ui/index.ts index 636e89d..e633c4e 100644 --- a/dashboard/src/components/ui/index.ts +++ b/dashboard/src/components/ui/index.ts @@ -1,33 +1,34 @@ -export * from "./button" -export * from "./alert-dialog" -export * from "./alert" -export * from "./badge" -export * from "./breadcrumb" -export * from "./button" -export * from "./card" -export * from "./checkbox" -export * from "./collapsible" -export * from "./dialog" -export * from "./drawer" -export * from "./dropdown-menu" -export * from "./form" -export * from "./input" -export * from "./label" -export * from "./popover" -export * from "./resizable" -export * from "./separator" -export * from "./sonner" -export * from "./switch" -export * from "./table" -export * from "./toggle" -export * from "./tooltip" -export * from "./aspect-ratio" -export * from "./select" -export * from "./sheet" -export * from "./tabs" -export * from "./progress" -export * from "./calendar" -export * from "./scroll-area" -export * from "./toggle-group" -export * from "./textarea" -export * from "./radio-group" +export * from "./button"; +export * from "./alert-dialog"; +export * from "./alert"; +export * from "./badge"; +export * from "./breadcrumb"; +export * from "./button"; +export * from "./card"; +export * from "./checkbox"; +export * from "./collapsible"; +export * from "./dialog"; +export * from "./drawer"; +export * from "./dropdown-menu"; +export * from "./form"; +export * from "./input"; +export * from "./label"; +export * from "./popover"; +export * from "./resizable"; +export * from "./separator"; +export * from "./sonner"; +export * from "./switch"; +export * from "./table"; +export * from "./toggle"; +export * from "./tooltip"; +export * from "./aspect-ratio"; +export * from "./select"; +export * from "./sheet"; +export * from "./tabs"; +export * from "./progress"; +export * from "./calendar"; +export * from "./scroll-area"; +export * from "./toggle-group"; +export * from "./textarea"; +export * from "./radio-group"; +export * from "./accordion"; diff --git a/dashboard/src/features/hosts/dialogs/mutation/fields/alpn.tsx b/dashboard/src/features/hosts/dialogs/mutation/fields/alpn.tsx index 793eb7a..dd23e1d 100644 --- a/dashboard/src/features/hosts/dialogs/mutation/fields/alpn.tsx +++ b/dashboard/src/features/hosts/dialogs/mutation/fields/alpn.tsx @@ -1,4 +1,3 @@ - import { FormControl, FormField, @@ -15,15 +14,15 @@ import { useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; export const AlpnField = () => { - const { t } = useTranslation() - const form = useFormContext() + const { t } = useTranslation(); + const form = useFormContext(); return ( ( - - {t('alpn')} + + {t("alpn")} )} /> - ) -} + ); +}; diff --git a/dashboard/src/features/hosts/dialogs/mutation/fields/fingerprint.tsx b/dashboard/src/features/hosts/dialogs/mutation/fields/fingerprint.tsx index 2686855..b2a06a9 100644 --- a/dashboard/src/features/hosts/dialogs/mutation/fields/fingerprint.tsx +++ b/dashboard/src/features/hosts/dialogs/mutation/fields/fingerprint.tsx @@ -1,53 +1,51 @@ - - import { - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@marzneshin/components"; import { useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; export const FingerprintField = () => { - const { t } = useTranslation() - const form = useFormContext() - return ( - ( - - {t('fingerprint')} - - - - )} - /> - ) -} + const { t } = useTranslation(); + const form = useFormContext(); + return ( + ( + + {t("fingerprint")} + + + + )} + /> + ); +}; diff --git a/dashboard/src/features/hosts/dialogs/mutation/fields/fragment.tsx b/dashboard/src/features/hosts/dialogs/mutation/fields/fragment.tsx new file mode 100644 index 0000000..87a0204 --- /dev/null +++ b/dashboard/src/features/hosts/dialogs/mutation/fields/fragment.tsx @@ -0,0 +1,198 @@ +import { + FormControl, + FormField, + FormItem, + FormLabel, + Input, + Button, + Popover, + PopoverContent, + PopoverTrigger, +} from "@marzneshin/components"; +import { + useWatch, + useFormContext, + FieldErrors, + FieldError, +} from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { MailWarning, TrashIcon } from "lucide-react"; +import { FC } from "react"; + +interface FragmentForm { + fragment: { + packets: FieldError; + length: FieldError; + interval: FieldError; + }; +} + +const FragmentErrorPopover: FC = () => { + const form = useFormContext(); + const { t } = useTranslation(); + const errors = form.formState.errors as FieldErrors; + return ( + + + + + +
    + {errors.fragment?.length?.message && ( +
  • + Length: {t(errors.fragment.length.message as string)} +
  • + )} + {errors.fragment?.packets?.message && ( +
  • + Packets: {t(errors.fragment.packets.message as string)} +
  • + )} + {errors.fragment?.interval?.message && ( +
  • + Interval: {t(errors.fragment.interval.message as string)} +
  • + )} +
+
+
+ ); +}; + +export const FragmentField = () => { + const { t } = useTranslation(); + const form = useFormContext(); + const errors = form.formState.errors as FieldErrors; + const fragment = useWatch({ name: "fragment" }); + const disabled = fragment === null; + + const enable = () => { + form.setValue( + "fragment", + { length: "", packets: "", interval: "" }, + { + shouldTouch: true, + shouldDirty: true, + }, + ); + }; + + const disable = () => { + form.setValue("fragment.length", ""); + form.setValue("fragment.packets", ""); + form.setValue("fragment.interval", ""); + form.setValue("fragment", null, { + shouldValidate: true, + shouldTouch: false, + shouldDirty: false, + }); + }; + + const hasFragmentErrors = (errors: FieldErrors) => + errors.fragment?.packets || + errors.fragment?.length || + errors.fragment?.interval; + + return ( + ( + + + {t("fragment")} +
+ {hasFragmentErrors(errors) && } + {!disabled && ( + + )} +
+
+ + +
+ {!disabled ? ( + <> + ( +
+ + + {t("page.hosts.fragment.packets")} + + + + + +
+ )} + /> + ( +
+ + + {t("page.hosts.fragment.length")} + + + + + +
+ )} + /> + ( +
+ + + {t("page.hosts.fragment.interval")} + + + + + +
+ )} + /> + + ) : ( + + )} +
+
+
+ )} + /> + ); +}; diff --git a/dashboard/src/features/hosts/dialogs/mutation/fields/host.tsx b/dashboard/src/features/hosts/dialogs/mutation/fields/host.tsx index 2bbfba0..a91d4cd 100644 --- a/dashboard/src/features/hosts/dialogs/mutation/fields/host.tsx +++ b/dashboard/src/features/hosts/dialogs/mutation/fields/host.tsx @@ -4,21 +4,21 @@ import { FormItem, FormLabel, FormMessage, - Input + Input, } from "@marzneshin/components"; import { useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; export const HostField = () => { - const { t } = useTranslation() - const form = useFormContext() + const { t } = useTranslation(); + const form = useFormContext(); return ( ( - - {t('host')} + + {t("host")} @@ -26,5 +26,5 @@ export const HostField = () => { )} /> - ) -} + ); +}; diff --git a/dashboard/src/features/hosts/dialogs/mutation/fields/index.ts b/dashboard/src/features/hosts/dialogs/mutation/fields/index.ts index 26e6b32..87d0e1b 100644 --- a/dashboard/src/features/hosts/dialogs/mutation/fields/index.ts +++ b/dashboard/src/features/hosts/dialogs/mutation/fields/index.ts @@ -8,3 +8,5 @@ export * from './security' export * from './sni' export * from './alpn' export * from './fingerprint' +export * from './mux' +export * from './fragment' diff --git a/dashboard/src/features/hosts/dialogs/mutation/fields/mux.tsx b/dashboard/src/features/hosts/dialogs/mutation/fields/mux.tsx new file mode 100644 index 0000000..4fcc420 --- /dev/null +++ b/dashboard/src/features/hosts/dialogs/mutation/fields/mux.tsx @@ -0,0 +1,39 @@ +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormDescription, + Checkbox, +} from "@marzneshin/components"; +import { useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +export const MuxField = () => { + const { t } = useTranslation(); + const form = useFormContext(); + return ( + ( + <> + {t("mux")} + + + + + +
+ {t("page.hosts.mux.title")} + {t("page.hosts.mux.desc")} +
+
+ + )} + /> + ); +}; diff --git a/dashboard/src/features/hosts/dialogs/mutation/fields/path.tsx b/dashboard/src/features/hosts/dialogs/mutation/fields/path.tsx index 2033bfb..2ebad1e 100644 --- a/dashboard/src/features/hosts/dialogs/mutation/fields/path.tsx +++ b/dashboard/src/features/hosts/dialogs/mutation/fields/path.tsx @@ -1,25 +1,24 @@ - import { FormControl, FormField, FormItem, FormLabel, FormMessage, - Input + Input, } from "@marzneshin/components"; import { useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; export const PathField = () => { - const { t } = useTranslation() - const form = useFormContext() + const { t } = useTranslation(); + const form = useFormContext(); return ( ( - - {t('path')} + + {t("path")} @@ -27,5 +26,5 @@ export const PathField = () => { )} /> - ) -} + ); +}; diff --git a/dashboard/src/features/hosts/dialogs/mutation/fields/security.tsx b/dashboard/src/features/hosts/dialogs/mutation/fields/security.tsx index beae0b7..36ea75e 100644 --- a/dashboard/src/features/hosts/dialogs/mutation/fields/security.tsx +++ b/dashboard/src/features/hosts/dialogs/mutation/fields/security.tsx @@ -1,15 +1,14 @@ - import { - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@marzneshin/components"; import { useEffect, useState } from "react"; import { useFormContext } from "react-hook-form"; @@ -17,46 +16,50 @@ import { useTranslation } from "react-i18next"; import { AllowInsecureField, AlpnField, FingerprintField, SniField } from "."; export const SecurityFields = () => { - const { t } = useTranslation() - const form = useFormContext() - const security = form.watch().security + const { t } = useTranslation(); + const form = useFormContext(); + const security = form.watch().security; - const [extraSecurity, setExtraSecurity] = useState(["tls", "inbound_default"].includes(security)) - useEffect(() => { - setExtraSecurity(["tls", "inbound_default"].includes(security)) - }, [security, setExtraSecurity]) - return ( - <> - ( - - {t('security')} - - - - )} - /> - {extraSecurity && - <> - - - - - - } - - ) -} + const [extraSecurity, setExtraSecurity] = useState( + ["tls", "inbound_default"].includes(security), + ); + useEffect(() => { + setExtraSecurity(["tls", "inbound_default"].includes(security)); + }, [security, setExtraSecurity]); + return ( + <> + ( + + {t("security")} + + + + )} + /> + {extraSecurity && ( + <> + +
+ + +
+ + + )} + + ); +}; diff --git a/dashboard/src/features/hosts/dialogs/mutation/index.tsx b/dashboard/src/features/hosts/dialogs/mutation/index.tsx index 2579b74..9428b92 100644 --- a/dashboard/src/features/hosts/dialogs/mutation/index.tsx +++ b/dashboard/src/features/hosts/dialogs/mutation/index.tsx @@ -6,38 +6,44 @@ import { DialogHeader, Form, Button, -} from '@marzneshin/components'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { useTranslation } from 'react-i18next'; + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@marzneshin/components"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslation } from "react-i18next"; import { HostSchema, HostType, getDefaultValues, useHostsCreationMutation, - useHostsUpdateMutation + useHostsUpdateMutation, } from "@marzneshin/features/hosts"; import { RemarkField } from "./fields/remark"; import { AddressField, PortField, HostField, + MuxField, + FragmentField, PathField, SecurityFields, } from "./fields"; interface MutationDialogProps { - entity: HostType | null - open: boolean - inboundId: number - onOpenChange: (state: boolean) => void + entity: HostType | null; + open: boolean; + inboundId: number; + onOpenChange: (state: boolean) => void; } const transformFormValue = (values: HostType) => { - const alpn = values.alpn === "none" ? "" : values.alpn - const fingerprint = values.fingerprint === "none" ? "" : values.fingerprint - return { ...values, alpn, fingerprint } -} + const alpn = values.alpn === "none" ? "" : values.alpn; + const fingerprint = values.fingerprint === "none" ? "" : values.fingerprint; + return { ...values, alpn, fingerprint }; +}; export const HostsMutationDialog: FC = ({ inboundId, @@ -47,14 +53,14 @@ export const HostsMutationDialog: FC = ({ }) => { const form = useForm({ defaultValues: entity ? entity : getDefaultValues(), - resolver: zodResolver(HostSchema) + resolver: zodResolver(HostSchema), }); const updateMutation = useHostsUpdateMutation(); const createMutation = useHostsCreationMutation(); const { t } = useTranslation(); const submit = (values: HostType) => { - const host = transformFormValue(values) + const host = transformFormValue(values); if (entity && entity.id !== undefined) { updateMutation.mutate({ hostId: entity.id, host }); } else { @@ -74,26 +80,37 @@ export const HostsMutationDialog: FC = ({ {entity - ? t('page.hosts.dialogs.edition.title') - : t('page.hosts.dialogs.creation.title')} + ? t("page.hosts.dialogs.edition.title") + : t("page.hosts.dialogs.creation.title")}
-
+
- - - + + + {t("advanced-options")} + +
+ + +
+ + + +
+
+
diff --git a/dashboard/src/features/hosts/schema/get-default.ts b/dashboard/src/features/hosts/schema/get-default.ts index e9f31d8..efa6ec5 100644 --- a/dashboard/src/features/hosts/schema/get-default.ts +++ b/dashboard/src/features/hosts/schema/get-default.ts @@ -8,6 +8,8 @@ export const getDefaultValues = (): HostSchemaType => { path: '', address: '', remark: '', + fragment: null, + mux: false, security: 'inbound_default', alpn: "", allowinsecure: false, diff --git a/dashboard/src/features/hosts/schema/hosts-schema.ts b/dashboard/src/features/hosts/schema/hosts-schema.ts index afbe0eb..69b7530 100644 --- a/dashboard/src/features/hosts/schema/hosts-schema.ts +++ b/dashboard/src/features/hosts/schema/hosts-schema.ts @@ -1,30 +1,74 @@ import { z } from "zod"; +import i18n from "i18next"; + +const numberInterval = (v: string | null | undefined) => { + return v ? /^[\d-]{1,32}$/.test(v) : false; +}; + +const packetsInterval = (v: string | null | undefined) => { + return v ? /^(:?tlshello|[\d-]{1,32})$/.test(v) : false; +}; export const HostSchema = z.object({ - remark: z.string().min(1, 'Remark is required'), - address: z.string().min(1, 'Address is required'), - port: z.coerce.number() - .gte(1, 'Port must be more than 1') - .lte(65535, 'Port can not be more than 65535') - .or(z.string()).nullable().optional(), + remark: z.string().min(1, "Remark is required"), + address: z.string().min(1, "Address is required"), + port: z.coerce + .number() + .gte(1, "Port must be more than 1") + .lte(65535, "Port can not be more than 65535") + .or(z.string()) + .nullable() + .optional(), path: z.string().nullable().optional(), sni: z.string().nullable().optional(), host: z.string().nullable().optional(), - security: z.enum(['inbound_default', 'none', 'tls']).default("inbound_default"), - alpn: z.enum(['h2', 'http/1.1', 'h2,http/1.1', 'none', '']).optional().default("none"), + mux: z.boolean().nullable().optional(), + fragment: z + .object({ + interval: z + .string() + .refine( + (v) => numberInterval(v), + i18n.t("page.hosts.fragment.interval-error"), + ), + length: z + .string() + .refine( + (v) => numberInterval(v), + i18n.t("page.hosts.fragment.length-error"), + ), + packets: z + .string() + .refine( + (v) => packetsInterval(v), + i18n.t("page.hosts.fragment.packets-error"), + ), + }) + .nullable() + .optional(), + security: z + .enum(["inbound_default", "none", "tls"]) + .default("inbound_default"), + alpn: z + .enum(["h2", "http/1.1", "h2,http/1.1", "none", ""]) + .optional() + .default("none"), allowinsecure: z.boolean().default(false).optional(), - fingerprint: z.enum([ - "", - "none", - "chrome", - "firefox", - "safari", - "ios", - "android", - "edge", - "360", - "qq", - "random", - "randomized", - ]).optional().default("none"), + fingerprint: z + .enum([ + "", + "none", + "chrome", + "firefox", + "safari", + "ios", + "android", + "edge", + "360", + "qq", + "random", + "randomized", + ]) + .optional() + .default("none"), }); diff --git a/dashboard/src/features/hosts/tables/inbound-hosts/index.tsx b/dashboard/src/features/hosts/tables/inbound-hosts/index.tsx index ab436fd..7cce230 100644 --- a/dashboard/src/features/hosts/tables/inbound-hosts/index.tsx +++ b/dashboard/src/features/hosts/tables/inbound-hosts/index.tsx @@ -10,7 +10,7 @@ import { useState } from 'react' import { useInboundsQuery } from '@marzneshin/features/inbounds' import { columns } from './columns' import { HostSettingsDialog } from '../../dialogs/settings' -import { useDialog } from '@marzneshin/components' +import { useDialog } from '@marzneshin/hooks' import { InboundNotSelectedAlertDialog } from './inbound-not-selected-alert-dialog' export const InboundHostsTable = () => { diff --git a/dashboard/src/globals.css b/dashboard/src/globals.css index 62bcaa8..c52d140 100644 --- a/dashboard/src/globals.css +++ b/dashboard/src/globals.css @@ -58,8 +58,8 @@ --accent: 217.2 32.6% 17.5%; --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-accent: 0 55% 57%; + --destructive: 0 55% 57%; + --destructive-accent: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; --border: 217.2 32.6% 17.5%; diff --git a/dashboard/src/hooks/index.ts b/dashboard/src/hooks/index.ts index 9e25e4d..f0caed8 100644 --- a/dashboard/src/hooks/index.ts +++ b/dashboard/src/hooks/index.ts @@ -1,2 +1,3 @@ export * from './use-screen-breakpoint' +export * from './use-dialog' export * from './use-mutation-dialog' diff --git a/dashboard/src/hooks/use-dialog.ts b/dashboard/src/hooks/use-dialog.ts new file mode 100644 index 0000000..abe52ee --- /dev/null +++ b/dashboard/src/hooks/use-dialog.ts @@ -0,0 +1,3 @@ + +import { useState } from "react"; +export const useDialog = () => useState(false)