🎉 feat: initialization
This commit is contained in:
@@ -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) };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user