Files
hi-frontend/apps/admin/src/sections/servers/server-config.tsx
T
2025-11-26 19:56:16 -08:00

653 lines
26 KiB
TypeScript

"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>
);
}