diff --git 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 <badge>,</badge> Each time an address is chosen randomly.",
- "host.wildcard": "Use <badge>*</badge> 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 <badge>,</badge> Each time an address is chosen randomly.",
+ "host.wildcard": "Use <badge>*</badge> 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
@@ -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 <badge>,</badge> Each time an address is chosen randomly.",
+ "host.wildcard": "Use <badge>*</badge> 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
@@ -1,4 +1,3 @@
-import { useState } from "react";
export interface UseDialogProps<T> {
open: boolean;
@@ -6,4 +5,3 @@ export interface UseDialogProps<T> {
entity: T | null;
}
-export const useDialog = () => useState<boolean>(false)
diff --git 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<T> {
fetchEntity: ({ queryKey }: EntityQueryKeyType) => FetchEntityReturn<T>;
diff --git 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
@@ -0,0 +1,5 @@
+import { FC, PropsWithChildren } from "react";
+
+export const HStack: FC<PropsWithChildren> = ({ children }) => {
+ return <div className="flex flex-row gap-2">{children}</div>;
+};
diff --git a/dashboard/src/components/stacks/index.ts 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
@@ -0,0 +1,5 @@
+import { FC, PropsWithChildren } from "react";
+
+export const VStack: FC<PropsWithChildren> = ({ children }) => {
+ return <div className="flex flex-col gap-2">{children}</div>;
+};
diff --git a/dashboard/src/components/ui/accordion.tsx 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<typeof AccordionPrimitive.Item>,
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
+>(({ className, ...props }, ref) => (
+ <AccordionPrimitive.Item
+ ref={ref}
+ className={cn("border-b", className)}
+ {...props}
+ />
+))
+AccordionItem.displayName = "AccordionItem"
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef<typeof AccordionPrimitive.Trigger>,
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
+>(({ className, children, ...props }, ref) => (
+ <AccordionPrimitive.Header className="flex">
+ <AccordionPrimitive.Trigger
+ ref={ref}
+ className={cn(
+ "flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+ <ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
+ </AccordionPrimitive.Trigger>
+ </AccordionPrimitive.Header>
+))
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef<typeof AccordionPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
+>(({ className, children, ...props }, ref) => (
+ <AccordionPrimitive.Content
+ ref={ref}
+ className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
+ {...props}
+ >
+ <div className={cn("pb-4 pt-0", className)}>{children}</div>
+ </AccordionPrimitive.Content>
+))
+
+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
@@ -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
@@ -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 (
<FormField
control={form.control}
name="alpn"
render={({ field }) => (
- <FormItem>
- <FormLabel>{t('alpn')}</FormLabel>
+ <FormItem className="w-full">
+ <FormLabel>{t("alpn")}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
@@ -33,13 +32,13 @@ export const AlpnField = () => {
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="h2">h2</SelectItem>
- <SelectItem value="http/1.1">HTTP 1.1</SelectItem>
- <SelectItem value="h2,http/1.1"> H2 HTTP 1.1</SelectItem>
+ <SelectItem value="http/1.1">http 1.1</SelectItem>
+ <SelectItem value="h2,http/1.1">h2,http/1.1</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
- )
-}
+ );
+};
diff --git 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 (
- <FormField
- control={form.control}
- name="fingerprint"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t('fingerprint')}</FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="Select fingerprint" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="none">None</SelectItem>
- <SelectItem value="chrome">Chrome</SelectItem>
- <SelectItem value="firefox">Firefox</SelectItem>
- <SelectItem value="safari">Safari</SelectItem>
- <SelectItem value="ios">iOS</SelectItem>
- <SelectItem value="android">Android</SelectItem>
- <SelectItem value="edge">Edge</SelectItem>
- <SelectItem value="360">360</SelectItem>
- <SelectItem value="qq">qq</SelectItem>
- <SelectItem value="random">Random</SelectItem>
- <SelectItem value="randomized">Randomized</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- )
-}
+ const { t } = useTranslation();
+ const form = useFormContext();
+ return (
+ <FormField
+ control={form.control}
+ name="fingerprint"
+ render={({ field }) => (
+ <FormItem className="w-full">
+ <FormLabel>{t("fingerprint")}</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="Select fingerprint" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="none">None</SelectItem>
+ <SelectItem value="chrome">Chrome</SelectItem>
+ <SelectItem value="firefox">Firefox</SelectItem>
+ <SelectItem value="safari">Safari</SelectItem>
+ <SelectItem value="ios">iOS</SelectItem>
+ <SelectItem value="android">Android</SelectItem>
+ <SelectItem value="edge">Edge</SelectItem>
+ <SelectItem value="360">360</SelectItem>
+ <SelectItem value="qq">qq</SelectItem>
+ <SelectItem value="random">Random</SelectItem>
+ <SelectItem value="randomized">Randomized</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ );
+};
diff --git a/dashboard/src/features/hosts/dialogs/mutation/fields/fragment.tsx 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<FragmentForm>;
+ return (
+ <Popover>
+ <PopoverTrigger>
+ <MailWarning className="p-0 size-5 text-destructive bg-primary-background" />
+ </PopoverTrigger>
+ <PopoverContent className="bg-destructive-accent text-sm">
+ <ul className="my-6 ml-3 list-disc [&>li]:mt-1 mt-0">
+ {errors.fragment?.length?.message && (
+ <li>
+ <b>Length:</b> {t(errors.fragment.length.message as string)}
+ </li>
+ )}
+ {errors.fragment?.packets?.message && (
+ <li>
+ <b>Packets:</b> {t(errors.fragment.packets.message as string)}
+ </li>
+ )}
+ {errors.fragment?.interval?.message && (
+ <li>
+ <b>Interval:</b> {t(errors.fragment.interval.message as string)}
+ </li>
+ )}
+ </ul>
+ </PopoverContent>
+ </Popover>
+ );
+};
+
+export const FragmentField = () => {
+ const { t } = useTranslation();
+ const form = useFormContext();
+ const errors = form.formState.errors as FieldErrors<FragmentForm>;
+ 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<FragmentForm>) =>
+ errors.fragment?.packets ||
+ errors.fragment?.length ||
+ errors.fragment?.interval;
+
+ return (
+ <FormField
+ control={form.control}
+ name="fragment"
+ render={() => (
+ <FormItem className="my-2 w-full">
+ <FormLabel className="flex flex-row justify-between items-center">
+ {t("fragment")}
+ <div className="flex flex-row items-center gap-2">
+ {hasFragmentErrors(errors) && <FragmentErrorPopover />}
+ {!disabled && (
+ <Button
+ variant="destructive"
+ className="p-0 size-5 bg-primary-background"
+ onClick={(e) => {
+ e.preventDefault();
+ disable();
+ }}
+ >
+ <TrashIcon className="text-red-400" />
+ </Button>
+ )}
+ </div>
+ </FormLabel>
+
+ <FormControl>
+ <div className="flex flex-row w-full items-center">
+ {!disabled ? (
+ <>
+ <FormField
+ name="fragment.packets"
+ render={({ field }) => (
+ <div className="flex flex-col">
+ <FormItem>
+ <FormLabel className="font-medium">
+ {t("page.hosts.fragment.packets")}
+ </FormLabel>
+ <FormControl>
+ <Input
+ className="border rounded-none rounded-s-lg p-2 w-full"
+ {...field}
+ />
+ </FormControl>
+ </FormItem>
+ </div>
+ )}
+ />
+ <FormField
+ name="fragment.length"
+ render={({ field }) => (
+ <div className="flex flex-col">
+ <FormItem>
+ <FormLabel className="font-medium">
+ {t("page.hosts.fragment.length")}
+ </FormLabel>
+ <FormControl>
+ <Input
+ className="rounded-none w-full p-2"
+ {...field}
+ />
+ </FormControl>
+ </FormItem>
+ </div>
+ )}
+ />
+ <FormField
+ name="fragment.interval"
+ render={({ field }) => (
+ <div className="flex flex-col">
+ <FormItem>
+ <FormLabel className="font-medium">
+ {t("page.hosts.fragment.interval")}
+ </FormLabel>
+ <FormControl>
+ <Input
+ className="border w-full rounded-none rounded-e-lg p-2"
+ {...field}
+ />
+ </FormControl>
+ </FormItem>
+ </div>
+ )}
+ />
+ </>
+ ) : (
+ <Button
+ className="border w-full"
+ variant="ghost"
+ onClick={(e) => {
+ e.preventDefault();
+ enable();
+ }}
+ >
+ {t("disabled")}
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ );
+};
diff --git 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 (
<FormField
control={form.control}
name="host"
render={({ field }) => (
- <FormItem>
- <FormLabel>{t('host')}</FormLabel>
+ <FormItem className="w-full">
+ <FormLabel>{t("host")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -26,5 +26,5 @@ export const HostField = () => {
</FormItem>
)}
/>
- )
-}
+ );
+};
diff --git 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
@@ -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 (
+ <FormField
+ control={form.control}
+ name="mux"
+ render={({ field }) => (
+ <>
+ <FormLabel>{t("mux")}</FormLabel>
+
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 shadow">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel>{t("page.hosts.mux.title")}</FormLabel>
+ <FormDescription>{t("page.hosts.mux.desc")}</FormDescription>
+ </div>
+ </FormItem>
+ </>
+ )}
+ />
+ );
+};
diff --git 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 (
<FormField
control={form.control}
name="path"
render={({ field }) => (
- <FormItem >
- <FormLabel>{t('path')}</FormLabel>
+ <FormItem className="w-full">
+ <FormLabel>{t("path")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -27,5 +26,5 @@ export const PathField = () => {
</FormItem>
)}
/>
- )
-}
+ );
+};
diff --git 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<boolean>(["tls", "inbound_default"].includes(security))
- useEffect(() => {
- setExtraSecurity(["tls", "inbound_default"].includes(security))
- }, [security, setExtraSecurity])
- return (
- <>
- <FormField
- control={form.control}
- name="security"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t('security')}</FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="Select a verified email to display" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="none">None</SelectItem>
- <SelectItem value="tls">TLS</SelectItem>
- <SelectItem value="inbound_default">Inbound Default</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- {extraSecurity &&
- <>
- <SniField />
- <AlpnField />
- <FingerprintField />
- <AllowInsecureField />
- </>
- }
- </>
- )
-}
+ const [extraSecurity, setExtraSecurity] = useState<boolean>(
+ ["tls", "inbound_default"].includes(security),
+ );
+ useEffect(() => {
+ setExtraSecurity(["tls", "inbound_default"].includes(security));
+ }, [security, setExtraSecurity]);
+ return (
+ <>
+ <FormField
+ control={form.control}
+ name="security"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("security")}</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="Select a verified email to display" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="none">None</SelectItem>
+ <SelectItem value="tls">TLS</SelectItem>
+ <SelectItem value="inbound_default">Inbound Default</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {extraSecurity && (
+ <>
+ <SniField />
+ <div className="flex flex-row w-full gap-2">
+ <AlpnField />
+ <FingerprintField />
+ </div>
+ <AllowInsecureField />
+ </>
+ )}
+ </>
+ );
+};
diff --git 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<MutationDialogProps> = ({
inboundId,
@@ -47,14 +53,14 @@ export const HostsMutationDialog: FC<MutationDialogProps> = ({
}) => {
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<MutationDialogProps> = ({
<DialogHeader>
<DialogTitle className="text-primary">
{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")}
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(submit)}>
<RemarkField />
- <div className="flex flex-row gap-1 items-center">
+ <div className="flex flex-row gap-2 items-start">
<AddressField />
<PortField />
</div>
- <PathField />
- <HostField />
- <SecurityFields />
+ <Accordion type="single" collapsible>
+ <AccordionItem value="item-1">
+ <AccordionTrigger>{t("advanced-options")}</AccordionTrigger>
+ <AccordionContent>
+ <div className="flex flex-row gap-2 items-start">
+ <HostField />
+ <PathField />
+ </div>
+ <FragmentField />
+ <MuxField />
+ <SecurityFields />
+ </AccordionContent>
+ </AccordionItem>
+ </Accordion>
<Button
className="mt-3 w-full font-semibold"
type="submit"
disabled={form.formState.isSubmitting}
>
- {t('submit')}
+ {t("submit")}
</Button>
</form>
</Form>
diff --git 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,3 @@
+
+import { useState } from "react";
+export const useDialog = () => useState<boolean>(false)