🎉 feat: initialization

This commit is contained in:
web
2025-11-26 19:56:16 -08:00
commit a801849fb2
553 changed files with 213088 additions and 0 deletions
@@ -0,0 +1,145 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import { Card, CardContent } from "@workspace/ui/components/card";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { ArrayInput } from "@workspace/ui/composed/dynamic-Inputs";
import { Icon } from "@workspace/ui/composed/icon";
import {
getNodeMultiplier,
setNodeMultiplier,
} from "@workspace/ui/services/admin/system";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export default function DynamicMultiplier() {
const { t } = useTranslation("servers");
const [open, setOpen] = useState(false);
const [timeSlots, setTimeSlots] = useState<API.TimePeriod[]>([]);
const { data: periodsResp, refetch: refetchPeriods } = useQuery({
queryKey: ["getNodeMultiplier"],
queryFn: async () => {
const { data } = await getNodeMultiplier();
return (data.data?.periods || []) as API.TimePeriod[];
},
enabled: open,
});
useEffect(() => {
if (periodsResp) {
setTimeSlots(periodsResp);
}
}, [periodsResp]);
async function savePeriods() {
await setNodeMultiplier({ periods: timeSlots });
await refetchPeriods();
toast.success(t("server_config.saveSuccess", "Saved successfully"));
setOpen(false);
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Card>
<CardContent>
<div className="flex cursor-pointer items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icon
className="h-5 w-5 text-primary"
icon="mdi:clock-time-eight"
/>
</div>
<div className="flex-1">
<p className="font-medium">
{t(
"server_config.dynamic_multiplier",
"Dynamic multiplier"
)}
</p>
<p className="truncate text-muted-foreground text-sm">
{t(
"server_config.dynamic_multiplier_desc",
"Define time slots and multipliers to adjust traffic accounting."
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</CardContent>
</Card>
</SheetTrigger>
<SheetContent className="w-[600px] max-w-full md:max-w-3xl">
<SheetHeader>
<SheetTitle>
{t("server_config.dynamic_multiplier", "Dynamic multiplier")}
</SheetTitle>
<SheetDescription>
{t(
"server_config.dynamic_multiplier_desc",
"Define time slots and multipliers to adjust traffic accounting."
)}
</SheetDescription>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-60px-env(safe-area-inset-top))] px-6">
<div className="space-y-4 pt-4">
<ArrayInput<API.TimePeriod>
fields={[
{
name: "start_time",
prefix: t("server_config.fields.start_time", "Start time"),
type: "time",
step: "1",
},
{
name: "end_time",
prefix: t("server_config.fields.end_time", "End time"),
type: "time",
step: "1",
},
{
name: "multiplier",
prefix: t("server_config.fields.multiplier", "Multiplier"),
type: "number",
placeholder: "0",
},
]}
onChange={setTimeSlots}
value={timeSlots}
/>
</div>
</ScrollArea>
<SheetFooter className="flex-row justify-between pt-3">
<Button
onClick={() => setTimeSlots(periodsResp || [])}
variant="outline"
>
{t("server_config.fields.reset", "Reset")}
</Button>
<div className="flex gap-2">
<Button onClick={() => setOpen(false)} variant="outline">
{t("actions.cancel", "Cancel")}
</Button>
<Button onClick={savePeriods}>{t("actions.save", "Save")}</Button>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,112 @@
export const protocols = [
"shadowsocks",
"vmess",
"vless",
"trojan",
"hysteria",
"tuic",
"anytls",
"socks",
"naive",
"http",
"mieru",
] as const;
// Global label map for display; fallback to raw value if missing
export const LABELS = {
// transport
tcp: "TCP",
udp: "UDP",
websocket: "WebSocket",
grpc: "gRPC",
mkcp: "mKCP",
httpupgrade: "HTTP Upgrade",
xhttp: "XHTTP",
// security
none: "NONE",
tls: "TLS",
reality: "Reality",
// fingerprint
chrome: "Chrome",
firefox: "Firefox",
safari: "Safari",
ios: "IOS",
android: "Android",
edge: "edge",
"360": "360",
qq: "QQ",
// multiplex
low: "Low",
middle: "Middle",
high: "High",
} as const;
// Flat arrays for enum-like sets
export const SS_CIPHERS = [
"aes-128-gcm",
"aes-192-gcm",
"aes-256-gcm",
"chacha20-ietf-poly1305",
"2022-blake3-aes-128-gcm",
"2022-blake3-aes-256-gcm",
"2022-blake3-chacha20-poly1305",
] as const;
export const TRANSPORTS = {
vmess: ["tcp", "websocket", "grpc"] as const,
vless: ["tcp", "websocket", "grpc", "mkcp", "httpupgrade", "xhttp"] as const,
trojan: ["tcp", "websocket", "grpc"] as const,
mieru: ["tcp", "udp"] as const,
} as const;
export const SECURITY = {
shadowsocks: ["none", "http", "tls"] as const,
vmess: ["none", "tls"] as const,
vless: ["none", "tls", "reality"] as const,
trojan: ["tls"] as const,
hysteria: ["tls"] as const,
tuic: ["tls"] as const,
anytls: ["tls"] as const,
naive: ["none", "tls"] as const,
http: ["none", "tls"] as const,
} as const;
export const FLOWS = {
vless: [
"none",
"xtls-rprx-direct",
"xtls-rprx-splice",
"xtls-rprx-vision",
] as const,
} as const;
export const TUIC_UDP_RELAY_MODES = ["native", "quic"] as const;
export const TUIC_CONGESTION = ["bbr", "cubic", "new_reno"] as const;
export const XHTTP_MODES = [
"auto",
"packet-up",
"stream-up",
"stream-one",
] as const;
export const ENCRYPTION_TYPES = ["none", "mlkem768x25519plus"] as const;
export const ENCRYPTION_MODES = ["native", "xorpub", "random"] as const;
export const ENCRYPTION_RTT = ["0rtt", "1rtt"] as const;
export const FINGERPRINTS = [
"chrome",
"firefox",
"safari",
"ios",
"android",
"edge",
"360",
"qq",
] as const;
export const CERT_MODES = ["none", "http", "dns", "self"] as const;
export const multiplexLevels = ["none", "low", "middle", "high"] as const;
export function getLabel(value: string): string {
const label = (LABELS as Record<string, string>)[value];
return label ?? value.toUpperCase();
}
@@ -0,0 +1,192 @@
import { XHTTP_MODES } from "./constants";
import type { ProtocolType } from "./types";
export function getProtocolDefaultConfig(proto: ProtocolType) {
switch (proto) {
case "shadowsocks":
return {
type: "shadowsocks",
enable: false,
port: null,
cipher: "chacha20-ietf-poly1305",
server_key: null,
obfs: "none",
obfs_host: null,
obfs_path: null,
sni: null,
allow_insecure: null,
cert_mode: "none",
cert_dns_provider: null,
cert_dns_env: null,
ratio: 1,
} as any;
case "vmess":
return {
type: "vmess",
enable: false,
host: null,
port: null,
transport: "tcp",
security: "none",
path: null,
service_name: null,
sni: null,
allow_insecure: null,
fingerprint: "chrome",
cert_mode: "none",
cert_dns_provider: null,
cert_dns_env: null,
ratio: 1,
} as any;
case "vless":
return {
type: "vless",
enable: false,
host: null,
port: null,
transport: "tcp",
security: "none",
flow: "none",
path: null,
service_name: null,
sni: null,
allow_insecure: null,
fingerprint: "chrome",
reality_server_addr: null,
reality_server_port: null,
reality_private_key: null,
reality_public_key: null,
reality_short_id: null,
xhttp_mode: XHTTP_MODES[0], // 'auto'
xhttp_extra: null,
encryption: "none",
encryption_mode: null,
encryption_rtt: null,
encryption_ticket: null,
encryption_server_padding: null,
encryption_private_key: null,
encryption_client_padding: null,
encryption_password: null,
cert_mode: "none",
cert_dns_provider: null,
cert_dns_env: null,
ratio: 1,
} as any;
case "trojan":
return {
type: "trojan",
enable: false,
host: null,
port: null,
transport: "tcp",
security: "tls",
path: null,
service_name: null,
sni: null,
allow_insecure: null,
fingerprint: "chrome",
cert_mode: "none",
cert_dns_provider: null,
cert_dns_env: null,
ratio: 1,
} as any;
case "hysteria":
return {
type: "hysteria",
enable: false,
port: null,
hop_ports: null,
hop_interval: null,
obfs: "none",
obfs_password: null,
security: "tls",
up_mbps: null,
down_mbps: null,
sni: null,
allow_insecure: null,
fingerprint: "chrome",
cert_mode: "none",
cert_dns_provider: null,
cert_dns_env: null,
ratio: 1,
} as any;
case "tuic":
return {
type: "tuic",
enable: false,
port: null,
disable_sni: false,
reduce_rtt: false,
udp_relay_mode: "native",
congestion_controller: "bbr",
security: "tls",
sni: null,
allow_insecure: false,
fingerprint: "chrome",
cert_mode: "none",
cert_dns_provider: null,
cert_dns_env: null,
ratio: 1,
} as any;
case "socks":
return {
type: "socks",
enable: false,
port: null,
ratio: 1,
} as any;
case "naive":
return {
type: "naive",
enable: false,
port: null,
security: "none",
sni: null,
allow_insecure: null,
fingerprint: "chrome",
cert_mode: "none",
cert_dns_provider: null,
cert_dns_env: null,
ratio: 1,
} as any;
case "http":
return {
type: "http",
enable: false,
port: null,
security: "none",
sni: null,
allow_insecure: null,
fingerprint: "chrome",
cert_mode: "none",
cert_dns_provider: null,
cert_dns_env: null,
ratio: 1,
} as any;
case "mieru":
return {
type: "mieru",
enable: false,
port: null,
multiplex: "none",
transport: "tcp",
} as any;
case "anytls":
return {
type: "anytls",
enable: false,
port: null,
security: "tls",
padding_scheme: null,
sni: null,
allow_insecure: false,
fingerprint: "chrome",
cert_mode: "none",
cert_dns_provider: null,
cert_dns_env: null,
ratio: 1,
} as any;
default:
return {} as any;
}
}
@@ -0,0 +1,26 @@
// Re-export all constants
export {
ENCRYPTION_MODES,
ENCRYPTION_RTT,
ENCRYPTION_TYPES,
FINGERPRINTS,
FLOWS,
getLabel,
LABELS,
multiplexLevels,
protocols,
SECURITY,
SS_CIPHERS,
TRANSPORTS,
TUIC_CONGESTION,
TUIC_UDP_RELAY_MODES,
XHTTP_MODES,
} from "./constants";
// Re-export defaults
export { getProtocolDefaultConfig } from "./defaults";
// Re-export all schemas
export { formSchema, protocolApiScheme } from "./schemas";
// Re-export all types
export type { FieldConfig, ProtocolType } from "./types";
// Re-export hooks
export { useProtocolFields } from "./useProtocolFields";
@@ -0,0 +1,225 @@
import { z } from "zod";
import {
CERT_MODES,
ENCRYPTION_MODES,
ENCRYPTION_RTT,
ENCRYPTION_TYPES,
FLOWS,
multiplexLevels,
SECURITY,
SS_CIPHERS,
TRANSPORTS,
TUIC_CONGESTION,
TUIC_UDP_RELAY_MODES,
XHTTP_MODES,
} from "./constants";
const nullableString = z.string().nullish();
const nullableBool = z.boolean().nullish();
const nullablePort = z.number().int().min(0).max(65_535).nullish();
const nullableRatio = z.number().min(0).nullish();
const ss = z.object({
ratio: nullableRatio,
type: z.literal("shadowsocks"),
enable: nullableBool,
port: nullablePort,
cipher: z.enum(SS_CIPHERS).nullish(),
server_key: nullableString,
obfs: z.enum(["none", "http", "tls"] as const).nullish(),
obfs_host: nullableString,
obfs_path: nullableString,
cert_mode: z.enum(CERT_MODES).nullish(),
cert_dns_provider: nullableString,
cert_dns_env: nullableString,
});
const vmess = z.object({
ratio: nullableRatio,
type: z.literal("vmess"),
enable: nullableBool,
host: nullableString,
port: nullablePort,
transport: z.enum(TRANSPORTS.vmess).nullish(),
security: z.enum(SECURITY.vmess).nullish(),
path: nullableString,
service_name: nullableString,
sni: nullableString,
allow_insecure: nullableBool,
fingerprint: nullableString,
cert_mode: z.enum(CERT_MODES).nullish(),
cert_dns_provider: nullableString,
cert_dns_env: nullableString,
});
const vless = z.object({
ratio: nullableRatio,
type: z.literal("vless"),
enable: nullableBool,
host: nullableString,
port: nullablePort,
transport: z.enum(TRANSPORTS.vless).nullish(),
security: z.enum(SECURITY.vless).nullish(),
path: nullableString,
service_name: nullableString,
flow: z.enum(FLOWS.vless).nullish(),
sni: nullableString,
allow_insecure: nullableBool,
fingerprint: nullableString,
reality_server_addr: nullableString,
reality_server_port: nullablePort,
reality_private_key: nullableString,
reality_public_key: nullableString,
reality_short_id: nullableString,
xhttp_mode: z.enum(XHTTP_MODES).nullish(),
xhttp_extra: nullableString,
encryption: z.enum(ENCRYPTION_TYPES).nullish(),
encryption_mode: z.enum(ENCRYPTION_MODES).nullish(),
encryption_rtt: z.enum(ENCRYPTION_RTT).nullish(),
encryption_ticket: nullableString,
encryption_server_padding: nullableString,
encryption_private_key: nullableString,
encryption_client_padding: nullableString,
encryption_password: nullableString,
cert_mode: z.enum(CERT_MODES).nullish(),
cert_dns_provider: nullableString,
cert_dns_env: nullableString,
});
const trojan = z.object({
ratio: nullableRatio,
type: z.literal("trojan"),
enable: nullableBool,
host: nullableString,
port: nullablePort,
transport: z.enum(TRANSPORTS.trojan).nullish(),
security: z.enum(SECURITY.trojan).nullish(),
path: nullableString,
service_name: nullableString,
sni: nullableString,
allow_insecure: nullableBool,
fingerprint: nullableString,
cert_mode: z.enum(CERT_MODES).nullish(),
cert_dns_provider: nullableString,
cert_dns_env: nullableString,
});
const hysteria = z.object({
ratio: nullableRatio,
type: z.literal("hysteria"),
enable: nullableBool,
hop_ports: nullableString,
hop_interval: z.number().nullish(),
obfs_password: nullableString,
obfs: z.enum(["none", "salamander"] as const).nullish(),
port: nullablePort,
security: z.enum(SECURITY.hysteria).nullish(),
sni: nullableString,
allow_insecure: nullableBool,
fingerprint: nullableString,
up_mbps: z.number().nullish(),
down_mbps: z.number().nullish(),
cert_mode: z.enum(CERT_MODES).nullish(),
cert_dns_provider: nullableString,
cert_dns_env: nullableString,
});
const tuic = z.object({
ratio: nullableRatio,
type: z.literal("tuic"),
enable: nullableBool,
host: nullableString,
port: nullablePort,
disable_sni: z.boolean().nullish(),
reduce_rtt: z.boolean().nullish(),
udp_relay_mode: z.enum(TUIC_UDP_RELAY_MODES).nullish(),
congestion_controller: z.enum(TUIC_CONGESTION).nullish(),
security: z.enum(SECURITY.tuic).nullish(),
sni: nullableString,
allow_insecure: nullableBool,
fingerprint: nullableString,
cert_mode: z.enum(CERT_MODES).nullish(),
cert_dns_provider: nullableString,
cert_dns_env: nullableString,
});
const anytls = z.object({
ratio: nullableRatio,
type: z.literal("anytls"),
enable: nullableBool,
port: nullablePort,
security: z.enum(SECURITY.anytls).nullish(),
sni: nullableString,
allow_insecure: nullableBool,
fingerprint: nullableString,
padding_scheme: nullableString,
cert_mode: z.enum(CERT_MODES).nullish(),
cert_dns_provider: nullableString,
cert_dns_env: nullableString,
});
const socks = z.object({
ratio: nullableRatio,
type: z.literal("socks"),
enable: nullableBool,
port: nullablePort,
});
const naive = z.object({
ratio: nullableRatio,
type: z.literal("naive"),
enable: nullableBool,
port: nullablePort,
security: z.enum(SECURITY.naive).nullish(),
sni: nullableString,
allow_insecure: nullableBool,
fingerprint: nullableString,
cert_mode: z.enum(CERT_MODES).nullish(),
cert_dns_provider: nullableString,
cert_dns_env: nullableString,
});
const http = z.object({
ratio: nullableRatio,
type: z.literal("http"),
enable: nullableBool,
port: nullablePort,
security: z.enum(SECURITY.http).nullish(),
sni: nullableString,
allow_insecure: nullableBool,
fingerprint: nullableString,
cert_mode: z.enum(CERT_MODES).nullish(),
cert_dns_provider: nullableString,
cert_dns_env: nullableString,
});
const mieru = z.object({
ratio: nullableRatio,
type: z.literal("mieru"),
enable: nullableBool,
port: nullablePort,
multiplex: z.enum(multiplexLevels).nullish(),
transport: z.enum(TRANSPORTS.mieru).nullish(),
});
export const protocolApiScheme = z.discriminatedUnion("type", [
ss,
vmess,
vless,
trojan,
hysteria,
tuic,
anytls,
socks,
naive,
http,
mieru,
]);
export const formSchema = z.object({
name: z.string().min(1),
address: z.string().min(1),
country: z.string().optional(),
city: z.string().optional(),
protocols: z.array(protocolApiScheme),
});
@@ -0,0 +1,39 @@
import type { protocols } from "./constants";
export type FieldConfig = {
name: string;
type: "input" | "select" | "switch" | "number" | "textarea";
label: string;
placeholder?: string;
options?: readonly string[];
defaultValue?: any;
min?: number;
max?: number;
step?: number;
suffix?: string;
generate?: {
function?: () =>
| Promise<string | Record<string, string>>
| string
| Record<string, string>;
functions?: {
label: string;
function: () =>
| Promise<string | Record<string, string>>
| string
| Record<string, string>;
}[];
updateFields?: Record<string, string>;
};
condition?: (protocol: any, values: any) => boolean;
group?:
| "basic"
| "transport"
| "security"
| "reality"
| "obfs"
| "encryption";
gridSpan?: 1 | 2;
};
export type ProtocolType = (typeof protocols)[number];
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
export { generateMLKEM768KeyPair } from "./mlkem768";
export { generateRealityShortId } from "./short-id";
export { generatePassword } from "./uid";
export { generateRealityKeyPair } from "./x25519";
@@ -0,0 +1,22 @@
import mlkem from "mlkem-wasm";
import { toB64Url } from "./util";
export async function generateMLKEM768KeyPair() {
const mlkemKeyPair = await mlkem.generateKey({ name: "ML-KEM-768" }, true, [
"encapsulateBits",
"decapsulateBits",
]);
const mlkemPublicKeyRaw = await mlkem.exportKey(
"raw-public",
mlkemKeyPair.publicKey
);
const mlkemPrivateKeyRaw = await mlkem.exportKey(
"raw-seed",
mlkemKeyPair.privateKey
);
return {
publicKey: toB64Url(new Uint8Array(mlkemPublicKeyRaw)),
privateKey: toB64Url(new Uint8Array(mlkemPrivateKeyRaw)),
};
}
@@ -0,0 +1,15 @@
/**
* Generate a short ID for Reality
* @returns A random hexadecimal string of length 2, 4, 6, 8, 10, 12, 14, or 16
*/
export function generateRealityShortId() {
const hex = "0123456789abcdef";
const lengths = [2, 4, 6, 8, 10, 12, 14, 16];
const idx = Math.floor(Math.random() * lengths.length);
const len = lengths[idx] ?? 16;
let out = "";
for (let i = 0; i < len; i++) {
out += hex.charAt(Math.floor(Math.random() * hex.length));
}
return out;
}
@@ -0,0 +1,11 @@
import { uid } from "radash";
/**
* Generate a random password
* @param length Length of the password
* @param charset Character set to use (defaults to alphanumeric)
* @returns Randomly generated password
*/
export function generatePassword(length = 16, charset?: string) {
return uid(length, charset).toLowerCase();
}
@@ -0,0 +1,6 @@
export function toB64Url(bytes: Uint8Array) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
@@ -0,0 +1,11 @@
import { x25519 } from "@noble/curves/ed25519.js";
import { toB64Url } from "./util";
/**
* Generate a Reality key pair
* @returns An object containing the private and public keys in base64url format
*/
export function generateRealityKeyPair() {
const { secretKey, publicKey } = x25519.keygen();
return { privateKey: toB64Url(secretKey), publicKey: toB64Url(publicKey) };
}
+392
View File
@@ -0,0 +1,392 @@
"use client";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import { cn } from "@workspace/ui/lib/utils";
import {
createServer,
deleteServer,
filterServerList,
resetSortWithServer,
updateServer,
} from "@workspace/ui/services/admin/server";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useNode } from "@/stores/node";
import { useServer } from "@/stores/server";
import DynamicMultiplier from "./dynamic-multiplier";
import OnlineUsersCell from "./online-users-cell";
import ServerConfig from "./server-config";
import ServerForm from "./server-form";
import ServerInstall from "./server-install";
function PctBar({ value }: { value: number }) {
const v = value.toFixed(2);
const widthClass =
value >= 90
? "w-[90%]"
: value >= 80
? "w-4/5"
: value >= 70
? "w-[70%]"
: value >= 60
? "w-3/5"
: value >= 50
? "w-1/2"
: value >= 40
? "w-2/5"
: value >= 30
? "w-[30%]"
: value >= 20
? "w-1/5"
: value >= 10
? "w-[10%]"
: "w-0";
return (
<div className="min-w-24">
<div className="text-xs leading-none">{v}%</div>
<div className="h-1.5 w-full rounded bg-muted">
<div className={cn("h-1.5 rounded bg-primary", widthClass)} />
</div>
</div>
);
}
function RegionIpCell({
country,
city,
ip,
notAvailableText,
}: {
country?: string;
city?: string;
ip?: string;
notAvailableText: string;
}) {
const region =
[country, city].filter(Boolean).join(" / ") || notAvailableText;
return (
<div className="flex items-center gap-1">
<Badge variant="outline">{region}</Badge>
<Badge variant="secondary">{ip || notAvailableText}</Badge>
</div>
);
}
export default function Servers() {
const { t } = useTranslation("servers");
const { isServerReferencedByNodes } = useNode();
const { fetchServers } = useServer();
const [loading, setLoading] = useState(false);
const ref = useRef<ProTableActions>(null);
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<DynamicMultiplier />
<ServerConfig />
</div>
<ProTable<API.Server, { search: string }>
action={ref}
actions={{
render: (row) => [
<ServerForm
initialValues={row}
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await updateServer({
id: row.id,
...(values as unknown as Omit<
API.UpdateServerRequest,
"id"
>),
});
toast.success(t("updated", "Updated"));
ref.current?.refresh();
fetchServers();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("drawerEditTitle", "Edit Server")}
trigger={t("edit", "Edit")}
/>,
<ServerInstall key="install" server={row} />,
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"confirmDeleteDesc",
"This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
await deleteServer({ id: row.id } as API.DeleteServerRequest);
toast.success(t("deleted", "Deleted"));
ref.current?.refresh();
fetchServers();
}}
title={t("confirmDeleteTitle", "Delete this server?")}
trigger={
<Button
disabled={isServerReferencedByNodes(row.id)}
variant="destructive"
>
{t("delete", "Delete")}
</Button>
}
/>,
<Button
key="copy"
onClick={async () => {
setLoading(true);
const {
id: _id,
created_at: _created_at,
updated_at: _updated_at,
last_reported_at: _last_reported_at,
status: _status,
...others
} = row as Record<string, unknown>;
const body: API.CreateServerRequest = {
name: others.name as string,
country: others.country as string,
city: others.city as string,
address: others.address as string,
protocols: (others.protocols as API.Protocol[]) || [],
};
await createServer(body);
toast.success(t("copied", "Copied"));
ref.current?.refresh();
fetchServers();
setLoading(false);
}}
variant="outline"
>
{t("copy", "Copy")}
</Button>,
],
batchRender(rows) {
const hasReferencedServers = rows.some((row) =>
isServerReferencedByNodes(row.id)
);
return [
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"confirmDeleteDesc",
"This action cannot be undone."
)}
key="delete"
onConfirm={async () => {
await Promise.all(
rows.map((r) => deleteServer({ id: r.id }))
);
toast.success(t("deleted", "Deleted"));
ref.current?.refresh();
fetchServers();
}}
title={t("confirmDeleteTitle", "Delete this server?")}
trigger={
<Button disabled={hasReferencedServers} variant="destructive">
{t("delete", "Delete")}
</Button>
}
/>,
];
},
}}
columns={[
{
accessorKey: "id",
header: t("id", "ID"),
cell: ({ row }) => <Badge>{row.getValue("id")}</Badge>,
},
{ accessorKey: "name", header: t("name", "Name") },
{
id: "region_ip",
header: t("address", "Address"),
cell: ({ row }) => (
<RegionIpCell
city={row.original.city as unknown as string}
country={row.original.country as unknown as string}
ip={row.original.address as unknown as string}
notAvailableText={t("notAvailable", "Not Available")}
/>
),
},
{
accessorKey: "protocols",
header: t("protocols", "Protocols"),
cell: ({ row }) => {
const list = row.original.protocols.filter(
(p) => p.enable
) as API.Protocol[];
if (!list.length) return "—";
return (
<div className="flex flex-col gap-1">
{list.map((p, idx) => {
const ratio = Number(p.ratio ?? 1) || 1;
return (
<div className="flex items-center gap-2" key={idx}>
<Badge variant="outline">{ratio.toFixed(2)}x</Badge>
<Badge variant="secondary">{p.type}</Badge>
<Badge variant="secondary">{p.port}</Badge>
</div>
);
})}
</div>
);
},
},
{
id: "status",
header: t("status", "Status"),
cell: ({ row }) => {
const offline = row.original.status.status === "offline";
return (
<div className="flex items-center gap-2">
<span
className={cn(
"inline-block h-2.5 w-2.5 rounded-full",
offline ? "bg-zinc-400" : "bg-emerald-500"
)}
/>
<span className="text-sm">
{offline ? t("offline", "Offline") : t("online", "Online")}
</span>
</div>
);
},
},
{
id: "cpu",
header: t("cpu", "CPU"),
cell: ({ row }) => (
<PctBar
value={(row.original.status?.cpu as unknown as number) ?? 0}
/>
),
},
{
id: "mem",
header: t("memory", "Memory"),
cell: ({ row }) => (
<PctBar
value={(row.original.status?.mem as unknown as number) ?? 0}
/>
),
},
{
id: "disk",
header: t("disk", "Disk"),
cell: ({ row }) => (
<PctBar
value={(row.original.status?.disk as unknown as number) ?? 0}
/>
),
},
{
id: "online_users",
header: t("onlineUsers", "Online Users"),
cell: ({ row }) => (
<OnlineUsersCell
status={row.original.status as API.ServerStatus}
/>
),
},
]}
header={{
title: t("pageTitle", "Servers"),
toolbar: (
<div className="flex gap-2">
<ServerForm
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createServer(
values as unknown as API.CreateServerRequest
);
toast.success(t("created", "Created"));
ref.current?.refresh();
fetchServers();
setLoading(false);
return true;
} catch {
setLoading(false);
return false;
}
}}
title={t("drawerCreateTitle", "Create Server")}
trigger={t("create", "Create")}
/>
</div>
),
}}
onSort={async (source, target, items) => {
const sourceIndex = items.findIndex(
(item) => String(item.id) === source
);
const targetIndex = items.findIndex(
(item) => String(item.id) === target
);
const originalSorts = items.map((item) => item.sort);
const [movedItem] = items.splice(sourceIndex, 1);
items.splice(targetIndex, 0, movedItem!);
const updatedItems = items.map((item, index) => {
const originalSort = originalSorts[index];
const newSort =
originalSort !== undefined ? originalSort : item.sort;
return { ...item, sort: newSort };
});
const changedItems = updatedItems.filter(
(item, index) => item.sort !== items[index]?.sort
);
if (changedItems.length > 0) {
resetSortWithServer({
sort: changedItems.map((item) => ({
id: item.id,
sort: item.sort,
})) as API.SortItem[],
});
toast.success(t("sorted_success", "Sorted successfully"));
}
return updatedItems;
}}
params={[{ key: "search" }]}
request={async (pagination, filter) => {
const { data } = await filterServerList({
page: pagination.page,
size: pagination.size,
search: filter?.search || undefined,
});
const list = (data?.data?.list || []) as API.Server[];
const total = (data?.data?.total ?? list.length) as number;
return { list, total };
}}
/>
</div>
);
}
@@ -0,0 +1,221 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Badge } from "@workspace/ui/components/badge";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { ProTable } from "@workspace/ui/composed/pro-table/pro-table";
import { getUserSubscribeById } from "@workspace/ui/services/admin/user";
import { formatBytes } from "@workspace/ui/utils/formatting";
import { Users } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { IpLink } from "@/components/ip-link";
import { UserDetail } from "@/sections/user/user-detail";
import { formatDate } from "@/utils/common";
function UserSubscribeInfo({
subscribeId,
open,
type,
expiredText,
unlimitedText,
}: {
subscribeId: number;
open: boolean;
type:
| "account"
| "subscribeName"
| "subscribeId"
| "trafficUsage"
| "expireTime";
expiredText: string;
unlimitedText: string;
}) {
const { data } = useQuery({
enabled: subscribeId !== 0 && open,
queryKey: ["getUserSubscribeById", subscribeId],
queryFn: async () => {
const { data } = await getUserSubscribeById({ id: subscribeId });
return data.data;
},
});
if (!data) return <span className="text-muted-foreground">--</span>;
switch (type) {
case "account":
if (!data.user_id)
return <span className="text-muted-foreground">--</span>;
return <UserDetail id={data.user_id} />;
case "subscribeName":
if (!data.subscribe?.name)
return <span className="text-muted-foreground">--</span>;
return <span className="text-sm">{data.subscribe.name}</span>;
case "subscribeId":
if (!data.id) return <span className="text-muted-foreground">--</span>;
return <span className="font-mono text-sm">{data.id}</span>;
case "trafficUsage": {
const usedTraffic = data.upload + data.download;
const totalTraffic = data.traffic || 0;
return (
<div className="min-w-0 text-sm">
<div className="wrap-break-word">
{formatBytes(usedTraffic)} /{" "}
{totalTraffic > 0 ? formatBytes(totalTraffic) : unlimitedText}
</div>
</div>
);
}
case "expireTime": {
if (!data.expire_time)
return <span className="text-muted-foreground">--</span>;
const isExpired = data.expire_time < Date.now() / 1000;
return (
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
<span className="text-sm">{formatDate(data.expire_time)}</span>
{isExpired && (
<Badge className="w-fit px-1 py-0 text-xs" variant="destructive">
{expiredText}
</Badge>
)}
</div>
);
}
default:
return <span className="text-muted-foreground">--</span>;
}
}
export default function OnlineUsersCell({
status,
}: {
status?: API.ServerStatus;
}) {
const { t } = useTranslation("servers");
const [open, setOpen] = useState(false);
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<button
className="flex items-center gap-2 bg-transparent p-0 text-muted-foreground text-sm hover:text-foreground"
type="button"
>
<Users className="h-4 w-4" /> {status?.online.length}
</button>
</SheetTrigger>
<SheetContent className="h-screen w-screen max-w-none sm:h-auto sm:w-[900px] sm:max-w-[90vw]">
<SheetHeader>
<SheetTitle>{t("onlineUsers", "Online Users")}</SheetTitle>
</SheetHeader>
<div className="h-[calc(100vh-48px-16px)] overflow-y-auto px-6 py-4 sm:h-[calc(100dvh-48px-16px-env(safe-area-inset-top))]">
<ProTable<API.ServerOnlineUser, Record<string, unknown>>
columns={[
{
accessorKey: "ip",
header: t("ipAddresses", "IP Addresses"),
cell: ({ row }) => {
const ips = row.original.ip;
return (
<div className="flex min-w-0 flex-col gap-1">
{ips.map((item) => (
<div
className="whitespace-nowrap text-sm"
key={`${item.protocol}-${item.ip}`}
>
<Badge>{item.protocol}</Badge>
<IpLink className="ml-1 font-medium" ip={item.ip} />
</div>
))}
</div>
);
},
},
{
accessorKey: "user",
header: t("user", "User"),
cell: ({ row }) => (
<UserSubscribeInfo
expiredText={t("expired", "Expired")}
open={open}
subscribeId={Number(row.original.subscribe_id)}
type="account"
unlimitedText={t("unlimited", "Unlimited")}
/>
),
},
{
accessorKey: "subscription",
header: t("subscription", "Subscription"),
cell: ({ row }) => (
<UserSubscribeInfo
expiredText={t("expired", "Expired")}
open={open}
subscribeId={Number(row.original.subscribe_id)}
type="subscribeName"
unlimitedText={t("unlimited", "Unlimited")}
/>
),
},
{
accessorKey: "subscribeId",
header: t("subscribeId", "Subscribe ID"),
cell: ({ row }) => (
<UserSubscribeInfo
expiredText={t("expired", "Expired")}
open={open}
subscribeId={Number(row.original.subscribe_id)}
type="subscribeId"
unlimitedText={t("unlimited", "Unlimited")}
/>
),
},
{
accessorKey: "traffic",
header: t("traffic", "Traffic"),
cell: ({ row }) => (
<UserSubscribeInfo
expiredText={t("expired", "Expired")}
open={open}
subscribeId={Number(row.original.subscribe_id)}
type="trafficUsage"
unlimitedText={t("unlimited", "Unlimited")}
/>
),
},
{
accessorKey: "expireTime",
header: t("expireTime", "Expire Time"),
cell: ({ row }) => (
<UserSubscribeInfo
expiredText={t("expired", "Expired")}
open={open}
subscribeId={Number(row.original.subscribe_id)}
type="expireTime"
unlimitedText={t("unlimited", "Unlimited")}
/>
),
},
]}
header={{ hidden: true }}
request={async () => ({
list: status?.online || [],
total: status?.online?.length || 0,
})}
/>
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,652 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import { Card, CardContent } from "@workspace/ui/components/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@workspace/ui/components/tabs";
import { Textarea } from "@workspace/ui/components/textarea";
import { ArrayInput } from "@workspace/ui/composed/dynamic-Inputs";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
getNodeConfig,
updateNodeConfig,
} from "@workspace/ui/services/admin/system";
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
import { DicesIcon } from "lucide-react";
import { uid } from "radash";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
import { SS_CIPHERS } from "./form-schema";
const dnsConfigSchema = z.object({
proto: z.string(), // z.enum(['tcp', 'udp', 'tls', 'https', 'quic']),
address: z.string(),
domains: z.array(z.string()),
});
const outboundConfigSchema = z.object({
name: z.string(),
protocol: z.string(),
address: z.string(),
port: z.number(),
cipher: z.string().optional(),
password: z.string().optional(),
rules: z.array(z.string()).optional(),
});
const nodeConfigSchema = z.object({
node_secret: z.string().optional(),
node_pull_interval: z.number().optional(),
node_push_interval: z.number().optional(),
traffic_report_threshold: z.number().optional(),
ip_strategy: z.enum(["prefer_ipv4", "prefer_ipv6"]).optional(),
dns: z.array(dnsConfigSchema).optional(),
block: z.array(z.string()).optional(),
outbound: z.array(outboundConfigSchema).optional(),
});
type NodeConfigFormData = z.infer<typeof nodeConfigSchema>;
export default function ServerConfig() {
const { t } = useTranslation("servers");
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const { data: cfgResp, refetch: refetchCfg } = useQuery({
queryKey: ["getNodeConfig"],
queryFn: async () => {
const { data } = await getNodeConfig();
return data.data as API.NodeConfig | undefined;
},
enabled: open,
});
const form = useForm<NodeConfigFormData>({
resolver: zodResolver(nodeConfigSchema),
defaultValues: {
node_secret: "",
node_pull_interval: undefined,
node_push_interval: undefined,
traffic_report_threshold: undefined,
ip_strategy: "prefer_ipv4",
dns: [],
block: [],
outbound: [],
},
});
useEffect(() => {
if (cfgResp) {
form.reset({
node_secret: cfgResp.node_secret ?? "",
node_pull_interval: cfgResp.node_pull_interval as number | undefined,
node_push_interval: cfgResp.node_push_interval as number | undefined,
traffic_report_threshold: cfgResp.traffic_report_threshold as
| number
| undefined,
ip_strategy:
(cfgResp.ip_strategy as "prefer_ipv4" | "prefer_ipv6" | undefined) ||
"prefer_ipv4",
dns: cfgResp.dns || [],
block: cfgResp.block || [],
outbound: cfgResp.outbound || [],
});
}
}, [cfgResp, form]);
async function onSubmit(values: NodeConfigFormData) {
setSaving(true);
try {
await updateNodeConfig(values as API.NodeConfig);
toast.success(t("server_config.saveSuccess", "Saved successfully"));
await refetchCfg();
setOpen(false);
} finally {
setSaving(false);
}
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Card>
<CardContent>
<div className="flex cursor-pointer items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icon
className="h-5 w-5 text-primary"
icon="mdi:resistor-nodes"
/>
</div>
<div className="flex-1">
<p className="font-medium">
{t("server_config.title", "Node configuration")}
</p>
<p className="truncate text-muted-foreground text-sm">
{t(
"server_config.description",
"Manage node communication keys, pull/push intervals."
)}
</p>
</div>
</div>
<Icon className="size-6" icon="mdi:chevron-right" />
</div>
</CardContent>
</Card>
</SheetTrigger>
<SheetContent className="w-[720px] max-w-full md:max-w-3xl">
<SheetHeader>
<SheetTitle>
{t("server_config.title", "Node configuration")}
</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))] px-6">
<Tabs className="pt-4" defaultValue="basic">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="basic">
{t("server_config.tabs.basic", "Basic Configuration")}
</TabsTrigger>
<TabsTrigger value="dns">
{t("server_config.tabs.dns", "DNS Configuration")}
</TabsTrigger>
<TabsTrigger value="outbound">
{t("server_config.tabs.outbound", "Outbound Rules")}
</TabsTrigger>
<TabsTrigger value="block">
{t("server_config.tabs.block", "Block Rules")}
</TabsTrigger>
</TabsList>
<Form {...form}>
<form
className="mt-4"
id="server-config-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<TabsContent className="space-y-4" value="basic">
<FormField
control={form.control}
name="node_secret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"server_config.fields.communication_key",
"Communication key"
)}
</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={field.onChange}
placeholder={t(
"server_config.fields.communication_key_placeholder",
"Please enter"
)}
suffix={
<div className="flex h-9 items-center bg-muted px-3">
<DicesIcon
className="cursor-pointer"
onClick={() => {
const id = uid(32).toLowerCase();
const formatted = `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
form.setValue("node_secret", formatted);
}}
/>
</div>
}
value={field.value || ""}
/>
</FormControl>
<FormDescription>
{t(
"server_config.fields.communication_key_desc",
"Used for node authentication."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="node_pull_interval"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"server_config.fields.node_pull_interval",
"Node pull interval"
)}
</FormLabel>
<FormControl>
<EnhancedInput
min={0}
onValueChange={field.onChange}
placeholder={t(
"server_config.fields.communication_key_placeholder",
"Please enter"
)}
suffix="S"
type="number"
value={field.value as number | undefined}
/>
</FormControl>
<FormDescription>
{t(
"server_config.fields.node_pull_interval_desc",
"How often the node pulls configuration (seconds)."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="node_push_interval"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"server_config.fields.node_push_interval",
"Node push interval"
)}
</FormLabel>
<FormControl>
<EnhancedInput
min={0}
onValueChange={field.onChange}
placeholder={t(
"server_config.fields.communication_key_placeholder",
"Please enter"
)}
step={0.1}
suffix="S"
type="number"
value={field.value as number | undefined}
/>
</FormControl>
<FormDescription>
{t(
"server_config.fields.node_push_interval_desc",
"How often the node pushes stats (seconds)."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="traffic_report_threshold"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"server_config.fields.traffic_report_threshold",
"Traffic Report Threshold"
)}
</FormLabel>
<FormControl>
<EnhancedInput
min={0}
onValueChange={(value) => {
field.onChange(unitConversion("mbToBits", value));
}}
placeholder="1"
suffix="MB"
type="number"
value={unitConversion(
"bitsToMb",
field.value as number | undefined
)}
/>
</FormControl>
<FormDescription>
{t(
"server_config.fields.traffic_report_threshold_desc",
"Set the minimum threshold for traffic reporting."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent className="space-y-4" value="dns">
<FormField
control={form.control}
name="ip_strategy"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("server_config.fields.ip_strategy", "IP Strategy")}
</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"server_config.fields.ip_strategy_placeholder",
"Select IP strategy"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="prefer_ipv4">
{t(
"server_config.fields.ip_strategy_ipv4",
"Prefer IPv4"
)}
</SelectItem>
<SelectItem value="prefer_ipv6">
{t(
"server_config.fields.ip_strategy_ipv6",
"Prefer IPv6"
)}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t(
"server_config.fields.ip_strategy_desc",
"Choose IP version preference for network connections"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="dns"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"server_config.fields.dns_config",
"DNS Configuration"
)}
</FormLabel>
<FormControl>
<ArrayInput
className="grid grid-cols-2 gap-2"
fields={[
{
name: "proto",
type: "select",
placeholder: t(
"server_config.fields.dns_proto_placeholder",
"Select type"
),
options: [
{ label: "TCP", value: "tcp" },
{ label: "UDP", value: "udp" },
{ label: "TLS", value: "tls" },
{ label: "HTTPS", value: "https" },
{ label: "QUIC", value: "quic" },
],
},
{
name: "address",
type: "text",
placeholder: "8.8.8.8:53",
},
{
name: "domains",
type: "textarea",
className: "col-span-2",
placeholder: t(
"server_config.fields.dns_domains_placeholder",
"One domain rule per line"
),
},
]}
onChange={(values) => {
const converted = values.map((item: any) => ({
proto: item.proto,
address: item.address,
domains:
typeof item.domains === "string"
? item.domains
.split("\n")
.map((d: string) => d.trim())
: item.domains || [],
}));
field.onChange(converted);
}}
value={(field.value || []).map((item) => ({
...item,
domains: Array.isArray(item.domains)
? item.domains.join("\n")
: "",
}))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent className="space-y-4" value="outbound">
<FormField
control={form.control}
name="outbound"
render={({ field }) => (
<FormItem>
<FormControl>
<ArrayInput
className="grid grid-cols-2 gap-2"
fields={[
{
name: "name",
type: "text",
className: "col-span-2",
placeholder: t(
"server_config.fields.outbound_name_placeholder",
"Configuration name"
),
},
{
name: "protocol",
type: "select",
placeholder: t(
"server_config.fields.outbound_protocol_placeholder",
"Select protocol"
),
options: [
{ label: "HTTP", value: "http" },
{ label: "SOCKS", value: "socks" },
{
label: "Shadowsocks",
value: "shadowsocks",
},
{ label: "Brook", value: "brook" },
{ label: "Snell", value: "snell" },
{ label: "VMess", value: "vmess" },
{ label: "VLESS", value: "vless" },
{ label: "Trojan", value: "trojan" },
{ label: "WireGuard", value: "wireguard" },
{ label: "Hysteria", value: "hysteria" },
{ label: "TUIC", value: "tuic" },
{ label: "AnyTLS", value: "anytls" },
{ label: "Naive", value: "naive" },
{ label: "Direct", value: "direct" },
{ label: "Reject", value: "reject" },
],
},
{
name: "cipher",
type: "select",
options: SS_CIPHERS.map((cipher) => ({
label: cipher,
value: cipher,
})),
visible: (item: Record<string, unknown>) =>
item.protocol === "shadowsocks",
},
{
name: "address",
type: "text",
placeholder: t(
"server_config.fields.outbound_address_placeholder",
"Server address"
),
},
{
name: "port",
type: "number",
placeholder: t(
"server_config.fields.outbound_port_placeholder",
"Port number"
),
},
{
name: "password",
type: "text",
placeholder: t(
"server_config.fields.outbound_password_placeholder",
"Password (optional)"
),
},
{
name: "rules",
type: "textarea",
className: "col-span-2",
placeholder: t(
"server_config.fields.outbound_rules_placeholder",
"One rule per line"
),
},
]}
onChange={(values) => {
const converted = values.map((item: any) => ({
name: item.name,
protocol: item.protocol,
address: item.address,
port: item.port,
cipher: item.cipher,
password: item.password,
rules:
typeof item.rules === "string"
? item.rules
.split("\n")
.map((r: string) => r.trim())
: item.rules || [],
}));
field.onChange(converted);
}}
value={(field.value || []).map((item) => ({
...item,
rules: Array.isArray(item.rules)
? item.rules.join("\n")
: "",
}))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
<TabsContent className="space-y-4" value="block">
<FormField
control={form.control}
name="block"
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
onChange={(e) => {
const lines = e.target.value
.split("\n")
.map((line) => line.trim());
field.onChange(lines);
}}
placeholder={t(
"server_config.fields.block_rules_placeholder",
"One domain rule per line"
)}
rows={10}
value={(field.value || []).join("\n")}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
</form>
</Form>
</Tabs>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={saving}
onClick={() => setOpen(false)}
variant="outline"
>
{t("actions.cancel", "Cancel")}
</Button>
<Button disabled={saving} form="server-config-form" type="submit">
<Icon
className={saving ? "mr-2 animate-spin" : "hidden"}
icon="mdi:loading"
/>
{t("actions.save", "Save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,703 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@workspace/ui/components/accordion";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@workspace/ui/components/sheet";
import { Switch } from "@workspace/ui/components/switch";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import { cn } from "@workspace/ui/lib/utils";
import { useEffect, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useNode } from "@/stores/node";
import {
type FieldConfig,
formSchema,
getLabel,
getProtocolDefaultConfig,
protocols as PROTOCOLS,
useProtocolFields,
} from "./form-schema";
function DynamicField({
field,
control,
form,
protocolIndex,
protocolData,
}: {
field: FieldConfig;
control: any;
form: any;
protocolIndex: number;
protocolData: any;
}) {
const fieldName = `protocols.${protocolIndex}.${field.name}` as const;
if (field.condition && !field.condition(protocolData, {})) {
return null;
}
const commonProps = {
control,
name: fieldName,
};
switch (field.type) {
case "input":
return (
<FormField
{...commonProps}
render={({ field: fieldProps }) => (
<FormItem>
<FormLabel>{field.label}</FormLabel>
<FormControl>
<EnhancedInput
{...fieldProps}
onValueChange={(v) => fieldProps.onChange(v)}
placeholder={field.placeholder}
suffix={
field.generate ? (
field.generate.functions &&
field.generate.functions.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" type="button" variant="ghost">
<Icon className="h-4 w-4" icon="mdi:key" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{field.generate.functions.map((genFunc, idx) => (
<DropdownMenuItem
key={idx}
onClick={async () => {
const result = await genFunc.function();
if (typeof result === "string") {
fieldProps.onChange(result);
} else if (field.generate!.updateFields) {
Object.entries(
field.generate!.updateFields
).forEach(([fieldName, resultKey]) => {
const fullFieldName = `protocols.${protocolIndex}.${fieldName}`;
form.setValue(
fullFieldName,
(result as any)[resultKey]
);
});
} else if (result.privateKey) {
fieldProps.onChange(result.privateKey);
}
}}
>
{genFunc.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : field.generate.function ? (
<Button
onClick={async () => {
const result = await field.generate!.function!();
if (typeof result === "string") {
fieldProps.onChange(result);
} else if (field.generate!.updateFields) {
Object.entries(
field.generate!.updateFields
).forEach(([fieldName, resultKey]) => {
const fullFieldName = `protocols.${protocolIndex}.${fieldName}`;
form.setValue(
fullFieldName,
(result as any)[resultKey]
);
});
} else if (result.privateKey) {
fieldProps.onChange(result.privateKey);
}
}}
size="sm"
type="button"
variant="ghost"
>
<Icon className="h-4 w-4" icon="mdi:key" />
</Button>
) : null
) : (
field.suffix
)
}
type="text"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
case "number":
return (
<FormField
{...commonProps}
render={({ field: fieldProps }) => (
<FormItem>
<FormLabel>{field.label}</FormLabel>
<FormControl>
<EnhancedInput
{...fieldProps}
max={field.max}
min={field.min}
onValueChange={(v) => fieldProps.onChange(v)}
placeholder={field.placeholder}
step={field.step || 1}
suffix={field.suffix}
type="number"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
case "select":
if (!field.options || field.options.length <= 1) {
return null;
}
return (
<FormField
{...commonProps}
render={({ field: fieldProps }) => (
<FormItem>
<FormLabel>{field.label}</FormLabel>
<FormControl>
<Select
onValueChange={(v) => fieldProps.onChange(v)}
value={fieldProps.value ?? field.defaultValue}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{field.options?.map((option) => (
<SelectItem key={option} value={option}>
{getLabel(option)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
case "switch":
return (
<FormField
{...commonProps}
render={({ field: fieldProps }) => (
<FormItem>
<FormLabel>{field.label}</FormLabel>
<FormControl>
<div className="pt-2">
<Switch
checked={!!fieldProps.value}
onCheckedChange={(checked) => fieldProps.onChange(checked)}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
case "textarea":
return (
<FormField
{...commonProps}
render={({ field: fieldProps }) => (
<FormItem className="col-span-2">
<FormLabel>{field.label}</FormLabel>
<FormControl>
<textarea
{...fieldProps}
className="flex min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
onChange={(e) => fieldProps.onChange(e.target.value)}
placeholder={field.placeholder}
value={fieldProps.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
default:
return null;
}
}
function renderFieldsByGroup(
fields: FieldConfig[],
group: string,
control: any,
form: any,
protocolIndex: number,
protocolData: any
) {
const groupFields = fields.filter((field) => field.group === group);
if (groupFields.length === 0) return null;
return (
<div className="grid grid-cols-2 gap-4">
{groupFields.map((field) => (
<DynamicField
control={control}
field={field}
form={form}
key={field.name}
protocolData={protocolData}
protocolIndex={protocolIndex}
/>
))}
</div>
);
}
function renderGroupCard(
title: string,
fields: FieldConfig[],
group: string,
control: any,
form: any,
protocolIndex: number,
protocolData: any
) {
const groupFields = fields.filter((field) => field.group === group);
if (groupFields.length === 0) return null;
const visibleFields = groupFields.filter(
(field) => !field.condition || field.condition(protocolData, {})
);
if (visibleFields.length === 0) return null;
return (
<div className="relative">
<fieldset className="rounded-lg border border-border">
<legend className="ml-3 bg-background px-1 py-1 font-medium text-foreground text-sm">
{title}
</legend>
<div className="p-4 pt-2">
{renderFieldsByGroup(
fields,
group,
control,
form,
protocolIndex,
protocolData
)}
</div>
</fieldset>
</div>
);
}
export default function ServerForm(props: {
trigger: string;
title: string;
loading?: boolean;
initialValues?: Partial<API.Server>;
onSubmit: (values: Partial<API.Server>) => Promise<boolean> | boolean;
}) {
const { trigger, title, loading, initialValues, onSubmit } = props;
const { t } = useTranslation("servers");
const [open, setOpen] = useState(false);
const [accordionValue, setAccordionValue] = useState<string>();
const { isProtocolUsedInNodes } = useNode();
const PROTOCOL_FIELDS = useProtocolFields();
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
address: "",
country: "",
city: "",
protocols: [] as any[],
...initialValues,
},
});
const { control } = form;
const protocolsValues = useWatch({ control, name: "protocols" });
useEffect(() => {
if (initialValues) {
form.reset({
name: "",
address: "",
country: "",
city: "",
...initialValues,
protocols: PROTOCOLS.map((type) => {
const existingProtocol = initialValues.protocols?.find(
(p) => p.type === type
);
const defaultConfig = getProtocolDefaultConfig(type);
return existingProtocol
? { ...defaultConfig, ...existingProtocol }
: defaultConfig;
}),
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValues]);
async function handleSubmit(values: Record<string, any>) {
const filteredProtocols = (values?.protocols || []).filter(
(protocol: any) => {
const port = Number(protocol?.port);
return protocol && Number.isFinite(port) && port > 0 && port <= 65_535;
}
);
const result = {
name: values.name,
country: values.country,
city: values.city,
address: values.address,
protocols: filteredProtocols,
};
const ok = await onSubmit(result);
if (ok) {
form.reset();
setOpen(false);
}
}
return (
<Sheet onOpenChange={setOpen} open={open}>
<SheetTrigger asChild>
<Button
onClick={() => {
if (!initialValues) {
const full = PROTOCOLS.map((t) => getProtocolDefaultConfig(t));
form.reset({
name: "",
address: "",
country: "",
city: "",
protocols: full,
});
}
setOpen(true);
}}
>
{trigger}
</Button>
</SheetTrigger>
<SheetContent className="w-[700px] max-w-full gap-0 md:max-w-3xl">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100dvh-48px-36px-36px-env(safe-area-inset-top))]">
<Form {...form}>
<form className="grid grid-cols-1 gap-2 px-6 pt-4">
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("name", "Name")}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
onValueChange={(v) => field.onChange(v)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="address"
render={({ field }) => (
<FormItem>
<FormLabel>{t("address", "Address")}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
onValueChange={(v) => field.onChange(v)}
placeholder={t(
"address_placeholder",
"Server address"
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="country"
render={({ field }) => (
<FormItem>
<FormLabel>{t("country", "Country")}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
onValueChange={(v) => field.onChange(v)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="city"
render={({ field }) => (
<FormItem>
<FormLabel>{t("city", "City")}</FormLabel>
<FormControl>
<EnhancedInput
{...field}
onValueChange={(v) => field.onChange(v)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="my-3">
<h3 className="font-semibold text-foreground text-sm">
{t("protocol_configurations", "Protocol Configurations")}
</h3>
<p className="mt-1 text-muted-foreground text-xs">
{t(
"protocol_configurations_desc",
"Enable and configure the required protocol types"
)}
</p>
</div>
<Accordion
className="w-full space-y-3"
collapsible
onValueChange={setAccordionValue}
type="single"
value={accordionValue}
>
{PROTOCOLS.map((type) => {
const i = Math.max(0, PROTOCOLS.indexOf(type));
const current = (protocolsValues[i] || {}) as Record<
string,
any
>;
const isEnabled = current?.enable;
const fields = PROTOCOL_FIELDS[type] || [];
return (
<AccordionItem
className="mb-2 rounded-lg border"
key={type}
value={type}
>
<AccordionTrigger className="px-4 py-3 hover:no-underline">
<div className="flex w-full items-center justify-between">
<div className="flex flex-col items-start gap-1">
<div className="flex items-center gap-1">
<span className="font-medium capitalize">
{type}
</span>
{current.transport && (
<Badge className="text-xs" variant="secondary">
{current.transport.toUpperCase()}
</Badge>
)}
{current.security &&
current.security !== "none" && (
<Badge className="text-xs" variant="outline">
{current.security.toUpperCase()}
</Badge>
)}
{current.port && (
<Badge className="text-xs">
{current.port}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
<span
className={cn(
"text-xs",
isEnabled
? "text-green-500"
: "text-muted-foreground"
)}
>
{isEnabled
? t("enabled", "Enabled")
: t("disabled", "Disabled")}
</span>
</div>
</div>
<Switch
checked={!!isEnabled}
className="mr-2"
disabled={Boolean(
initialValues?.id &&
isProtocolUsedInNodes(
initialValues?.id || 0,
type
) &&
isEnabled
)}
onCheckedChange={(checked) => {
form.setValue(`protocols.${i}.enable`, checked);
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pt-0 pb-4">
<div className="-mx-4 space-y-4 rounded-b-lg border-t px-4 pt-4">
{renderGroupCard(
t("basic", "Basic Configuration"),
fields,
"basic",
control,
form,
i,
current
)}
{renderGroupCard(
t("obfs", "Obfuscation"),
fields,
"obfs",
control,
form,
i,
current
)}
{renderGroupCard(
t("transport", "Transport"),
fields,
"transport",
control,
form,
i,
current
)}
{renderGroupCard(
t("security", "Security"),
fields,
"security",
control,
form,
i,
current
)}
{renderGroupCard(
t("reality", "Reality"),
fields,
"reality",
control,
form,
i,
current
)}
{renderGroupCard(
t("encryption", "Encryption"),
fields,
"encryption",
control,
form,
i,
current
)}
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
<Button
disabled={loading}
onClick={() => setOpen(false)}
variant="outline"
>
{t("cancel", "Cancel")}
</Button>
<Button
disabled={loading}
onClick={form.handleSubmit(handleSubmit, (errors) => {
const key = Object.keys(errors)[0] as keyof typeof errors;
if (key) toast.error(String(errors[key]?.message));
return false;
})}
>
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("confirm", "Confirm")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,127 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import { Input } from "@workspace/ui/components/input";
import { Label } from "@workspace/ui/components/label";
import { getNodeConfig } from "@workspace/ui/services/admin/system";
import {
type ChangeEvent,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
type Props = {
server: API.Server;
};
export default function ServerInstall({ server }: Props) {
const { t } = useTranslation("servers");
const [open, setOpen] = useState(false);
const [domain, setDomain] = useState("");
const { data: cfgResp } = useQuery({
queryKey: ["getNodeConfig"],
queryFn: async () => {
const { data } = await getNodeConfig();
return data.data as API.NodeConfig | undefined;
},
enabled: open,
});
useEffect(() => {
if (open) {
const host = localStorage.getItem("API_HOST") ?? window.location.origin;
setDomain(host);
}
}, [open]);
const installCommand = useMemo(() => {
const secret = cfgResp?.node_secret ?? "";
return `wget -N https://raw.githubusercontent.com/perfect-panel/ppanel-node/master/scripts/install.sh && bash install.sh --api-host ${domain} --server-id ${server.id} --secret-key ${secret}`;
}, [domain, server.id, cfgResp?.node_secret]);
async function handleCopy() {
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(installCommand);
} else {
// fallback for environments without clipboard API
const el = document.createElement("textarea");
el.value = installCommand;
document.body.appendChild(el);
el.select();
document.execCommand("copy");
document.body.removeChild(el);
}
toast.success(t("copied", "Copied"));
setOpen(false);
} catch {
toast.error(t("copyFailed", "Copy failed"));
}
}
const onDomainChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setDomain(e.target.value);
localStorage.setItem("API_HOST", e.target.value);
}, []);
return (
<Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger asChild>
<Button variant="secondary">{t("connect", "Connect")}</Button>
</DialogTrigger>
<DialogContent className="w-[720px] max-w-full md:max-w-3xl">
<DialogHeader>
<DialogTitle>{t("oneClickInstall", "One-click Install")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>{t("apiHost", "API Host")}</Label>
<div className="flex items-center gap-2">
<Input
onChange={onDomainChange}
placeholder={t("apiHostPlaceholder", "http(s)://example.com")}
value={domain}
/>
</div>
</div>
<div>
<Label>{t("installCommand", "Install command")}</Label>
<div className="flex flex-col gap-2">
<textarea
aria-label={t("installCommand", "Install command")}
className="min-h-[88px] w-full rounded border p-2 font-mono text-sm"
readOnly
value={installCommand}
/>
</div>
</div>
</div>
<DialogFooter className="flex-row justify-end gap-2 pt-3">
<Button onClick={() => setOpen(false)} variant="outline">
{t("close", "Close")}
</Button>
<Button onClick={handleCopy}>
{t("copyAndClose", "Copy and Close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}