From f4fbc9f488606506aa5a238d019900e79a6a66f1 Mon Sep 17 00:00:00 2001 From: UUBulb <35923940+uubulb@users.noreply.github.com> Date: Sun, 17 Nov 2024 10:05:20 +0800 Subject: [PATCH 001/220] further implementing service page (#3) --- package-lock.json | 32 ++ package.json | 1 + src/api/service.ts | 4 +- src/components/action-button-group.tsx | 50 +++ src/components/header-button-group.tsx | 64 ++++ src/components/header.tsx | 14 +- src/components/service.tsx | 512 +++++++++++++++++-------- src/components/ui/checkbox.tsx | 2 +- src/components/ui/scroll-area.tsx | 46 +++ src/components/ui/skeleton.tsx | 15 + src/components/xui/icon-buttons.tsx | 27 ++ src/hooks/useAuth.tsx | 5 +- src/lib/utils.ts | 38 ++ src/routes/root.tsx | 2 +- src/routes/server.tsx | 2 +- src/routes/service.tsx | 206 +++++++--- src/types/api.ts | 125 +++--- src/types/index.ts | 3 +- src/types/service.ts | 10 + tailwind.config.js | 111 +++--- 20 files changed, 936 insertions(+), 333 deletions(-) create mode 100644 src/components/action-button-group.tsx create mode 100644 src/components/header-button-group.tsx create mode 100644 src/components/ui/scroll-area.tsx create mode 100644 src/components/ui/skeleton.tsx create mode 100644 src/components/xui/icon-buttons.tsx create mode 100644 src/types/service.ts diff --git a/package-lock.json b/package-lock.json index e4d5dc2..058dc1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-navigation-menu": "^1.2.1", + "@radix-ui/react-scroll-area": "^1.2.1", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", "@tanstack/react-table": "^8.20.5", @@ -1695,6 +1696,37 @@ } } }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.1.tgz", + "integrity": "sha512-FnM1fHfCtEZ1JkyfH/1oMiTcFBQvHKl4vD9WnpwkLgtF+UmnXMCad6ECPTaAjcDjam+ndOEJWgHyKDGNteWSHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.2.tgz", diff --git a/package.json b/package.json index 513cb0c..ed93608 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-navigation-menu": "^1.2.1", + "@radix-ui/react-scroll-area": "^1.2.1", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", "@tanstack/react-table": "^8.20.5", diff --git a/src/api/service.ts b/src/api/service.ts index 1b917d3..e96280f 100644 --- a/src/api/service.ts +++ b/src/api/service.ts @@ -2,11 +2,11 @@ import { ModelServiceForm } from "@/types" import { fetcher, FetcherMethod } from "./api" export const createService = async (data: ModelServiceForm): Promise => { - return fetcher(FetcherMethod.POST, '/api/v1/profile', data) + return fetcher(FetcherMethod.POST, '/api/v1/service', data) } export const updateService = async (id: number, data: ModelServiceForm): Promise => { - return fetcher(FetcherMethod.PATCH, `/api/v1/profile/${id}`, data) + return fetcher(FetcherMethod.PATCH, `/api/v1/service/${id}`, data) } export const deleteService = async (id: number[]): Promise => { diff --git a/src/components/action-button-group.tsx b/src/components/action-button-group.tsx new file mode 100644 index 0000000..208519a --- /dev/null +++ b/src/components/action-button-group.tsx @@ -0,0 +1,50 @@ +import { Button } from "@/components/ui/button"; +import { TrashButton } from "@/components/xui/icon-buttons"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogClose, +} from "@/components/ui/dialog" +import { KeyedMutator } from "swr"; + +interface ButtonGroupProps { + className?: string; + children: React.ReactNode; + delete: { fn: (id: number[]) => Promise, id: number, mutate: KeyedMutator }; +} + +export function ActionButtonGroup({ className, children, delete: { fn, id, mutate } }: ButtonGroupProps) { + const handleDelete = async () => { + await fn([id]); + await mutate(); + } + + return ( +
+ {children} + + + + + + + Confirm Deletion? + + This operation is unrecoverable! + + + + + + + + + +
+ ) +} diff --git a/src/components/header-button-group.tsx b/src/components/header-button-group.tsx new file mode 100644 index 0000000..8534b47 --- /dev/null +++ b/src/components/header-button-group.tsx @@ -0,0 +1,64 @@ +import { Button } from "@/components/ui/button"; +import { TrashButton } from "@/components/xui/icon-buttons"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogClose, +} from "@/components/ui/dialog" +import { KeyedMutator } from "swr"; +import { toast } from "sonner" + +interface ButtonGroupProps { + className?: string; + children: React.ReactNode; + delete: { fn: (id: number[]) => Promise, id: number[], mutate: KeyedMutator }; +} + +export function HeaderButtonGroup({ className, children, delete: { fn, id, mutate } }: ButtonGroupProps) { + const handleDelete = async () => { + await fn(id); + await mutate(); + } + + return ( +
+ {id.length < 1 ? ( + <> + { + toast("Error", { + description: "No rows are selected." + }); + }} /> + {children} + + ) : ( + <> + + + + + + + Confirm Deletion? + + This operation is unrecoverable! + + + + + + + + + + {children} + + )} +
+ ) +} diff --git a/src/components/header.tsx b/src/components/header.tsx index 6a276d3..5fb9d8a 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -13,12 +13,14 @@ import { NzNavigationMenuLink } from "./xui/navigation-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from "./ui/dropdown-menu"; import { User, LogOut } from "lucide-react"; import { useAuth } from "@/hooks/useAuth"; -import { Link } from "react-router-dom"; - +import { Link, useLocation } from "react-router-dom"; export default function Header() { - const { logout } = useAuth() - const profile = useMainStore(store => store.profile) + const { logout } = useAuth(); + const profile = useMainStore(store => store.profile); + + const location = useLocation(); + return
@@ -31,12 +33,12 @@ export default function Header() { { profile && <> - + Server - + Service diff --git a/src/components/service.tsx b/src/components/service.tsx index b6d1344..f74afc8 100644 --- a/src/components/service.tsx +++ b/src/components/service.tsx @@ -1,9 +1,9 @@ -import { Plus } from "lucide-react" import { Button } from "@/components/ui/button" import { Dialog, DialogClose, DialogContent, + DialogDescription, DialogFooter, DialogHeader, DialogTitle, @@ -25,184 +25,388 @@ import { FormLabel, FormMessage, } from "@/components/ui/form" +import { ScrollArea } from "@/components/ui/scroll-area" import { useForm } from "react-hook-form" import { z } from "zod" import { zodResolver } from "@hookform/resolvers/zod" -import { ModelService } from "@/types" +import { ModelService, ModelServiceResponse } from "@/types" import { createService, updateService } from "@/api/service" -import { Checkbox } from "./ui/checkbox" -import { Label } from "./ui/label" +import { Checkbox } from "@/components/ui/checkbox" +import { Label } from "@/components/ui/label" +import { conv } from "@/lib/utils" +import { useState } from "react" +import { KeyedMutator } from "swr" +import { asOptionalField } from "@/lib/utils" +import { EditButton, PlusButton } from "@/components/xui/icon-buttons" +import { serviceTypes, serviceCoverageTypes } from "@/types" interface ServiceCardProps { - className?: string; data?: ModelService; + mutate: KeyedMutator; } const serviceFormSchema = z.object({ - cover: z.number(), - duration: z.number().min(30), - enable_show_in_service: z.boolean().default(false), - enable_trigger_task: z.boolean().default(false), - fail_trigger_tasks: z.array(z.number()), - latency_notify: z.boolean(), - max_latency: z.number(), - min_latency: z.number(), - name: z.string(), - notification_group_id: z.number(), - notify: z.boolean(), - recover_trigger_tasks: z.array(z.number()), + cover: z.coerce.number().min(0), + duration: z.coerce.number().min(30), + enable_show_in_service: asOptionalField(z.boolean()), + enable_trigger_task: asOptionalField(z.boolean()), + fail_trigger_tasks: z.array(z.string()).transform((v => { + return v.filter(Boolean).map(Number); + })), + latency_notify: asOptionalField(z.boolean()), + max_latency: z.coerce.number().min(0), + min_latency: z.coerce.number().min(0), + name: z.string().min(1), + notification_group_id: z.coerce.number(), + notify: asOptionalField(z.boolean()), + recover_trigger_tasks: z.array(z.string()).transform((v => { + return v.filter(Boolean).map(Number); + })), skip_servers: z.record(z.boolean()), - target: z.string(), - type: z.number(), + target: z.string().url(), + type: z.coerce.number().min(0), }); -const serviceTypes = { - 1: "HTTP GET (Certificate expiration and changes)", - 2: "ICMP Ping", - 3: "TCPing", -} - -const serviceCoverageTypes = { - 0: "All excludes specific servers", - 1: "Only specific servers", -} - -export const ServiceCard: React.FC = ({ className, data }) => { +export const ServiceCard: React.FC = ({ data, mutate }) => { const form = useForm>({ resolver: zodResolver(serviceFormSchema), - defaultValues: data, + defaultValues: data ? data : { + type: 1, + cover: 0, + name: "", + target: "", + max_latency: 0.0, + min_latency: 0.0, + duration: 30, + notification_group_id: 0, + fail_trigger_tasks: [], + recover_trigger_tasks: [], + skip_servers: {}, + }, + resetOptions: { + keepDefaultValues: false, + } }) - const onSubmit = (values: z.infer) => { - data?.id ? updateService(data.id, values) - : createService(values); + const [open, setOpen] = useState(false); + + const onSubmit = async (values: z.infer) => { + data?.id ? await updateService(data.id, values) : await createService(values); + setOpen(false); + await mutate(); + form.reset(); } return ( - + - + {data + ? + + : + + } - - - New Service - -
-
- - ( - - Service Name - - - - - - )} - /> - ( - - Target - - - - - - )} - /> - ( - - Type - - - - )} - /> - ( - - -
- - -
-
- -
- )} - /> - ( - - Interval - - - - - - )} - /> - ( - - Coverage - - - - )} - /> - - -
- - - - - + + + )} + /> + ( + + Target + + + + + + )} + /> + ( + + Type + + + + )} + /> + ( + + +
+ + +
+
+ +
+ )} + /> + ( + + Interval (s) + + + + + + )} + /> + ( + + Coverage + + + + )} + /> + ( + + Specific Servers (separate with comma) + + { + const rec = conv.strToRecord(e.target.value); + field.onChange(rec); + }} + /> + + + + )} + /> + ( + + Notification Group ID + + + + + + )} + /> + ( + + +
+ + +
+
+ +
+ )} + /> + ( + + Maximum Latency Time (ms) + + + + + + )} + /> + ( + + Minimum Latency Time (ms) + + + + + + )} + /> + ( + + +
+ + +
+
+ +
+ )} + /> + ( + + +
+ + +
+
+ +
+ )} + /> + ( + + Tasks to trigger on an alarm (Separate with comma) + + { + const arr = conv.strToArr(e.target.value); + field.onChange(arr); + }} + /> + + + + )} + /> + ( + + Tasks to trigger after recovery (Separate with comma) + + { + const arr = conv.strToArr(e.target.value); + field.onChange(arr); + }} + /> + + + + )} + /> + + + + + + + + + +
) diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index ddbdd01..93cba99 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -11,7 +11,7 @@ const Checkbox = React.forwardRef< , + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..01b8b6d --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/src/components/xui/icon-buttons.tsx b/src/components/xui/icon-buttons.tsx new file mode 100644 index 0000000..8add626 --- /dev/null +++ b/src/components/xui/icon-buttons.tsx @@ -0,0 +1,27 @@ +import { Plus, Edit2, Trash2 } from "lucide-react" +import { Button, ButtonProps } from "@/components/ui/button" +import { forwardRef } from "react"; + +export const EditButton = forwardRef((props, ref) => { + return ( + + ); +}); + +export const TrashButton = forwardRef((props, ref) => { + return ( + + ); +}); + +export const PlusButton = forwardRef((props, ref) => { + return ( + + ); +}); diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx index 1653b51..54bef76 100644 --- a/src/hooks/useAuth.tsx +++ b/src/hooks/useAuth.tsx @@ -32,8 +32,9 @@ export const AuthProvider = ({ children }: { const login = async (username: string, password: string) => { try { - await loginRequest(username, password) - setProfile({ username: username }); + await loginRequest(username, password); + const user = await getProfile(); + setProfile(user); navigate("/dashboard"); } catch (error: any) { toast(error.message); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bd0c391..924c466 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,44 @@ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" +import { z } from "zod" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +const emptyStringToUndefined = z.literal('').transform(() => undefined); + +export function asOptionalField(schema: T) { + return schema.optional().or(emptyStringToUndefined); +} + +export const conv = { + recordToStr: (rec: Record) => { + const arr: string[] = []; + for (const key in rec) { + arr.push(key); + } + + return arr.join(','); + }, + strToRecord: (str: string) => { + const arr = str.split(','); + return arr.reduce((acc, num) => { + acc[num] = true; + return acc; + }, {} as Record); + }, + arrToStr: (arr: T[]) => { + return arr.join(','); + }, + strToArr: (str: string) => { + return str.split(','); + }, + recordToArr: (rec: Record) => { + const arr: T[] = []; + for (const val of Object.values(rec)) { + arr.push(val); + } + return arr; + }, +} diff --git a/src/routes/root.tsx b/src/routes/root.tsx index 391a72f..1dbaaa8 100644 --- a/src/routes/root.tsx +++ b/src/routes/root.tsx @@ -8,7 +8,7 @@ import { Toaster } from "@/components/ui/sonner"; export default function Root() { return ( - +
diff --git a/src/routes/server.tsx b/src/routes/server.tsx index 760ce95..19c285f 100644 --- a/src/routes/server.tsx +++ b/src/routes/server.tsx @@ -113,4 +113,4 @@ export default function ServerPage() {
-} \ No newline at end of file +} diff --git a/src/routes/service.tsx b/src/routes/service.tsx index a209979..06dbbad 100644 --- a/src/routes/service.tsx +++ b/src/routes/service.tsx @@ -2,11 +2,28 @@ import { swrFetcher } from "@/api/api" import { Checkbox } from "@/components/ui/checkbox" import { ServiceCard } from "@/components/service" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import { ModelService as Service } from "@/types" +import { ModelServiceResponse, ModelServiceResponseItem as Service } from "@/types" import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table" import useSWR from "swr" +import { conv } from "@/lib/utils" +import { useEffect, useMemo } from "react" +import { serviceTypes } from "@/types" +import { ActionButtonGroup } from "@/components/action-button-group" +import { deleteService } from "@/api/service" +import { HeaderButtonGroup } from "@/components/header-button-group" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "sonner" export default function ServicePage() { + const { data, mutate, error, isLoading } = useSWR('/api/v1/service', swrFetcher) + + useEffect(() => { + if (error) + toast("Error", { + description: "Error fetching resource.", + }) + }, [error]) + const columns: ColumnDef[] = [ { id: "select", @@ -32,18 +49,67 @@ export default function ServicePage() { }, { header: "ID", - accessorKey: "id", - accessorFn: (row) => row.id, + accessorKey: "service.id", + accessorFn: row => row.service.id, }, { header: "Name", - accessorKey: "name", - accessorFn: (row) => row.name, + accessorKey: "service.name", + accessorFn: row => row.service.name, + }, + { + header: "Target", + accessorKey: "service.target", + accessorFn: row => row.service.target, + }, + { + header: "Coverage", + accessorKey: "service.cover", + accessorFn: row => { + switch (row.service.cover) { + case 0: { + return "Cover All" + } + case 1: { + return "Ignore All" + } + } + } + }, + { + header: "Specific Servers", + accessorKey: "service.skipServers", + accessorFn: row => Object.keys(row.service.skip_servers ?? {}), }, { header: "Type", accessorKey: "service.type", - accessorFn: (row) => row.type, + accessorFn: row => serviceTypes[row.service.type] || '', + }, + { + header: "Interval", + accessorKey: "service.duration", + accessorFn: row => row.service.duration, + }, + { + header: "Notification Group ID", + accessorKey: "service.ngroup", + accessorFn: row => row.service.notification_group_id, + }, + { + header: "Enable Trigger Task", + accessorKey: "service.triggerTask", + accessorFn: row => row.service.enable_trigger_task ?? false, + }, + { + header: "Tasks to trigger on an alarm", + accessorKey: "service.failTriggerTasks", + accessorFn: row => row.service.fail_trigger_tasks, + }, + { + header: "Tasks to trigger after recovery", + accessorKey: "service.recoverTriggerTasks", + accessorFn: row => row.service.recover_trigger_tasks, }, { id: "actions", @@ -51,68 +117,90 @@ export default function ServicePage() { cell: ({ row }) => { const s = row.original return ( - <>{s.id} + + + ) }, }, ] - const { data, error, isLoading } = useSWR('/api/v1/service', swrFetcher) + const dataArr = useMemo(() => { + return conv.recordToArr(data?.services ?? {}); + }, [data?.services]); const table = useReactTable({ - data: data ?? [], + data: dataArr, columns, getCoreRowModel: getCoreRowModel(), }) - return
-
-

- Service -

- -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ) - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} + const selectedRows = table.getSelectedRowModel().rows; + + return ( +
+
+

+ Service +

+ r.original.service.id), + mutate: mutate, + }}> + + +
+ {isLoading ? ( +
+ + + +
+ ) : ( +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. - ))} - - )) - ) : ( - - - No results. - - - )} - -
-
-} \ No newline at end of file + + )} + + + )} +
+ ) +} diff --git a/src/types/api.ts b/src/types/api.ts index 5994152..fe28ee0 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -142,14 +142,16 @@ export interface ModelAlertRule { } export interface ModelAlertRuleForm { - enable: boolean; + enable?: boolean; /** 失败时触发的任务id */ fail_trigger_tasks: number[]; + /** @minLength 1 */ name: string; notification_group_id: number; /** 恢复时触发的任务id */ recover_trigger_tasks: number[]; rules: ModelRule[]; + /** @default 0 */ trigger_mode: number; } @@ -219,43 +221,52 @@ export interface ModelCron { } export interface ModelCronForm { - command: string; + command?: string; + /** @default 0 */ cover: number; id: number; + /** @minLength 1 */ name: string; notification_group_id: number; - push_successful: boolean; + push_successful?: boolean; scheduler: string; servers: number[]; - /** 0:计划任务 1:触发任务 */ + /** + * 0:计划任务 1:触发任务 + * @default 0 + */ task_type: number; } export interface ModelCycleTransferStats { - from?: string; - max?: number; - min?: number; - name?: string; - nextUpdate?: Record; - serverName?: Record; - to?: string; - transfer?: Record; + from: string; + max: number; + min: number; + name: string; + next_update: Record; + server_name: Record; + to: string; + transfer: Record; } export interface ModelDDNSForm { - access_id: string; - access_secret: string; + access_id?: string; + access_secret?: string; domains: string[]; - enable_ipv4: boolean; - enable_ipv6: boolean; + enable_ipv4?: boolean; + enable_ipv6?: boolean; + /** @default 3 */ max_retries: number; + /** @minLength 1 */ name: string; provider: string; - webhook_headers: string; - webhook_method: number; - webhook_request_body: string; - webhook_request_type: number; - webhook_url: string; + webhook_headers?: string; + /** @default 1 */ + webhook_method?: number; + webhook_request_body?: string; + /** @default 1 */ + webhook_request_type?: number; + webhook_url?: string; } export interface ModelDDNSProfile { @@ -328,16 +339,17 @@ export interface ModelNAT { created_at: string; deleted_at: GormDeletedAt; domain: string; - host?: string; + host: string; id: number; - name?: string; - serverID?: number; + name: string; + server_id: number; updated_at: string; } export interface ModelNATForm { domain: string; host: string; + /** @minLength 1 */ name: string; server_id: number; } @@ -357,14 +369,15 @@ export interface ModelNotification { } export interface ModelNotificationForm { + /** @minLength 1 */ name: string; request_body: string; request_header: string; request_method: number; request_type: number; - skip_check: boolean; + skip_check?: boolean; url: string; - verify_tls: boolean; + verify_tls?: boolean; } export interface ModelNotificationGroup { @@ -376,6 +389,7 @@ export interface ModelNotificationGroup { } export interface ModelNotificationGroupForm { + /** @minLength 1 */ name: string; notifications: number[]; } @@ -442,17 +456,20 @@ export interface ModelServer { export interface ModelServerForm { /** DDNS配置 */ ddns_profiles: number[]; - /** 展示排序,越大越靠前 */ + /** + * 展示排序,越大越靠前 + * @default 0 + */ display_index: number; /** 启用DDNS */ - enable_ddns: boolean; + enable_ddns?: boolean; /** 对游客隐藏 */ - hide_for_guest: boolean; + hide_for_guest?: boolean; name: string; /** 管理员可见备注 */ - note: string; + note?: string; /** 公开备注 */ - public_note: string; + public_note?: string; } export interface ModelServerGroup { @@ -464,6 +481,7 @@ export interface ModelServerGroup { } export interface ModelServerGroupForm { + /** @minLength 1 */ name: string; servers: number[]; } @@ -501,15 +519,18 @@ export interface ModelService { export interface ModelServiceForm { cover: number; duration: number; - enable_show_in_service: boolean; - enable_trigger_task: boolean; + enable_show_in_service?: boolean; + enable_trigger_task?: boolean; fail_trigger_tasks: number[]; - latency_notify: boolean; + latency_notify?: boolean; + /** @default 0 */ max_latency: number; + /** @default 0 */ min_latency: number; + /** @minLength 1 */ name: string; notification_group_id: number; - notify: boolean; + notify?: boolean; recover_trigger_tasks: number[]; skip_servers: Record; target: string; @@ -526,30 +547,30 @@ export interface ModelServiceInfos { } export interface ModelServiceResponse { - cycleTransferStats?: Record; - services?: Record; + cycle_transfer_stats: Record; + services: Record; } export interface ModelServiceResponseItem { - currentDown?: number; - currentUp?: number; - delay?: number[]; - down?: number[]; - service?: ModelService; - totalDown?: number; - totalUp?: number; - up?: number[]; + current_down: number; + current_up: number; + delay: number[]; + down: number[]; + service: ModelService; + total_down: number; + total_up: number; + up: number[]; } export interface ModelSettingForm { cover: number; - custom_code: string; - custom_code_dashboard: string; - custom_nameservers: string; - enable_ip_change_notification: boolean; - enable_plain_ip_in_notification: boolean; - ignored_ip_notification: string; - install_host: string; + custom_code?: string; + custom_code_dashboard?: string; + custom_nameservers?: string; + enable_ip_change_notification?: boolean; + enable_plain_ip_in_notification?: boolean; + ignored_ip_notification?: string; + install_host?: string; /** IP变更提醒的通知组 */ ip_change_notification_group_id: number; language: string; diff --git a/src/types/index.ts b/src/types/index.ts index ae27896..7defd11 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,4 @@ export * from './mainStore'; export * from './authContext'; -export * from './api'; \ No newline at end of file +export * from './api'; +export * from './service'; diff --git a/src/types/service.ts b/src/types/service.ts new file mode 100644 index 0000000..7be6e8c --- /dev/null +++ b/src/types/service.ts @@ -0,0 +1,10 @@ +export const serviceTypes: Record = { + 1: "HTTP GET", + 2: "ICMP Ping", + 3: "TCPing", +} + +export const serviceCoverageTypes: Record = { + 0: "All excludes specific servers", + 1: "Only specific servers", +} diff --git a/tailwind.config.js b/tailwind.config.js index c73f0d9..28bdada 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,57 +1,60 @@ /** @type {import('tailwindcss').Config} */ export default { - darkMode: ["class"], - content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"], - theme: { - extend: { - borderRadius: { - lg: 'var(--radius)', - md: 'calc(var(--radius) - 2px)', - sm: 'calc(var(--radius) - 4px)' - }, - colors: { - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))' - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))' - }, - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))' - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))' - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))' - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))' - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))' - }, - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - chart: { - '1': 'hsl(var(--chart-1))', - '2': 'hsl(var(--chart-2))', - '3': 'hsl(var(--chart-3))', - '4': 'hsl(var(--chart-4))', - '5': 'hsl(var(--chart-5))' - } - } - } - }, - plugins: [require("tailwindcss-animate")], + darkMode: ["class"], + content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"], + theme: { + extend: { + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + }, + colors: { + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))' + }, + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))' + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))' + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))' + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))' + }, + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + chart: { + '1': 'hsl(var(--chart-1))', + '2': 'hsl(var(--chart-2))', + '3': 'hsl(var(--chart-3))', + '4': 'hsl(var(--chart-4))', + '5': 'hsl(var(--chart-5))' + }, + }, + fontSize: { + xsm: ['0.825rem', '1.25rem'], + }, + } + }, + plugins: [require("tailwindcss-animate")], } From bf7ffd36e094910fd2fa91e13678ac0d6ea808d4 Mon Sep 17 00:00:00 2001 From: UUBulb <35923940+uubulb@users.noreply.github.com> Date: Mon, 18 Nov 2024 20:48:30 +0800 Subject: [PATCH 002/220] further implementing server page (#4) * further implementing server page * optimize icon button * rename some unnecessary file extensions * add terminal page & fm card --- package-lock.json | 63 +++++ package.json | 5 + src/api/fm.ts | 6 + src/api/server.ts | 10 + src/api/terminal.ts | 8 + src/components/action-button-group.tsx | 56 ++--- src/components/fm.tsx | 93 ++++++++ src/components/header-button-group.tsx | 60 ++--- src/components/install-commands.tsx | 38 +++ src/components/note-menu.tsx | 49 ++++ src/components/server.tsx | 222 ++++++++++++++++++ src/components/service.tsx | 6 +- src/components/terminal.tsx | 190 +++++++++++++++ src/components/ui/alert-dialog.tsx | 139 +++++++++++ src/components/ui/textarea.tsx | 22 ++ src/components/xui/icon-button.tsx | 42 ++++ src/components/xui/icon-buttons.tsx | 27 --- src/components/xui/overlayless-sheet.tsx | 121 ++++++++++ src/lib/utils.ts | 4 + src/main.tsx | 5 + src/routes/server.tsx | 202 +++++++++++----- src/routes/service.tsx | 24 +- src/types/{authContext.tsx => authContext.ts} | 0 src/types/{mainStore.tsx => mainStore.ts} | 0 vite.config.ts | 5 + 25 files changed, 1248 insertions(+), 149 deletions(-) create mode 100644 src/api/fm.ts create mode 100644 src/api/server.ts create mode 100644 src/api/terminal.ts create mode 100644 src/components/fm.tsx create mode 100644 src/components/install-commands.tsx create mode 100644 src/components/note-menu.tsx create mode 100644 src/components/server.tsx create mode 100644 src/components/terminal.tsx create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/components/ui/textarea.tsx create mode 100644 src/components/xui/icon-button.tsx delete mode 100644 src/components/xui/icon-buttons.tsx create mode 100644 src/components/xui/overlayless-sheet.tsx rename src/types/{authContext.tsx => authContext.ts} (100%) rename src/types/{mainStore.tsx => mainStore.ts} (100%) diff --git a/package-lock.json b/package-lock.json index 058dc1b..7df9140 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@hookform/resolvers": "^3.9.1", + "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", @@ -19,6 +20,9 @@ "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", "@tanstack/react-table": "^8.20.5", + "@xterm/addon-attach": "^0.11.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "jotai-zustand": "^0.6.0", @@ -28,6 +32,7 @@ "react-dom": "^18.3.1", "react-hook-form": "^7.53.1", "react-router-dom": "^6.27.0", + "react-use-websocket": "^4.10.1", "sonner": "^1.6.1", "swr": "^2.2.5", "tailwind-merge": "^2.5.4", @@ -1118,6 +1123,34 @@ "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==", "license": "MIT" }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.2.tgz", + "integrity": "sha512-eGSlLzPhKO+TErxkiGcCZGuvbVMnLA1MTnyBksGOeGRGkxHiiJUujsjmNTdWTm4iHVSRaUao9/4Ur671auMghQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dialog": "1.1.2", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", @@ -2594,6 +2627,30 @@ "vite": "^4.2.0 || ^5.0.0" } }, + "node_modules/@xterm/addon-attach": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-attach/-/addon-attach-0.11.0.tgz", + "integrity": "sha512-JboCN0QAY6ZLY/SSB/Zl2cQ5zW1Eh4X3fH7BnuR1NB7xGRhzbqU2Npmpiw/3zFlxDaU88vtKzok44JKi2L2V2Q==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", @@ -4977,6 +5034,12 @@ } } }, + "node_modules/react-use-websocket": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.10.1.tgz", + "integrity": "sha512-PrZbKj3BSy9kRU9otKEoMi0FOcEVh1abyYxJDzB/oL7kMBDBs+ZXhnWWed/sc679nPHAWMOn1gotoV04j5gJUw==", + "license": "MIT" + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/package.json b/package.json index ed93608..747de86 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@hookform/resolvers": "^3.9.1", + "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", @@ -21,6 +22,9 @@ "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", "@tanstack/react-table": "^8.20.5", + "@xterm/addon-attach": "^0.11.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "jotai-zustand": "^0.6.0", @@ -30,6 +34,7 @@ "react-dom": "^18.3.1", "react-hook-form": "^7.53.1", "react-router-dom": "^6.27.0", + "react-use-websocket": "^4.10.1", "sonner": "^1.6.1", "swr": "^2.2.5", "tailwind-merge": "^2.5.4", diff --git a/src/api/fm.ts b/src/api/fm.ts new file mode 100644 index 0000000..82c3d68 --- /dev/null +++ b/src/api/fm.ts @@ -0,0 +1,6 @@ +import { ModelCreateFMResponse } from "@/types"; +import { fetcher, FetcherMethod } from "./api" + +export const createFM = async (id: string): Promise => { + return fetcher(FetcherMethod.GET, `/api/v1/file?id=${id}`, null); +} diff --git a/src/api/server.ts b/src/api/server.ts new file mode 100644 index 0000000..3bc90ff --- /dev/null +++ b/src/api/server.ts @@ -0,0 +1,10 @@ +import { ModelServerForm } from "@/types" +import { fetcher, FetcherMethod } from "./api" + +export const updateServer = async (id: number, data: ModelServerForm): Promise => { + return fetcher(FetcherMethod.PATCH, `/api/v1/server/${id}`, data) +} + +export const deleteServer = async (id: number[]): Promise => { + return fetcher(FetcherMethod.POST, '/api/v1/batch-delete/server', id) +} diff --git a/src/api/terminal.ts b/src/api/terminal.ts new file mode 100644 index 0000000..0e21c05 --- /dev/null +++ b/src/api/terminal.ts @@ -0,0 +1,8 @@ +import { ModelTerminalForm, ModelCreateTerminalResponse } from "@/types"; +import { fetcher, FetcherMethod } from "./api" + +export const createTerminal = async (id: number): Promise => { + return fetcher(FetcherMethod.POST, '/api/v1/terminal', { + server_id: id, + }); +} diff --git a/src/components/action-button-group.tsx b/src/components/action-button-group.tsx index 208519a..b639879 100644 --- a/src/components/action-button-group.tsx +++ b/src/components/action-button-group.tsx @@ -1,16 +1,17 @@ -import { Button } from "@/components/ui/button"; -import { TrashButton } from "@/components/xui/icon-buttons"; +import { IconButton } from "@/components/xui/icon-button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, - DialogClose, -} from "@/components/ui/dialog" + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" import { KeyedMutator } from "swr"; +import { buttonVariants } from "@/components/ui/button" interface ButtonGroupProps { className?: string; @@ -27,24 +28,23 @@ export function ActionButtonGroup({ className, children, delete: { fn, id, mu return (
{children} - - - - - - - Confirm Deletion? - + + + + + + + Confirm Deletion? + This operation is unrecoverable! - - - - - - - - - + + + + Cancel + Confirm + + +
) } diff --git a/src/components/fm.tsx b/src/components/fm.tsx new file mode 100644 index 0000000..0288290 --- /dev/null +++ b/src/components/fm.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState, useRef } from "react" +import { + Sheet, + SheetTrigger, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, +} from "./xui/overlayless-sheet" +import { IconButton } from "./xui/icon-button" +import { createFM } from "@/api/fm" +import { ModelCreateFMResponse } from "@/types" +import useWebSocket from "react-use-websocket" +import { toast } from "sonner" + +interface FMProps { + wsUrl: string; +} + +const FMComponent: React.FC = ({ wsUrl, ...props }) => { + const fmRef = useRef(null); + + const { sendMessage } = useWebSocket(wsUrl, { + share: false, + onOpen: () => { + listFile(); + }, + onClose: (e) => { + console.log('WebSocket connection closed:', e); + }, + onError: (e) => { + console.log(e); + toast("Websocket error", { + description: "View console for details.", + }) + }, + onMessage: async (e) => { + + } + }); + + const currentPath = useRef('').current; + + const listFile = () => { + const prefix = new Int8Array([0]); + const resizeMessage = new TextEncoder().encode(currentPath); + + const msg = new Int8Array(prefix.length + resizeMessage.length); + msg.set(prefix); + msg.set(resizeMessage, prefix.length); + + sendMessage(msg); + } + + return
; +} + +export const FMCard = ({ id }: { id?: string }) => { + const [open, setOpen] = useState(false); + const [fm, setFM] = useState(null); + + const fetchFM = async () => { + if (id && !fm) { + try { + const createdFM = await createFM(id); + setFM(createdFM); + } catch (e) { + toast("FM API Error", { + description: "View console for details.", + }) + console.log("fetch error", e); + return; + } + } + } + + return ( + { if (isOpen) setOpen(true); }}> + + + + + + Pseudo File Manager + + +
+ +
+
+
+ ) +} diff --git a/src/components/header-button-group.tsx b/src/components/header-button-group.tsx index 8534b47..4ecfc80 100644 --- a/src/components/header-button-group.tsx +++ b/src/components/header-button-group.tsx @@ -1,21 +1,22 @@ -import { Button } from "@/components/ui/button"; -import { TrashButton } from "@/components/xui/icon-buttons"; +import { buttonVariants } from "@/components/ui/button"; +import { IconButton } from "@/components/xui/icon-button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, - DialogClose, -} from "@/components/ui/dialog" + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" import { KeyedMutator } from "swr"; import { toast } from "sonner" interface ButtonGroupProps { className?: string; - children: React.ReactNode; + children?: React.ReactNode; delete: { fn: (id: number[]) => Promise, id: number[], mutate: KeyedMutator }; } @@ -29,7 +30,7 @@ export function HeaderButtonGroup({ className, children, delete: { fn, id, mu
{id.length < 1 ? ( <> - { + { toast("Error", { description: "No rows are selected." }); @@ -38,24 +39,23 @@ export function HeaderButtonGroup({ className, children, delete: { fn, id, mu ) : ( <> - - - - - - - Confirm Deletion? - + + + + + + + Confirm Deletion? + This operation is unrecoverable! - - - - - - - - - + + + + Cancel + Confirm + + + {children} )} diff --git a/src/components/install-commands.tsx b/src/components/install-commands.tsx new file mode 100644 index 0000000..d540cc2 --- /dev/null +++ b/src/components/install-commands.tsx @@ -0,0 +1,38 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { ButtonProps } from "@/components/ui/button" +import { forwardRef, useState } from "react" +import { IconButton } from "./xui/icon-button" + +export const InstallCommandsMenu = forwardRef((props, ref) => { + const [copy, setCopy] = useState(false); + + const switchState = async () => { + if (!copy) { + setCopy(true); + await navigator.clipboard.writeText("stub"); + setTimeout(() => { + setCopy(false); + }, 2 * 1000); + } + } + + return ( + + + + + + Linux + macOS + Windows + + + ); +}) diff --git a/src/components/note-menu.tsx b/src/components/note-menu.tsx new file mode 100644 index 0000000..46f508d --- /dev/null +++ b/src/components/note-menu.tsx @@ -0,0 +1,49 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { ButtonProps } from "@/components/ui/button" +import { forwardRef, useState } from "react" +import { IconButton } from "./xui/icon-button" +import { toast } from "sonner"; + +interface NoteMenuProps extends ButtonProps { + note: { private?: string, public?: string }; +} + +export const NoteMenu = forwardRef((props, ref) => { + const [copy, setCopy] = useState(false); + + const switchState = async (text?: string) => { + if (!text) { + toast("Warning", { + description: "You didn't have any note." + }) + return; + } + + if (!copy) { + setCopy(true); + await navigator.clipboard.writeText(text); + setTimeout(() => { + setCopy(false); + }, 2 * 1000); + } + } + + return ( + + + + + + { switchState(props.note.private) }}>Private + { switchState(props.note.public) }}>Public + + + ); +}) diff --git a/src/components/server.tsx b/src/components/server.tsx new file mode 100644 index 0000000..60060a1 --- /dev/null +++ b/src/components/server.tsx @@ -0,0 +1,222 @@ +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { ScrollArea } from "@/components/ui/scroll-area" +import { useForm } from "react-hook-form" +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" +import { ModelServer } from "@/types" +import { updateServer } from "@/api/server" +import { Checkbox } from "@/components/ui/checkbox" +import { Label } from "@/components/ui/label" +import { conv } from "@/lib/utils" +import { useState } from "react" +import { KeyedMutator } from "swr" +import { asOptionalField } from "@/lib/utils" +import { IconButton } from "@/components/xui/icon-button" +import { Textarea } from "@/components/ui/textarea" + +interface ServerCardProps { + data: ModelServer; + mutate: KeyedMutator; +} + +const serverFormSchema = z.object({ + name: z.string().min(1), + note: asOptionalField(z.string()), + public_note: asOptionalField(z.string()), + display_index: z.number(), + hide_for_guest: asOptionalField(z.boolean()), + enable_ddns: asOptionalField(z.boolean()), + ddns_profiles: z.array(z.string()).transform((v => { + return v.filter(Boolean).map(Number); + })), +}); + +export const ServerCard: React.FC = ({ data, mutate }) => { + const form = useForm>({ + resolver: zodResolver(serverFormSchema), + defaultValues: data, + resetOptions: { + keepDefaultValues: false, + } + }) + + const [open, setOpen] = useState(false); + + const onSubmit = async (values: z.infer) => { + await updateServer(data.id, values); + setOpen(false); + await mutate(); + form.reset(); + } + + return ( + + + + + + +
+ + New Service + + +
+ + ( + + Name + + + + + + )} + /> + ( + + Display Index + + + + + + )} + /> + ( + + DDNS Profile IDs (Separate with comma) + + { + const arr = conv.strToArr(e.target.value); + field.onChange(arr); + }} + /> + + + + )} + /> + ( + + +
+ + +
+
+ +
+ )} + /> + ( + + +
+ + +
+
+ +
+ )} + /> + ( + + Note + + + + { + setToUserId(parseInt(e.target.value, 10)) + }} + /> + + + + + + +
+
+ + +
+ ) +} diff --git a/src/components/xui/icon-button.tsx b/src/components/xui/icon-button.tsx index 49a1152..a00163d 100644 --- a/src/components/xui/icon-button.tsx +++ b/src/components/xui/icon-button.tsx @@ -16,6 +16,7 @@ import { Terminal, Trash2, Upload, + UserPen, } from "lucide-react" import { forwardRef } from "react" @@ -37,6 +38,7 @@ export interface IconButtonProps extends ButtonProps { | "expand" | "cog" | "minus" + | "user-pen" } export const IconButton = forwardRef((props, ref) => { @@ -97,6 +99,9 @@ export const IconButton = forwardRef((props, case "minus": { return } + case "user-pen": { + return + } } })()} diff --git a/src/routes/server.tsx b/src/routes/server.tsx index 1ac27f6..d84e37f 100644 --- a/src/routes/server.tsx +++ b/src/routes/server.tsx @@ -1,6 +1,7 @@ import { swrFetcher } from "@/api/api" import { deleteServer, forceUpdateServer } from "@/api/server" import { ActionButtonGroup } from "@/components/action-button-group" +import { BatchMoveServerIcon } from "@/components/batch-move-server-icon" import { CopyButton } from "@/components/copy-button" import { HeaderButtonGroup } from "@/components/header-button-group" import { InstallCommandsMenu } from "@/components/install-commands" @@ -213,6 +214,7 @@ export default function ServerPage() { }) }} /> + r.original.id)} /> r.original.id)} className="shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] bg-yellow-600 text-white hover:bg-yellow-500 dark:hover:bg-yellow-700 rounded-lg" diff --git a/src/routes/user.tsx b/src/routes/user.tsx index 39a501e..12416a0 100644 --- a/src/routes/user.tsx +++ b/src/routes/user.tsx @@ -74,6 +74,11 @@ export default function UserPage() { return row.role === 1 ? t("User") : t("Admin") }, }, + { + header: t("LastLogin"), + accessorKey: "updated_at", + accessorFn: (row) => row.updated_at ? new Date(row.updated_at).toLocaleString() : t("Never"), + }, { id: "actions", header: t("Actions"), diff --git a/src/types/api.ts b/src/types/api.ts index 14cc7cc..eb2cb11 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,14 +1,14 @@ -/* eslint-disable */ -/* tslint:disable */ -/* - * --------------------------------------------------------------- - * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## - * ## ## - * ## AUTHOR: acacode ## - * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## - * --------------------------------------------------------------- - */ - +/* eslint-disable */ +/* tslint:disable */ +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + export interface GithubComNezhahqNezhaModelCommonResponseAny { data: any error: string @@ -188,6 +188,11 @@ export interface ModelAlertRuleForm { trigger_mode: number } +export interface ModelBatchMoveServerForm { + ids: number[] + to_user: number +} + export interface ModelCreateFMResponse { session_id: string } @@ -631,6 +636,8 @@ export interface ModelServiceResponseItem { export interface ModelSetting { admin_template: string + /** Agent真实IP */ + agent_real_ip_header: string /** 覆盖范围(0:提醒未被 IgnoredIPNotification 包含的所有服务器; 1:仅提醒被 IgnoredIPNotification 包含的服务器;) */ cover: number custom_code: string @@ -648,17 +655,17 @@ export interface ModelSetting { /** 系统语言,默认 zh_CN */ language: string oauth2_providers: string[] - /** 前端真实IP */ - web_real_ip_header: string - /** Agent真实IP */ - agent_real_ip_header: string site_name: string /** 用于前端判断生成的安装命令是否启用 TLS */ tls: boolean user_template: string + /** 前端真实IP */ + web_real_ip_header: string } export interface ModelSettingForm { + /** Agent真实IP */ + agent_real_ip_header?: string cover: number custom_code?: string custom_code_dashboard?: string @@ -671,14 +678,12 @@ export interface ModelSettingForm { ip_change_notification_group_id: number /** @minLength 2 */ language: string - /** 前端真实IP */ - web_real_ip_header?: string - /** Agent真实IP */ - agent_real_ip_header?: string /** @minLength 1 */ site_name: string tls?: boolean user_template?: string + /** 前端真实IP */ + web_real_ip_header?: string } export interface ModelSettingResponse { From e05fe015a3d95473ca078ce92d08b1439a8451c3 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Fri, 25 Jul 2025 15:46:13 +0200 Subject: [PATCH 116/220] Translated using Weblate (French) (#133) Currently translated at 25.9% (47 of 181 strings) Added translation using Weblate (French) Translated using Weblate (Indonesian) Currently translated at 9.9% (18 of 181 strings) Translate-URL: https://hosted.weblate.org/projects/nezha/admin-frontend/fr/ Translate-URL: https://hosted.weblate.org/projects/nezha/admin-frontend/id/ Translation: Nezha/Admin frontend Co-authored-by: Kxroqi :3 Co-authored-by: TommyLPB --- src/locales/fr/translation.json | 55 +++++++++++++++++++++++++++++++++ src/locales/id/translation.json | 25 ++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 src/locales/fr/translation.json diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json new file mode 100644 index 0000000..28f1e02 --- /dev/null +++ b/src/locales/fr/translation.json @@ -0,0 +1,55 @@ +{ + "theme": { + "light": "Clair", + "dark": "Sombre" + }, + "Username": "Nom d’utilisateur", + "Password": "Mot de passe", + "CurrentTime": "Heure actuelle", + "Results": { + "UsernameMin": "Le nom d’utilisateur doit comporter au moins {{number}} caractères.", + "PasswordRequired": "Le mot de passe ne peut pas être vide.", + "SelectAtLeastOneServer": "Veuillez sélectionner au moins un serveur.", + "UnExpectedError": "Erreur inattendue, veuillez consulter la console pour avoir plus de détails.", + "ForceUpdate": "Mise à jour forcée :", + "NoRowsAreSelected": "Aucune ligne n’a été sélectionnée", + "ThisOperationIsUnrecoverable": "Cette opération ne peut pas être annulée !", + "TheServerDoesNotOnline": "Le serveur n’existe pas ou n’a pas encore été connecté", + "UnknownIdentifier": "Identifiant inconnu" + }, + "Login": "Se connecter", + "Server": "Serveur", + "Service": "Service", + "Task": "Tâche", + "Notification": "Notification", + "DDNS": "DNS dynamique", + "NATT": "NAT traversal", + "Group": "Groupe", + "Profile": "Profil", + "Settings": "Paramètres système", + "BackToHome": "Retourner à l’accueil", + "Logout": "Se déconnecter", + "NavigateTo": "Naviguer vers", + "Close": "Fermer", + "Error": "Erreur", + "Name": "Nom", + "Version": "Version", + "Unknown": "inconnu", + "Enable": "Activer", + "HideForGuest": "Caché des visiteurs", + "InstallCommands": "Commande d’installation", + "Note": "Note", + "Success": "Succès", + "Done": "Terminé", + "Offline": "Hors-ligne", + "Failure": "Échoué", + "Loading": "Chargement", + "NoResults": "Pas de résultats", + "Actions": "Actions", + "EditServer": "Modifier le serveur", + "SeparateWithComma": "(Séparé par des virgules)", + "Public": "Publique", + "Private": "Privée", + "Submit": "Envoyer", + "IgnoreAll": "Tout ignorer" +} diff --git a/src/locales/id/translation.json b/src/locales/id/translation.json index 0967ef4..43e0110 100644 --- a/src/locales/id/translation.json +++ b/src/locales/id/translation.json @@ -1 +1,24 @@ -{} +{ + "nezha": "Monitor Nezha", + "theme": { + "light": "Terang", + "dark": "Gelap", + "system": "Mengikuti sistem" + }, + "Username": "Nama Pengguna", + "Password": "Kata Sandi", + "LoginFirst": "Mohon masuk terlebih dahulu", + "CurrentTime": "Waktu saat ini", + "Results": { + "UsernameMin": "Nama Pengguna setidak nya harus {{number}} karakter.", + "PasswordRequired": "Kata Sandi tidak boleh kosong.", + "ErrorFetchingResource": "Kesalahan mengambil sumber daya: {{error}}", + "SelectAtLeastOneServer": "Silahkan pilih setidaknya satu server.", + "UnExpectedError": "Kesalahan tidak terduga, Silahhkan lihat konsol untuk detailnya.", + "ForceUpdate": "Peningkatan Paksa:", + "NoRowsAreSelected": "Tidak ada baris yang dipilih", + "ThisOperationIsUnrecoverable": "Operasi tidak dapat dibatalkan!", + "TaskTriggeredSuccessfully": "Tugas berhasil dipicu", + "TheServerDoesNotOnline": "Server tidak ada atau masih belum terhubung" + } +} From d8d34abf2f53d4a52b84e26c03bfcb6caeac9d16 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sun, 27 Jul 2025 04:10:09 +0200 Subject: [PATCH 117/220] Translated using Weblate (French) (#135) Currently translated at 48.0% (87 of 181 strings) Translate-URL: https://hosted.weblate.org/projects/nezha/admin-frontend/fr/ Translation: Nezha/Admin frontend Co-authored-by: TommyLPB --- src/locales/fr/translation.json | 44 +++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 28f1e02..7c40fca 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -1,7 +1,8 @@ { "theme": { "light": "Clair", - "dark": "Sombre" + "dark": "Sombre", + "system": "Suivre le système" }, "Username": "Nom d’utilisateur", "Password": "Mot de passe", @@ -51,5 +52,44 @@ "Public": "Publique", "Private": "Privée", "Submit": "Envoyer", - "IgnoreAll": "Tout ignorer" + "IgnoreAll": "Tout ignorer", + "LoginFirst": "Veuillez d’abord vous connecter", + "DDNSProfiles": "ID de profil DDNS", + "Target": "Cible", + "SpecificServers": "Serveur spécifique", + "Type": "Type", + "Interval": "Intervalle", + "TasksToTriggerOnAlert": "Tâches à déclencher en cas d’alerte", + "TasksToTriggerAfterRecovery": "Tâches à déclencher après la récupération", + "Confirm": "Confirmer", + "ConfirmDeletion": "Confirmer la suppression ?", + "Services": "Services", + "ShowInService": "Montrer en service", + "MaximumLatency": "Délai maximum (ms)", + "MinimumLatency": "Délai minimum (millisecondes)", + "Command": "Commande", + "LastExecution": "Dernière exécution", + "Result": "Résultat", + "Scheduled": "Tâches planifiées", + "AlertRule": "Règles d’alerte", + "Rules": "Règles", + "DoNotSendTestMessage": "Ne pas envoyer de message de test", + "Always": "Toujours", + "Domains": "Domaines", + "Refresh": "Rafraîchir", + "CopyPath": "Copier le chemin", + "NewUsername": "Nouveau nom d’utilisateur", + "OriginalPassword": "Mot de passe d’origine", + "NewPassword": "Nouveau mot de passe", + "EditDDNS": "Éditer le DNS dynamique", + "User": "Utilisateur", + "SiteName": "Nom du site", + "Language": "Langage", + "Theme": "Thème", + "Author": "Auteur", + "Community": "Communauté", + "Cancel": "Annuler", + "UserInvalid": "L’utilisateur est invalide", + "BlockByUser": "Bloqué par un administrateur", + "OnlineUser": "Utilisateur en ligne" } From ee6079fa582519408e358c26bcf6afe44a1883dc Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sat, 2 Aug 2025 09:53:17 +0200 Subject: [PATCH 118/220] Translated using Weblate (French) (#136) Currently translated at 48.6% (88 of 181 strings) Translate-URL: https://hosted.weblate.org/projects/nezha/admin-frontend/fr/ Translation: Nezha/Admin frontend Co-authored-by: TommyLPB --- src/locales/fr/translation.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 7c40fca..18b9a7e 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -91,5 +91,6 @@ "Cancel": "Annuler", "UserInvalid": "L’utilisateur est invalide", "BlockByUser": "Bloqué par un administrateur", - "OnlineUser": "Utilisateur en ligne" + "OnlineUser": "Utilisateur en ligne", + "UserId": "ID utilisateur" } From b6f62635e3e45994fc6b828072fac5b60f9cbdb7 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sat, 23 Aug 2025 15:07:02 +0200 Subject: [PATCH 119/220] Translated using Weblate (Chinese (Traditional Han script)) (#137) Currently translated at 100.0% (181 of 181 strings) Translate-URL: https://hosted.weblate.org/projects/nezha/admin-frontend/zh_Hant/ Translation: Nezha/Admin frontend Co-authored-by: TurboHK --- src/locales/zh-TW/translation.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index b6e11ad..9126b3e 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -182,5 +182,8 @@ "EmptyText": "空文字", "EmptyNote": "您沒有任何備註。", "OverrideDDNSDomains": "強制指定 DDNS 域名(每個配置)", - "EditServerConfig": "編輯伺服器配置" + "EditServerConfig": "編輯伺服器配置", + "Option": "選項", + "Value": "值", + "Preview": "預覽" } From 10a04a2daf1558c1a28b28dcee0ede576e529255 Mon Sep 17 00:00:00 2001 From: naiba Date: Sat, 23 Aug 2025 21:09:54 +0800 Subject: [PATCH 120/220] chore: add locales --- src/lib/i18n.ts | 8 ++++++++ src/types/settings.ts | 2 ++ 2 files changed, 10 insertions(+) diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 58b8422..8c0fac8 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -9,6 +9,8 @@ import ruTranslation from "../locales/ru/translation.json" import taTranslation from "../locales/ta/translation.json" import zhCNTranslation from "../locales/zh-CN/translation.json" import zhTWTranslation from "../locales/zh-TW/translation.json" +import frTranslation from "../locales/fr/translation.json" +import idTranslation from "../locales/id/translation.json" const resources = { "zh-CN": { @@ -35,6 +37,12 @@ const resources = { "it-IT": { translation: itTranslation, }, + "fr-FR": { + translation: frTranslation, + }, + "id-ID": { + translation: idTranslation, + }, } const getStoredLanguage = () => { diff --git a/src/types/settings.ts b/src/types/settings.ts index 6fa0dce..19dce00 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -14,6 +14,8 @@ export const nezhaLang: Record = { "de-DE": "Deutsch", "ta-IN": "தமிழ்", "it-IT": "Italiano", + "fr-FR": "Français", + "id-ID": "Bahasa Indonesia", } export const wafBlockReasons: Record = { From c27a13a67b46c0827414561cfdd7843c3030aa43 Mon Sep 17 00:00:00 2001 From: Chillln <8766520@gmail.com> Date: Thu, 2 Oct 2025 14:23:11 +0800 Subject: [PATCH 121/220] Fix and update (#139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(ui): 统一组件引用类型为ComponentRef 更新所有UI组件中的forwardRef类型,从ElementRef改为ComponentRef以保持一致性 迁移postcss配置至mjs格式并更新依赖版本 * refactor: 优化表单类型定义和验证逻辑 移除自定义的 asOptionalField 工具函数,直接使用 Zod 的 optional() 方法,并明确定义表单数据类型。 * style: 更新UI主题配置和样式变量 将主题风格从default切换为new-york,并重构CSS变量使用OKLCH色彩空间。同时添加tailwindcss-animate插件支持。 * style: 统一页面头部按钮组样式 优化多个页面头部按钮组的布局样式,增加响应式设计和flex-wrap支持 * fix(server): 修复对话框交互问题并优化SWR配置 修复对话框关闭逻辑并阻止外部交互,同时禁用SWR的自动重新验证功能以提升性能。 * feat: 添加日历组件及账单相关国际化 实现基于 react-day-picker 的日历组件,并添加账单管理相关的多语言支持 * style(components): 统一按钮样式并格式化代码 为删除和禁用按钮添加text-white类名,同时调整ServerCard组件中的代码缩进格式。 * perf(build): 优化Vite打包配置与代码分割策略 调整Vite构建配置,改进第三方依赖的分组逻辑并添加UUID支持到安装命令组件 * fix: 修正页面标题翻译不一致问题 将CronPage和ServicePage的标题从"Server"分别改为"Task"和"Service",并优化NotificationGroupPage的按钮组布局。 * fix(auth): 改进登录错误处理和国际化支持 优化登录错误提示,添加多语言支持并移除控制台错误日志。同时修复头部组件透明度样式问题。 * feat: 添加服务器操作下拉菜单 为服务器卡片添加统一的下拉菜单操作入口,整合终端、配置和安装命令功能。 * feat[alert-rule]: 优化告警规则组件性能 重构告警规则组件代码结构,提升渲染效率并减少内存占用。 * docs(i18n): 新增翻译字段 为界面添加"Add"、"Delete"、"AdvancedJSON"和"Save"等关键操作的翻译字段,支持中英文双语显示。 * perf(vite): 优化分包策略以提升构建性能 重构 manualChunks 逻辑,按功能类别分组依赖项,并增加大型库的独立分包规则。 * style: 统一危险操作按钮的文字颜色 在所有确认操作的弹窗按钮中添加白色文字样式,保持视觉一致性。 * fix(components): 调整下拉菜单对齐方式 根据菜单项状态动态设置下拉菜单的对齐方向和起始位置。 * fix(types): 修复在线用户API分页类型 添加ModelOnlineUserApi接口类型,包含分页信息,并移除index.ts中重复的类型定义。 * chore: auto-fix linting and formatting issues * feat(locales): 添加无过期相关翻译项 为英文和中文翻译文件添加"NoExpiry"、"SetNoExpiry"等无过期相关字段的翻译。 fix(components): 移除重复的图标按钮选项 从IconButton组件中删除重复的"more"图标选项。 * feat(ServerCard): 优化日期选择器并添加下拉提示 为日期选择器添加下拉布局和年份范围限制,并在公共笔记区域增加下拉项生效提示文本。 * chore: auto-fix linting and formatting issues * style: 优化多个组件的UI交互细节 统一按钮悬停样式并简化国际化文本调用,移除冗余的单位显示和空值判断逻辑。 * refactor(ServerCard): 移除网络路由相关代码 删除 ServerCard 组件中与 plan.networkRoute 相关的字段验证和错误显示逻辑。 * chore: auto-fix linting and formatting issues * feat(ui): 添加Switch组件并改进服务器表单交互 - 新增Radix UI Switch组件依赖及实现 - 将IPv4/IPv6输入改为开关控件,优化用户体验 - 添加"按量付费"选项和新的翻译字段 - 改进网络路由和备注输入的占位提示 - 修复暗黑模式下的按钮背景色 * style(components): 为禁止按钮添加白色文本样式 * chore: auto-fix linting and formatting issues * fix(ServerCard): 修复日期选择器样式和滚动问题 调整日期选择器的宽度和高度限制,添加滚动容器以解决内容溢出问题 * refactor(server-config): 简化复选框checked属性的布尔转换 使用!!操作符简化controllerField.value的布尔值转换,使代码更简洁 * feat(国际化): 添加告警规则和搜索框的国际化支持 为告警规则组件添加多语言支持,包括服务器监控选项、忽略提示和示例文本。同时将搜索框的占位文本替换为国际化字段。 * chore: auto-fix linting and formatting issues * fix(switch): 修正 Switch 组件 ref 类型定义错误 --------- Co-authored-by: Guccen <171530509+Chillln@users.noreply.github.com> --- bun.lockb | Bin 207761 -> 0 bytes components.json | 4 +- package.json | 104 +-- postcss.config.js | 6 - postcss.config.mjs | 5 + src/components/action-button-group.tsx | 14 +- src/components/alert-rule.tsx | 491 ++++++++++++- src/components/batch-move-server-icon.tsx | 19 +- src/components/cron.tsx | 23 +- src/components/ddns.tsx | 33 +- src/components/fm.tsx | 2 +- src/components/header-button-group.tsx | 16 +- src/components/header.tsx | 2 +- src/components/install-commands.tsx | 177 +++-- src/components/nat.tsx | 16 +- src/components/notifier.tsx | 26 +- src/components/profile.tsx | 6 +- src/components/server-config.tsx | 27 +- src/components/server.tsx | 712 ++++++++++++++++++- src/components/service.tsx | 8 +- src/components/terminal.tsx | 22 +- src/components/ui/alert-dialog.tsx | 12 +- src/components/ui/avatar.tsx | 6 +- src/components/ui/calendar.tsx | 184 +++++ src/components/ui/checkbox.tsx | 2 +- src/components/ui/command.tsx | 14 +- src/components/ui/dialog.tsx | 8 +- src/components/ui/drawer.tsx | 8 +- src/components/ui/dropdown-menu.tsx | 16 +- src/components/ui/form.tsx | 4 +- src/components/ui/label.tsx | 2 +- src/components/ui/navigation-menu.tsx | 12 +- src/components/ui/popover.tsx | 2 +- src/components/ui/scroll-area.tsx | 4 +- src/components/ui/select.tsx | 14 +- src/components/ui/separator.tsx | 2 +- src/components/ui/switch.tsx | 26 + src/components/ui/tabs.tsx | 6 +- src/components/xui/icon-button.tsx | 5 + src/components/xui/overlayless-sheet.tsx | 6 +- src/components/xui/virtulized-data-table.tsx | 2 +- src/hooks/useAuth.tsx | 10 +- src/index.css | 187 +++-- src/lib/i18n.ts | 4 +- src/locales/en/translation.json | 58 +- src/locales/zh-CN/translation.json | 58 +- src/routes/alert-rule.tsx | 2 +- src/routes/cron.tsx | 8 +- src/routes/ddns.tsx | 2 +- src/routes/nat.tsx | 2 +- src/routes/notification-group.tsx | 2 +- src/routes/notification.tsx | 2 +- src/routes/online-user.tsx | 4 +- src/routes/server-group.tsx | 2 +- src/routes/server.tsx | 135 ++-- src/routes/service.tsx | 8 +- src/routes/settings.tsx | 6 +- src/routes/user.tsx | 3 +- src/types/api.ts | 31 +- src/types/server.ts | 2 +- vite.config.ts | 86 ++- 61 files changed, 2214 insertions(+), 446 deletions(-) delete mode 100755 bun.lockb delete mode 100644 postcss.config.js create mode 100644 postcss.config.mjs create mode 100644 src/components/ui/calendar.tsx create mode 100644 src/components/ui/switch.tsx diff --git a/bun.lockb b/bun.lockb deleted file mode 100755 index ef833ddba4c9e5b7c6990ef83d29e719981cda4a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 207761 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 diff --git a/components.json b/components.json index 4879b60..3151684 100644 --- a/components.json +++ b/components.json @@ -10,11 +10,13 @@ "cssVariables": true, "prefix": "" }, + "iconLibrary": "lucide", "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" - } + }, + "registries": {} } diff --git a/package.json b/package.json index 4271765..0a57257 100644 --- a/package.json +++ b/package.json @@ -13,68 +13,72 @@ "preview": "vite preview" }, "dependencies": { - "@hookform/resolvers": "^3.10.0", - "@radix-ui/react-alert-dialog": "^1.1.5", - "@radix-ui/react-avatar": "^1.1.2", - "@radix-ui/react-checkbox": "^1.1.3", - "@radix-ui/react-dialog": "^1.1.5", - "@radix-ui/react-dropdown-menu": "^2.1.5", - "@radix-ui/react-label": "^2.1.1", - "@radix-ui/react-navigation-menu": "^1.2.4", - "@radix-ui/react-popover": "^1.1.5", - "@radix-ui/react-scroll-area": "^1.2.2", - "@radix-ui/react-select": "^2.1.5", - "@radix-ui/react-separator": "^1.1.1", - "@radix-ui/react-slot": "^1.1.1", - "@radix-ui/react-tabs": "^1.1.2", - "@tanstack/react-table": "^8.20.6", + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@tailwindcss/postcss": "^4.1.13", + "@tanstack/react-table": "^8.21.3", "@trivago/prettier-plugin-sort-imports": "^5.2.2", - "@types/luxon": "^3.4.2", + "@types/luxon": "^3.7.1", "@xterm/addon-attach": "^0.11.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "cmdk": "^1.0.4", + "cmdk": "^1.1.1", "copy-to-clipboard": "^3.3.3", - "framer-motion": "^11.18.2", - "i18next": "^24.2.2", - "i18next-browser-languagedetector": "^8.0.2", + "date-fns": "^4.1.0", + "framer-motion": "^12.23.22", + "i18next": "^25.5.2", + "i18next-browser-languagedetector": "^8.2.0", "jotai-zustand": "^0.6.0", - "lucide-react": "^0.454.0", - "luxon": "^3.5.0", + "lucide-react": "^0.544.0", + "luxon": "^3.7.2", "next-themes": "^0.4.6", - "prettier-plugin-tailwindcss": "^0.6.11", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-hook-form": "^7.54.2", - "react-i18next": "^15.4.0", - "react-router-dom": "^7.1.5", - "react-virtuoso": "^4.12.3", - "sonner": "^1.7.4", - "swr": "^2.3.0", - "tailwind-merge": "^2.6.0", + "prettier-plugin-tailwindcss": "^0.6.14", + "react": "^19.1.1", + "react-day-picker": "^9.11.0", + "react-dom": "^19.1.1", + "react-hook-form": "^7.63.0", + "react-i18next": "^16.0.0", + "react-router-dom": "^7.9.3", + "react-virtuoso": "^4.14.1", + "sonner": "^2.0.7", + "swr": "^2.3.6", + "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2", - "zod": "^3.24.1", - "zustand": "^5.0.3" + "zod": "^4.1.11", + "zustand": "^5.0.8" }, "devDependencies": { - "@eslint/js": "^9.19.0", - "@types/node": "^22.13.0", - "@types/react": "^18.3.18", - "@types/react-dom": "^18.3.5", - "@vitejs/plugin-react": "^4.3.4", - "autoprefixer": "^10.4.20", - "eslint": "^9.19.0", - "eslint-plugin-react-hooks": "^5.1.0", - "eslint-plugin-react-refresh": "^0.4.18", - "globals": "^15.14.0", - "postcss": "^8.5.1", - "swagger-typescript-api": "^13.0.23", - "tailwindcss": "^3.4.17", - "typescript": "~5.6.3", - "typescript-eslint": "^8.22.0", - "vite": "^6.0.11" + "@eslint/js": "^9.36.0", + "@types/node": "^24.5.2", + "@types/react": "^19.1.15", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.4", + "autoprefixer": "^10.4.21", + "eslint": "^9.36.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.22", + "globals": "^16.4.0", + "postcss": "^8.5.6", + "swagger-typescript-api": "^13.2.13", + "tailwindcss": "^4.1.13", + "typescript": "~5.9.2", + "typescript-eslint": "^8.44.1", + "vite": "^7.1.7" } } diff --git a/postcss.config.js b/postcss.config.js deleted file mode 100644 index d41ad63..0000000 --- a/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..521f73c --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +} diff --git a/src/components/action-button-group.tsx b/src/components/action-button-group.tsx index b1a5e98..09d794d 100644 --- a/src/components/action-button-group.tsx +++ b/src/components/action-button-group.tsx @@ -49,7 +49,7 @@ export function ActionButtonGroup({ {children} - + @@ -61,7 +61,10 @@ export function ActionButtonGroup({ {t("Close")} {t("Confirm")} @@ -95,7 +98,7 @@ export function BlockButtonGroup({ {children} - + @@ -107,7 +110,10 @@ export function BlockButtonGroup({ {t("Close")} {t("Confirm")} diff --git a/src/components/alert-rule.tsx b/src/components/alert-rule.tsx index e86e53d..468800d 100644 --- a/src/components/alert-rule.tsx +++ b/src/components/alert-rule.tsx @@ -32,11 +32,10 @@ import { import { IconButton } from "@/components/xui/icon-button" import { useNotification } from "@/hooks/useNotfication" import { conv } from "@/lib/utils" -import { asOptionalField } from "@/lib/utils" import { ModelAlertRule } from "@/types" import { triggerModes } from "@/types" import { zodResolver } from "@hookform/resolvers/zod" -import { useState } from "react" +import { useEffect, useState } from "react" import { useForm } from "react-hook-form" import { useTranslation } from "react-i18next" import { toast } from "sonner" @@ -53,16 +52,16 @@ interface AlertRuleCardProps { const ruleSchema = z.object({ type: z.string(), - min: asOptionalField(z.number()), - max: asOptionalField(z.number()), - cycle_start: asOptionalField(z.string()), - cycle_interval: asOptionalField(z.number()), - cycle_unit: asOptionalField(z.enum(["hour", "day", "week", "month", "year"])), - duration: asOptionalField(z.number()), + min: z.number().optional(), + max: z.number().optional(), + cycle_start: z.string().optional(), + cycle_interval: z.number().optional(), + cycle_unit: z.enum(["hour", "day", "week", "month", "year"]).optional(), + duration: z.number().optional(), cover: z.number().int().min(0), - ignore: asOptionalField(z.record(z.boolean())), - next_transfer_at: asOptionalField(z.record(z.string())), - last_cycle_status: asOptionalField(z.boolean()), + ignore: z.record(z.string(), z.boolean()).optional(), + next_transfer_at: z.record(z.string(), z.string()).optional(), + last_cycle_status: z.boolean().optional(), }) const alertRuleFormSchema = z.object({ @@ -87,13 +86,16 @@ const alertRuleFormSchema = z.object({ recover_trigger_tasks_raw: z.string(), notification_group_id: z.coerce.number().int(), trigger_mode: z.coerce.number().int().min(0), - enable: asOptionalField(z.boolean()), + enable: z.boolean().optional(), }) export const AlertRuleCard: React.FC = ({ data, mutate }) => { const { t } = useTranslation() - const form = useForm>({ - resolver: zodResolver(alertRuleFormSchema), + + type AlertRuleFormData = z.infer + + const form = useForm({ + resolver: zodResolver(alertRuleFormSchema) as any, defaultValues: data ? { ...data, @@ -119,7 +121,28 @@ export const AlertRuleCard: React.FC = ({ data, mutate }) => const [open, setOpen] = useState(false) - const onSubmit = async (values: z.infer) => { + // 结构化规则编辑状态:从已有数据或 rules_raw 初始化 + const initialRules = (() => { + try { + if (data?.rules) return data.rules as any[] + const raw = form.getValues("rules_raw") + return raw ? JSON.parse(raw) : [] + } catch { + return [] + } + })() + const [rulesUI, setRulesUI] = useState(initialRules) + + // 同步到 rules_raw(提交仍走 JSON 字符串) + useEffect(() => { + try { + form.setValue("rules_raw", JSON.stringify(rulesUI), { shouldDirty: true }) + } catch { + // ignore + } + }, [rulesUI]) + + const onSubmit = async (values: AlertRuleFormData) => { values.rules = JSON.parse(values.rules_raw) values.fail_trigger_tasks = conv.strToArr(values.fail_trigger_tasks_raw).map(Number) values.recover_trigger_tasks = conv.strToArr(values.recover_trigger_tasks_raw).map(Number) @@ -161,7 +184,10 @@ export const AlertRuleCard: React.FC = ({ data, mutate }) => - + = ({ data, mutate }) => )} /> - ( - - {t("Rules")} - - + = ({ server {t("Cancel")} - diff --git a/src/components/cron.tsx b/src/components/cron.tsx index df811f0..aa8680d 100644 --- a/src/components/cron.tsx +++ b/src/components/cron.tsx @@ -63,18 +63,31 @@ const cronFormSchema = z.object({ notification_group_id: z.coerce.number().int(), }) +type CronFormData = z.infer + export const CronCard: React.FC = ({ data, mutate }) => { const { t } = useTranslation() - const form = useForm>({ - resolver: zodResolver(cronFormSchema), + const form = useForm({ + resolver: zodResolver(cronFormSchema as any), defaultValues: data - ? data + ? { + task_type: data.task_type ?? 0, + name: data.name ?? "", + scheduler: data.scheduler ?? "", + command: (data as any).command ?? "", + servers: data.servers ?? [], + cover: data.cover ?? 0, + push_successful: (data as any).push_successful ?? false, + notification_group_id: data.notification_group_id ?? 0, + } : { - name: "", task_type: 0, + name: "", scheduler: "", + command: "", servers: [], cover: 0, + push_successful: false, notification_group_id: 0, }, resetOptions: { @@ -84,7 +97,7 @@ export const CronCard: React.FC = ({ data, mutate }) => { const [open, setOpen] = useState(false) - const onSubmit = async (values: z.infer) => { + const onSubmit = async (values: CronFormData) => { try { data?.id ? await updateCron(data.id, values) : await createCron(values) } catch (e) { diff --git a/src/components/ddns.tsx b/src/components/ddns.tsx index 3d56180..3bea2bd 100644 --- a/src/components/ddns.tsx +++ b/src/components/ddns.tsx @@ -67,21 +67,44 @@ const ddnsFormSchema = z.object({ webhook_headers: asOptionalField(z.string()), }) +type DDNSFormData = z.infer + export const DDNSCard: React.FC = ({ data, providers, mutate }) => { const { t } = useTranslation() - const form = useForm>({ - resolver: zodResolver(ddnsFormSchema), + const form = useForm({ + resolver: zodResolver(ddnsFormSchema as any), defaultValues: data ? { - ...data, - domains_raw: conv.arrToStr(data.domains), + max_retries: data.max_retries ?? 3, + enable_ipv4: (data as any).enable_ipv4 ?? false, + enable_ipv6: (data as any).enable_ipv6 ?? false, + name: data.name ?? "", + provider: data.provider ?? "dummy", + domains: data.domains ?? [], + domains_raw: conv.arrToStr(data.domains ?? []), + access_id: (data as any).access_id ?? "", + access_secret: (data as any).access_secret ?? "", + webhook_url: (data as any).webhook_url ?? "", + webhook_method: (data as any).webhook_method, + webhook_request_type: (data as any).webhook_request_type, + webhook_request_body: (data as any).webhook_request_body ?? "", + webhook_headers: (data as any).webhook_headers ?? "", } : { max_retries: 3, + enable_ipv4: false, + enable_ipv6: false, name: "", provider: "dummy", domains: [], domains_raw: "", + access_id: "", + access_secret: "", + webhook_url: "", + webhook_method: undefined, + webhook_request_type: undefined, + webhook_request_body: "", + webhook_headers: "", }, resetOptions: { keepDefaultValues: false, @@ -90,7 +113,7 @@ export const DDNSCard: React.FC = ({ data, providers, mutate }) = const [open, setOpen] = useState(false) - const onSubmit = async (values: z.infer) => { + const onSubmit = async (values: DDNSFormData) => { try { values.domains = conv.strToArr(values.domains_raw) data?.id ? await updateDDNSProfile(data.id, values) : await createDDNSProfile(values) diff --git a/src/components/fm.tsx b/src/components/fm.tsx index c9dc2d1..c1555b3 100644 --- a/src/components/fm.tsx +++ b/src/components/fm.tsx @@ -37,7 +37,7 @@ import { import { ColumnDef } from "@tanstack/react-table" import { Row, flexRender } from "@tanstack/react-table" import { File, Folder } from "lucide-react" -import { HTMLAttributes, useEffect, useRef, useState } from "react" +import { HTMLAttributes, JSX, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" diff --git a/src/components/header-button-group.tsx b/src/components/header-button-group.tsx index b60bd93..ca51997 100644 --- a/src/components/header-button-group.tsx +++ b/src/components/header-button-group.tsx @@ -51,6 +51,7 @@ export function HeaderButtonGroup({ { toast(t("Error"), { description: t("Results.NoRowsAreSelected"), @@ -63,7 +64,7 @@ export function HeaderButtonGroup({ <> - + @@ -75,7 +76,10 @@ export function HeaderButtonGroup({ {t("Close")} {t("Confirm")} @@ -114,6 +118,7 @@ export function HeaderBlockButtonGroup({ { toast(t("Error"), { description: t("Results.NoRowsAreSelected"), @@ -126,7 +131,7 @@ export function HeaderBlockButtonGroup({ <> - + @@ -138,7 +143,10 @@ export function HeaderBlockButtonGroup({ {t("Close")} {t("Confirm")} diff --git a/src/components/header.tsx b/src/components/header.tsx index 774ef5e..bbd6a68 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -403,7 +403,7 @@ function Overview() { {!profile &&

{t("LoginFirst")}

}

{t("CurrentTime")}

-

{timeString}

+

{timeString}

) diff --git a/src/components/install-commands.tsx b/src/components/install-commands.tsx index 4b9a432..3df734b 100644 --- a/src/components/install-commands.tsx +++ b/src/components/install-commands.tsx @@ -10,7 +10,7 @@ import useSettings from "@/hooks/useSetting" import { copyToClipboard } from "@/lib/utils" import { ModelProfile, ModelSetting } from "@/types" import i18next from "i18next" -import { Check, Clipboard } from "lucide-react" +import { Check, Copy, Download } from "lucide-react" import { forwardRef, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" @@ -21,82 +21,137 @@ enum OSTypes { Windows, } -export const InstallCommandsMenu = forwardRef((props, ref) => { - const [copy, setCopy] = useState(false) - const { data: settings } = useSettings() - const { profile } = useAuth() +type InstallCommandsMenuProps = ButtonProps & { + uuid?: string + iconOnly?: boolean + menuItem?: boolean +} - const { t } = useTranslation() +export const InstallCommandsMenu = forwardRef( + ({ uuid, iconOnly = false, menuItem = false, ...props }, ref) => { + const [copy, setCopy] = useState(false) + const { data: settings } = useSettings() + const { profile } = useAuth() - const switchState = async (type: number) => { - if (!copy) { - try { - setCopy(true) - if (!profile) throw new Error("Profile is not found.") - if (!settings?.config) throw new Error("Settings is not found.") - await copyToClipboard(generateCommand(type, settings!.config, profile) || "") - } catch (e: Error | any) { - console.error(e) - toast(t("Error"), { - description: e.message, - }) - } finally { - setTimeout(() => { - setCopy(false) - }, 2 * 1000) + const { t } = useTranslation() + + const switchState = async (type: number) => { + if (!copy) { + try { + setCopy(true) + if (!profile) throw new Error("Profile is not found.") + if (!settings?.config) throw new Error("Settings is not found.") + await copyToClipboard( + generateCommand(type, settings!.config, profile, uuid) || "", + ) + } catch (e: Error | any) { + console.error(e) + toast(t("Error"), { + description: e.message, + }) + } finally { + setTimeout(() => { + setCopy(false) + }, 2 * 1000) + } } } - } - return ( - - - - - - { - switchState(OSTypes.Linux) - }} + return ( + + + {menuItem ? ( + + ) : iconOnly ? ( + + ) : ( + + )} + + - Linux - - { - switchState(OSTypes.macOS) - }} - > - macOS - - { - switchState(OSTypes.Windows) - }} - > - Windows - - - - ) -}) + { + switchState(OSTypes.Linux) + }} + > + Linux + + { + switchState(OSTypes.macOS) + }} + > + macOS + + { + switchState(OSTypes.Windows) + }} + > + Windows + + + + ) + }, +) const generateCommand = ( type: number, { install_host, tls }: ModelSetting, { agent_secret }: ModelProfile, + uuid?: string, ) => { if (!install_host) throw new Error(i18next.t("Results.InstallHostRequired")) if (!agent_secret) throw new Error(i18next.t("Results.AgentSecretRequired")) - const env = `NZ_SERVER=${install_host} NZ_TLS=${tls || false} NZ_CLIENT_SECRET=${agent_secret}` - const env_win = `$env:NZ_SERVER=\"${install_host}\";$env:NZ_TLS=\"${tls || false}\";$env:NZ_CLIENT_SECRET=\"${agent_secret}\";` + const envParts = [ + `NZ_SERVER=${install_host}`, + `NZ_TLS=${tls || false}`, + `NZ_CLIENT_SECRET=${agent_secret}`, + ] + if (uuid) envParts.push(`NZ_UUID=${uuid}`) + const env = envParts.join(" ") + + const envWinParts = [ + `$env:NZ_SERVER=\"${install_host}\";`, + `$env:NZ_TLS=\"${tls || false}\";`, + `$env:NZ_CLIENT_SECRET=\"${agent_secret}\";`, + ] + if (uuid) envWinParts.push(`$env:NZ_UUID=\"${uuid}\";`) + const env_win = envWinParts.join("") switch (type) { case OSTypes.Linux: diff --git a/src/components/nat.tsx b/src/components/nat.tsx index f82677f..6bbab76 100644 --- a/src/components/nat.tsx +++ b/src/components/nat.tsx @@ -46,12 +46,20 @@ const natFormSchema = z.object({ domain: z.string(), }) +type NatFormData = z.infer + export const NATCard: React.FC = ({ data, mutate }) => { const { t } = useTranslation() - const form = useForm>({ - resolver: zodResolver(natFormSchema), + const form = useForm({ + resolver: zodResolver(natFormSchema as any), defaultValues: data - ? data + ? { + name: data.name ?? "", + enabled: (data as any).enabled ?? false, + server_id: data.server_id ?? 0, + host: data.host ?? "", + domain: data.domain ?? "", + } : { name: "", enabled: false, @@ -66,7 +74,7 @@ export const NATCard: React.FC = ({ data, mutate }) => { const [open, setOpen] = useState(false) - const onSubmit = async (values: z.infer) => { + const onSubmit = async (values: NatFormData) => { try { data?.id ? await updateNAT(data.id, values) : await createNAT(values) } catch (e) { diff --git a/src/components/notifier.tsx b/src/components/notifier.tsx index 2b60583..af3b93d 100644 --- a/src/components/notifier.tsx +++ b/src/components/notifier.tsx @@ -61,10 +61,21 @@ const notificationFormSchema = z.object({ export const NotifierCard: React.FC = ({ data, mutate }) => { const { t } = useTranslation() - const form = useForm>({ - resolver: zodResolver(notificationFormSchema), + type notificationFormData = z.infer + + const form = useForm({ + resolver: zodResolver(notificationFormSchema) as any, defaultValues: data - ? data + ? { + name: data.name ?? "", + url: data.url ?? "", + request_method: data.request_method ?? 1, + request_type: data.request_type ?? 1, + request_header: data.request_header ?? "", + request_body: data.request_body ?? "", + verify_tls: (data as any).verify_tls ?? false, + skip_check: (data as any).skip_check ?? false, + } : { name: "", url: "", @@ -72,6 +83,8 @@ export const NotifierCard: React.FC = ({ data, mutate }) => { request_type: 1, request_header: "", request_body: "", + verify_tls: false, + skip_check: false, }, resetOptions: { keepDefaultValues: false, @@ -80,7 +93,7 @@ export const NotifierCard: React.FC = ({ data, mutate }) => { const [open, setOpen] = useState(false) - const onSubmit = async (values: z.infer) => { + const onSubmit = async (values: notificationFormData) => { try { data?.id ? await updateNotification(data.id, values) : await createNotification(values) } catch (e) { @@ -110,7 +123,10 @@ export const NotifierCard: React.FC = ({ data, mutate }) => { - + { const { t } = useTranslation() const { profile, setProfile } = useMainStore() - const form = useForm>({ - resolver: zodResolver(profileFormSchema), + const form = useForm({ + resolver: zodResolver(profileFormSchema) as any, defaultValues: { original_password: "", new_password: "", @@ -57,7 +57,7 @@ export const ProfileCard = ({ className }: { className: string }) => { const [open, setOpen] = useState(false) - const onSubmit = async (values: z.infer) => { + const onSubmit = async (values: any) => { try { await updateProfile(values) } catch (e) { diff --git a/src/components/server-config.tsx b/src/components/server-config.tsx index 7ef9dc6..d0fd738 100644 --- a/src/components/server-config.tsx +++ b/src/components/server-config.tsx @@ -27,6 +27,7 @@ import { IconButton } from "@/components/xui/icon-button" import { asOptionalField } from "@/lib/utils" import { ModelServerTaskResponse } from "@/types" import { zodResolver } from "@hookform/resolvers/zod" +import { CogIcon } from "lucide-react" import { useEffect, useState } from "react" import { useForm } from "react-hook-form" import { useTranslation } from "react-i18next" @@ -58,7 +59,7 @@ const agentConfigSchema = z.object({ ), ), ip_report_period: asOptionalField(z.coerce.number().int().min(30)), - nic_allowlist: asOptionalField(z.record(z.boolean())), + nic_allowlist: asOptionalField(z.record(z.string(), z.boolean())), nic_allowlist_raw: asOptionalField( z.string().refine( (val) => { @@ -102,9 +103,10 @@ for (let i = 0; i < boolFields.length; i += 2) { interface ServerConfigCardProps extends ButtonProps { sid: number + menuItem?: boolean } -export const ServerConfigCard = ({ sid, ...props }: ServerConfigCardProps) => { +export const ServerConfigCard = ({ sid, menuItem = false, ...props }: ServerConfigCardProps) => { const { t } = useTranslation() const [data, setData] = useState(undefined) const [loading, setLoading] = useState(true) @@ -129,8 +131,8 @@ export const ServerConfigCard = ({ sid, ...props }: ServerConfigCardProps) => { if (open) fetchData() }, [open]) - const form = useForm({ - resolver: zodResolver(agentConfigSchema), + const form = useForm({ + resolver: zodResolver(agentConfigSchema) as any, defaultValues: { ...data, hard_drive_partition_allowlist_raw: JSON.stringify( @@ -155,7 +157,7 @@ export const ServerConfigCard = ({ sid, ...props }: ServerConfigCardProps) => { } }, [data, form]) - const onSubmit = async (values: AgentConfig) => { + const onSubmit = async (values: any) => { let resp: ModelServerTaskResponse = {} try { values.nic_allowlist = values.nic_allowlist_raw @@ -186,7 +188,18 @@ export const ServerConfigCard = ({ sid, ...props }: ServerConfigCardProps) => { return ( - + {menuItem ? ( + + ) : ( + + )} {loading ? ( @@ -283,7 +296,7 @@ export const ServerConfigCard = ({ sid, ...props }: ServerConfigCardProps) => {
{ @@ -69,8 +79,8 @@ const serverFormSchema = z.object({ export const ServerCard: React.FC = ({ data, mutate }) => { const { t } = useTranslation() - const form = useForm>({ - resolver: zodResolver(serverFormSchema), + const form = useForm({ + resolver: zodResolver(serverFormSchema) as any, defaultValues: { ...data, ddns_profiles_raw: data.ddns_profiles ? conv.arrToStr(data.ddns_profiles) : undefined, @@ -85,7 +95,131 @@ export const ServerCard: React.FC = ({ data, mutate }) => { const [open, setOpen] = useState(false) - const onSubmit = async (values: z.infer) => { + type PublicNote = { + billingDataMod: { + startDate: string + endDate: string + autoRenewal: string + cycle: string + amount: string + } + planDataMod: { + bandwidth: string + trafficVol: string + trafficType: string + IPv4: string + IPv6: string + networkRoute: string + extra: string + } + } + + const defaultPublicNote: PublicNote = { + billingDataMod: { + startDate: "", + endDate: "", + autoRenewal: "", + cycle: "", + amount: "", + }, + planDataMod: { + bandwidth: "", + trafficVol: "", + trafficType: "", + IPv4: "0", + IPv6: "0", + networkRoute: "", + extra: "", + }, + } + + const parsePublicNote = (s?: string): PublicNote => { + if (!s) return defaultPublicNote + try { + const obj = JSON.parse(s) + return { + billingDataMod: { + startDate: obj?.billingDataMod?.startDate ?? "", + endDate: obj?.billingDataMod?.endDate ?? "", + autoRenewal: obj?.billingDataMod?.autoRenewal ?? "", + cycle: obj?.billingDataMod?.cycle ?? "", + amount: obj?.billingDataMod?.amount ?? "", + }, + planDataMod: { + bandwidth: obj?.planDataMod?.bandwidth ?? "", + trafficVol: obj?.planDataMod?.trafficVol ?? "", + trafficType: obj?.planDataMod?.trafficType ?? "", + IPv4: obj?.planDataMod?.IPv4 === "1" ? "1" : "0", + IPv6: obj?.planDataMod?.IPv6 === "1" ? "1" : "0", + networkRoute: obj?.planDataMod?.networkRoute ?? "", + extra: obj?.planDataMod?.extra ?? "", + }, + } + } catch { + return defaultPublicNote + } + } + + const [publicNoteObj, setPublicNoteObj] = useState( + parsePublicNote(data?.public_note), + ) + const [publicNoteErrors, setPublicNoteErrors] = useState< + Partial< + Record< + | "billing.startDate" + | "billing.endDate" + | "billing.autoRenewal" + | "billing.cycle" + | "billing.amount" + | "plan.bandwidth" + | "plan.trafficVol" + | "plan.trafficType" + | "plan.IPv4" + | "plan.IPv6" + | "plan.extra", + string + > + > + >({}) + + const isValidISOLike = (v: string) => { + if (!v) return true + // special marker for "no expiry" + if (v === "0000-00-00T23:59:59+08:00") return true + const d = new Date(v) + return !isNaN(d.getTime()) + } + + const validatePublicNote = (pn: PublicNote) => { + const errs: Partial> = {} + + if (pn.billingDataMod.startDate && !isValidISOLike(pn.billingDataMod.startDate)) { + errs["billing.startDate"] = t("Validation.InvalidDate") + } + if (pn.billingDataMod.endDate && !isValidISOLike(pn.billingDataMod.endDate)) { + errs["billing.endDate"] = t("Validation.InvalidDate") + } + if (pn.billingDataMod.autoRenewal && !/^(0|1)$/.test(pn.billingDataMod.autoRenewal)) { + errs["billing.autoRenewal"] = t("Validation.MustBe0Or1") + } + if (pn.billingDataMod.cycle && !/^(Day|Week|Month|Year)$/i.test(pn.billingDataMod.cycle)) { + errs["billing.cycle"] = t("Validation.MustBeDayWeekMonthYear") + } + // amount 允许任意非空字符串或空 + if (pn.planDataMod.trafficType && !/^(1|2)$/.test(pn.planDataMod.trafficType)) { + errs["plan.trafficType"] = t("Validation.MustBe1Or2") + } + if (!/^(0|1)$/.test(pn.planDataMod.IPv4)) { + errs["plan.IPv4"] = t("Validation.MustBe0Or1") + } + if (!/^(0|1)$/.test(pn.planDataMod.IPv6)) { + errs["plan.IPv6"] = t("Validation.MustBe0Or1") + } + + return { errors: errs, valid: Object.keys(errs).length === 0 } + } + + const onSubmit = async (values: any) => { try { values.ddns_profiles = values.ddns_profiles_raw ? conv.strToArr(values.ddns_profiles_raw).map(Number) @@ -93,6 +227,36 @@ export const ServerCard: React.FC = ({ data, mutate }) => { values.override_ddns_domains = values.override_ddns_domains_raw ? JSON.parse(values.override_ddns_domains_raw) : undefined + + // validate structured fields + const { errors, valid } = validatePublicNote(publicNoteObj) + if (!valid) { + setPublicNoteErrors(errors) + toast(t("Error"), { description: t("Validation.InvalidForm") }) + return + } + setPublicNoteErrors({}) + + // normalize datetime-local to ISO string if provided + const normalizeISO = (v: string) => { + if (!v) return v + // keep special "no expiry" value as-is + if (v === "0000-00-00T23:59:59+08:00") return v + const date = new Date(v) + return isNaN(date.getTime()) ? v : date.toISOString() + } + const pnNormalized: PublicNote = { + billingDataMod: { + ...publicNoteObj.billingDataMod, + startDate: normalizeISO(publicNoteObj.billingDataMod.startDate), + endDate: normalizeISO(publicNoteObj.billingDataMod.endDate), + // keep others as-is + }, + planDataMod: { ...publicNoteObj.planDataMod }, + } + + // serialize structured public note back to JSON string + values.public_note = JSON.stringify(pnNormalized) await updateServer(data!.id!, values) } catch (e) { console.error(e) @@ -111,7 +275,11 @@ export const ServerCard: React.FC = ({ data, mutate }) => { - + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + >
@@ -236,19 +404,527 @@ export const ServerCard: React.FC = ({ data, mutate }) => { )} /> - ( - - {t("Public") + t("Note")} - -