feat: server transfer rotation

This commit is contained in:
naiba committed 2026-05-25 10:15:24 +00:00
1 parent 142fc19469
commit b23a6c0be1
60 files changed
+4488 -1359

No files matched your search

+61 -47
View File
@@ -36,7 +36,7 @@ import { ModelAlertRule } from "@/types"
import { triggerModes } from "@/types"
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useState } from "react"
import { useForm } from "react-hook-form"
import { useForm, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { KeyedMutator } from "swr"
@@ -50,13 +50,15 @@ interface AlertRuleCardProps {
mutate: KeyedMutator<ModelAlertRule[]>
}
const cycleUnitSchema = z.enum(["hour", "day", "week", "month", "year"])
const ruleSchema = z.object({
type: z.string(),
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(),
cycle_unit: cycleUnitSchema.optional(),
duration: z.number().optional(),
cover: z.number().int().min(0),
ignore: z.record(z.string(), z.boolean()).optional(),
@@ -71,7 +73,7 @@ const alertRuleFormSchema = z.object({
try {
JSON.parse(val)
return true
} catch (e) {
} catch {
return false
}
},
@@ -92,28 +94,30 @@ const alertRuleFormSchema = z.object({
export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) => {
const { t } = useTranslation()
type AlertRuleFormData = z.infer<typeof alertRuleFormSchema>
type AlertRuleEntry = z.output<typeof ruleSchema>
type AlertRuleFormInput = z.input<typeof alertRuleFormSchema>
type AlertRuleFormData = z.output<typeof alertRuleFormSchema>
const form = useForm({
resolver: zodResolver(alertRuleFormSchema) as any,
const form = useForm<AlertRuleFormInput, unknown, AlertRuleFormData>({
resolver: zodResolver(alertRuleFormSchema),
defaultValues: data
? {
...data,
rules_raw: JSON.stringify(data.rules),
fail_trigger_tasks_raw: conv.arrToStr(data.fail_trigger_tasks),
recover_trigger_tasks_raw: conv.arrToStr(data.recover_trigger_tasks),
}
...data,
rules_raw: JSON.stringify(data.rules),
fail_trigger_tasks_raw: conv.arrToStr(data.fail_trigger_tasks),
recover_trigger_tasks_raw: conv.arrToStr(data.recover_trigger_tasks),
}
: {
name: "",
rules_raw: "",
rules: [],
fail_trigger_tasks: [],
fail_trigger_tasks_raw: "",
recover_trigger_tasks: [],
recover_trigger_tasks_raw: "",
notification_group_id: 0,
trigger_mode: 0,
},
name: "",
rules_raw: "",
rules: [],
fail_trigger_tasks: [],
fail_trigger_tasks_raw: "",
recover_trigger_tasks: [],
recover_trigger_tasks_raw: "",
notification_group_id: 0,
trigger_mode: 0,
},
resetOptions: {
keepDefaultValues: false,
},
@@ -124,14 +128,16 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
// 结构化规则编辑状态:从已有数据或 rules_raw 初始化
const initialRules = (() => {
try {
if (data?.rules) return data.rules as any[]
if (data?.rules) return data.rules
const raw = form.getValues("rules_raw")
return raw ? JSON.parse(raw) : []
if (!raw) return []
const parsed: unknown = JSON.parse(raw)
return z.array(ruleSchema).parse(parsed)
} catch {
return []
}
})()
const [rulesUI, setRulesUI] = useState<any[]>(initialRules)
const [rulesUI, setRulesUI] = useState<AlertRuleEntry[]>(initialRules)
// 同步到 rules_raw(提交仍走 JSON 字符串)
useEffect(() => {
@@ -140,17 +146,22 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
} catch {
// ignore
}
}, [rulesUI])
}, [form, rulesUI])
const rulesRaw = useWatch({ control: form.control, name: "rules_raw" })
const onSubmit = async (values: AlertRuleFormData) => {
values.rules = JSON.parse(values.rules_raw)
values.rules = z.array(ruleSchema).parse(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)
const { rules_raw, ...requiredFields } = values
const requiredFields = { ...values }
delete (requiredFields as Record<string, unknown>).rules_raw
try {
data?.id
? await updateAlertRule(data.id, requiredFields)
: await createAlertRule(requiredFields)
if (data?.id) {
await updateAlertRule(data.id, requiredFields)
} else {
await createAlertRule(requiredFields)
}
} catch (e) {
console.error(e)
toast(t("Error"), {
@@ -185,7 +196,7 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit as any)}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-2 my-2"
>
<FormField
@@ -358,9 +369,9 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
...next[idx],
min: e.target.value
? Number(
e.target
.value,
)
e.target
.value,
)
: undefined,
}
setRulesUI(next)
@@ -381,9 +392,9 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
...next[idx],
max: e.target.value
? Number(
e.target
.value,
)
e.target
.value,
)
: undefined,
}
setRulesUI(next)
@@ -445,8 +456,8 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
try {
return r.ignore
? JSON.stringify(
r.ignore,
)
r.ignore,
)
: ""
} catch {
return ""
@@ -457,8 +468,8 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
try {
const obj = e.target.value
? JSON.parse(
e.target.value,
)
e.target.value,
)
: undefined
next[idx] = {
...next[idx],
@@ -512,9 +523,9 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
cycle_interval: e.target
.value
? Number(
e.target
.value,
)
e.target
.value,
)
: undefined,
}
setRulesUI(next)
@@ -531,7 +542,10 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
const next = [...rulesUI]
next[idx] = {
...next[idx],
cycle_unit: val,
cycle_unit:
cycleUnitSchema.parse(
val,
),
}
setRulesUI(next)
}}
@@ -603,7 +617,7 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
<FormControl>
<Textarea
className="resize-y"
value={form.watch("rules_raw")}
value={rulesRaw}
onChange={(e) => {
// 同步到结构化编辑器
form.setValue("rules_raw", e.target.value, {
@@ -630,7 +644,7 @@ export const AlertRuleCard: React.FC<AlertRuleCardProps> = ({ data, mutate }) =>
placeholder={t("Search")}
options={ngroupList}
onValueChange={field.onChange}
defaultValue={field.value.toString()}
defaultValue={String(field.value ?? "")}
/>
</FormControl>
<FormMessage />
+26 -2
View File
@@ -33,8 +33,9 @@ export const BatchMoveServerIcon: React.FC<BatchMoveServerIconProps> = ({
const [toUserId, setToUserId] = useState<number | undefined>(undefined)
const onSubmit = async () => {
let results
try {
await batchMoveServer({
results = await batchMoveServer({
ids: serverIds,
to_user: toUserId!,
})
@@ -45,7 +46,30 @@ export const BatchMoveServerIcon: React.FC<BatchMoveServerIconProps> = ({
})
return
}
toast(t("Done"))
// The backend now responds per-server; render a structured summary
// instead of "Done" so the operator can see which ids hit a snag
// (DisableCommandExecute / already_transferring / etc). Pending ids
// remain async — watching /ws/transfer or the Transfers page will
// surface their terminal state.
const pending = results.filter((r) => r.status === "pending").length
const denied = results.filter((r) => r.status === "permission_denied").length
const dup = results.filter((r) => r.status === "already_transferring").length
const missing = results.filter((r) => r.status === "server_not_found").length
const same = results.filter((r) => r.status === "same_owner").length
const tooOld = results.filter((r) => r.status === "agent_too_old").length
const parts: string[] = []
if (pending) parts.push(t("Transfer.PendingCount", { count: pending }))
if (denied) parts.push(t("Transfer.PermissionDeniedCount", { count: denied }))
if (dup) parts.push(t("Transfer.AlreadyTransferringCount", { count: dup }))
if (missing) parts.push(t("Transfer.ServerNotFoundCount", { count: missing }))
if (same) parts.push(t("Transfer.SameOwnerCount", { count: same }))
if (tooOld) parts.push(t("Transfer.AgentTooOldCount", { count: tooOld }))
toast(t("Transfer.BatchSubmitted"), {
description: parts.join(" · ") || t("Done"),
})
setOpen(false)
}
+29 -24
View File
@@ -63,33 +63,34 @@ const cronFormSchema = z.object({
notification_group_id: z.coerce.number().int(),
})
type CronFormData = z.infer<typeof cronFormSchema>
type CronFormInput = z.input<typeof cronFormSchema>
type CronFormData = z.output<typeof cronFormSchema>
export const CronCard: React.FC<CronCardProps> = ({ data, mutate }) => {
const { t } = useTranslation()
const form = useForm<CronFormData>({
resolver: zodResolver(cronFormSchema as any),
const form = useForm<CronFormInput, unknown, CronFormData>({
resolver: zodResolver(cronFormSchema),
defaultValues: 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,
}
task_type: data.task_type ?? 0,
name: data.name ?? "",
scheduler: data.scheduler ?? "",
command: data.command ?? "",
servers: data.servers ?? [],
cover: data.cover ?? 0,
push_successful: data.push_successful ?? false,
notification_group_id: data.notification_group_id ?? 0,
}
: {
task_type: 0,
name: "",
scheduler: "",
command: "",
servers: [],
cover: 0,
push_successful: false,
notification_group_id: 0,
},
task_type: 0,
name: "",
scheduler: "",
command: "",
servers: [],
cover: 0,
push_successful: false,
notification_group_id: 0,
},
resetOptions: {
keepDefaultValues: false,
},
@@ -99,7 +100,11 @@ export const CronCard: React.FC<CronCardProps> = ({ data, mutate }) => {
const onSubmit = async (values: CronFormData) => {
try {
data?.id ? await updateCron(data.id, values) : await createCron(values)
if (data?.id) {
await updateCron(data.id, values)
} else {
await createCron(values)
}
} catch (e) {
console.error(e)
toast(t("Error"), {
@@ -267,7 +272,7 @@ export const CronCard: React.FC<CronCardProps> = ({ data, mutate }) => {
placeholder="Search..."
options={ngroupList}
onValueChange={field.onChange}
defaultValue={field.value.toString()}
defaultValue={String(field.value ?? "")}
/>
</FormControl>
<FormMessage />
@@ -282,7 +287,7 @@ export const CronCard: React.FC<CronCardProps> = ({ data, mutate }) => {
<FormControl>
<div className="flex items-center gap-2">
<Checkbox
checked={field.value}
checked={field.value === true}
onCheckedChange={field.onChange}
/>
<Label className="text-sm">
+58 -45
View File
@@ -67,45 +67,46 @@ const ddnsFormSchema = z.object({
webhook_headers: asOptionalField(z.string()),
})
type DDNSFormData = z.infer<typeof ddnsFormSchema>
type DDNSFormInput = z.input<typeof ddnsFormSchema>
type DDNSFormData = z.output<typeof ddnsFormSchema>
export const DDNSCard: React.FC<DDNSCardProps> = ({ data, providers, mutate }) => {
const { t } = useTranslation()
const form = useForm<DDNSFormData>({
resolver: zodResolver(ddnsFormSchema as any),
const form = useForm<DDNSFormInput, unknown, DDNSFormData>({
resolver: zodResolver(ddnsFormSchema),
defaultValues: data
? {
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: data.max_retries ?? 3,
enable_ipv4: data.enable_ipv4 ?? false,
enable_ipv6: data.enable_ipv6 ?? false,
name: data.name ?? "",
provider: data.provider ?? "dummy",
domains: data.domains ?? [],
domains_raw: conv.arrToStr(data.domains ?? []),
access_id: data.access_id ?? "",
access_secret: data.access_secret ?? "",
webhook_url: data.webhook_url ?? "",
webhook_method: data.webhook_method,
webhook_request_type: data.webhook_request_type,
webhook_request_body: data.webhook_request_body ?? "",
webhook_headers: data.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: "",
},
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,
},
@@ -116,7 +117,11 @@ export const DDNSCard: React.FC<DDNSCardProps> = ({ data, providers, mutate }) =
const onSubmit = async (values: DDNSFormData) => {
try {
values.domains = conv.strToArr(values.domains_raw)
data?.id ? await updateDDNSProfile(data.id, values) : await createDDNSProfile(values)
if (data?.id) {
await updateDDNSProfile(data.id, values)
} else {
await createDDNSProfile(values)
}
} catch (e) {
console.error(e)
toast(t("Error"), {
@@ -227,15 +232,23 @@ export const DDNSCard: React.FC<DDNSCardProps> = ({ data, providers, mutate }) =
<FormField
control={form.control}
name="max_retries"
render={({ field }) => (
<FormItem>
<FormLabel>{t("MaximumRetryAttempts")}</FormLabel>
<FormControl>
<Input type="number" placeholder="3" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
render={({ field }) => {
const { value, ...fieldProps } = field
return (
<FormItem>
<FormLabel>{t("MaximumRetryAttempts")}</FormLabel>
<FormControl>
<Input
type="number"
placeholder="3"
value={String(value ?? "")}
{...fieldProps}
/>
</FormControl>
<FormMessage />
</FormItem>
)
}}
/>
<FormField
control={form.control}
@@ -351,7 +364,7 @@ export const DDNSCard: React.FC<DDNSCardProps> = ({ data, providers, mutate }) =
<FormControl>
<div className="flex items-center gap-2">
<Checkbox
checked={field.value}
checked={field.value === true}
onCheckedChange={field.onChange}
/>
<Label className="text-sm">
@@ -371,7 +384,7 @@ export const DDNSCard: React.FC<DDNSCardProps> = ({ data, providers, mutate }) =
<FormControl>
<div className="flex items-center gap-2">
<Checkbox
checked={field.value}
checked={field.value === true}
onCheckedChange={field.onChange}
/>
<Label className="text-sm">
+83 -72
View File
@@ -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, JSX, useEffect, useRef, useState } from "react"
import { HTMLAttributes, JSX, useCallback, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@@ -59,6 +59,10 @@ interface FMProps {
wsUrl: string
}
type VirtualizedTableRowProps = HTMLAttributes<HTMLTableRowElement> & {
"data-index"?: number | string
}
const arraysEqual = (a: Uint8Array, b: Uint8Array) => {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
@@ -67,16 +71,12 @@ const arraysEqual = (a: Uint8Array, b: Uint8Array) => {
return true
}
const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl, ...props }) => {
export const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl, ...props }) => {
const { t } = useTranslation()
const fmRef = useRef<HTMLDivElement>(null)
const wsRef = useRef<WebSocket | null>(null)
useEffect(() => {
return () => {
wsRef.current?.close()
}
}, [])
const tRef = useRef(t)
tRef.current = t
const [dOpen, setdOpen] = useState(false)
const [uOpen, setuOpen] = useState(false)
@@ -120,9 +120,8 @@ const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl,
]
const tableRowComponent = (rows: Row<FMEntry>[]) =>
function getTableRow(props: HTMLAttributes<HTMLTableRowElement>) {
// @ts-expect-error data-index is a valid attribute
const index = props["data-index"]
function getTableRow(props: VirtualizedTableRowProps) {
const index = Number(props["data-index"])
const row = rows[index]
if (!row) return null
@@ -160,6 +159,65 @@ const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl,
}
}
useEffect(() => {
worker.onmessage = async (event: MessageEvent<FMWorkerData>) => {
switch (event.data.type) {
case FMWorkerOpcode.Error: {
console.error("Error from worker", event.data.error)
break
}
case FMWorkerOpcode.Progress: {
handleReady.current = true
break
}
case FMWorkerOpcode.Result: {
handleReady.current = false
if (event.data.blob && event.data.fileName) {
const url = URL.createObjectURL(event.data.blob)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = event.data.fileName
anchor.click()
URL.revokeObjectURL(url)
}
firstChunk.current = true
if (dOpen) setdOpen(false)
break
}
}
}
const handleBeforeUnload = () => {
worker.postMessage({
operation: 3,
})
}
window.addEventListener("beforeunload", handleBeforeUnload)
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload)
}
}, [dOpen])
const [currentPath, setPath] = useState("")
const currentPathRef = useRef(currentPath)
currentPathRef.current = currentPath
const listFile = useCallback(() => {
const prefix = new Int8Array([FMOpcode.List])
const pathMsg = new TextEncoder().encode(currentPathRef.current)
const msg = new Int8Array(prefix.length + pathMsg.length)
msg.set(prefix)
msg.set(pathMsg, prefix.length)
wsRef.current?.send(msg)
}, [])
// The WebSocket initialization must not depend on listFile or currentPath,
// otherwise navigating directories triggers a disconnect and reconnect.
useEffect(() => {
const url = new URL(wsUrl, window.location.origin)
url.protocol = url.protocol.replace("http", "ws")
@@ -174,8 +232,8 @@ const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl,
}
ws.onerror = (e) => {
console.error(e)
toast("Websocket" + " " + t("Error"), {
description: t("Results.UnExpectedError"),
toast("Websocket" + " " + tRef.current("Error"), {
description: tRef.current("Results.UnExpectedError"),
})
}
ws.onmessage = async (e: MessageEvent<ArrayBufferLike>) => {
@@ -186,7 +244,6 @@ const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl,
const errMsg = new TextDecoder("utf-8").decode(errBytes)
throw new Error(errMsg)
}
if (firstChunk.current) {
if (arraysEqual(identifier, FMIdentifier.file)) {
worker.postMessage({
@@ -204,7 +261,7 @@ const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl,
setuOpen(false)
listFile()
} else {
throw new Error(t("Results.UnknownIdentifier"))
throw new Error(tRef.current("Results.UnknownIdentifier"))
}
} else {
await waitForHandleReady()
@@ -216,74 +273,27 @@ const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl,
}
} catch (error) {
console.error("Error processing received data:", error)
toast("FM" + " " + t("Error"), {
description: t("Results.UnExpectedError"),
toast("FM" + " " + tRef.current("Error"), {
description: tRef.current("Results.UnExpectedError"),
})
setdOpen(false)
setuOpen(false)
}
}
}, [wsUrl])
useEffect(() => {
worker.onmessage = async (event: MessageEvent<FMWorkerData>) => {
switch (event.data.type) {
case FMWorkerOpcode.Error: {
console.error("Error from worker", event.data.error)
break
}
case FMWorkerOpcode.Progress: {
handleReady.current = true
break
}
case FMWorkerOpcode.Result: {
handleReady.current = false
if (event.data.blob && event.data.fileName) {
const url = URL.createObjectURL(event.data.blob)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = event.data.fileName
anchor.click()
URL.revokeObjectURL(url)
}
firstChunk.current = true
if (dOpen) setdOpen(false)
break
}
return () => {
ws.close()
if (wsRef.current === ws) {
wsRef.current = null
}
}
}, [listFile, wsUrl])
const handleBeforeUnload = () => {
worker.postMessage({
operation: 3,
})
}
window.addEventListener("beforeunload", handleBeforeUnload)
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload)
}
}, [worker, dOpen])
const [currentPath, setPath] = useState("")
useEffect(() => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
listFile()
}
}, [wsRef.current, currentPath])
const listFile = () => {
const prefix = new Int8Array([FMOpcode.List])
const pathMsg = new TextEncoder().encode(currentPath)
const msg = new Int8Array(prefix.length + pathMsg.length)
msg.set(prefix)
msg.set(pathMsg, prefix.length)
wsRef.current?.send(msg)
}
}, [currentPath, listFile])
const downloadFile = (basename: string) => {
currentBasename.current = basename
@@ -331,9 +341,10 @@ const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl,
onClick={async () => {
try {
await copyToClipboard(formatPath(currentPath))
} catch (error: any) {
} catch (error) {
const description = error instanceof Error ? error.message : t("Results.UnExpectedError")
toast("FM" + " " + t("Error"), {
description: error.message,
description,
})
console.error("copy error: ", error)
}
+11 -2
View File
@@ -49,6 +49,7 @@ const pages = [
{ href: "/dashboard/ddns", label: i18next.t("DDNS") },
{ href: "/dashboard/nat", label: i18next.t("NATT") },
{ href: "/dashboard/server-group", label: i18next.t("Group") },
{ href: "/dashboard/transfer", label: i18next.t("Transfer.Title") },
]
export default function Header() {
@@ -248,6 +249,15 @@ export default function Header() {
<Link to="/dashboard/server-group">{t("Group")}</Link>
</NzNavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NzNavigationMenuLink
asChild
active={location.pathname === "/dashboard/transfer"}
className={navigationMenuTriggerStyle()}
>
<Link to="/dashboard/transfer">{t("Transfer.Title")}</Link>
</NzNavigationMenuLink>
</NavigationMenuItem>
</>
)}
</div>
@@ -385,8 +395,7 @@ const useInterval = (callback: () => void, delay?: number | null) => {
function Overview() {
const { t } = useTranslation()
const profile = useMainStore((store) => store.profile)
const timeOption = DateTime.TIME_SIMPLE
timeOption.hour12 = true
const timeOption = { ...DateTime.TIME_SIMPLE, hour12: true }
const [timeString, setTimeString] = useState(
DateTime.now().setLocale("en-US").toLocaleString(timeOption),
)
+14 -14
View File
@@ -146,23 +146,23 @@ const generateCommand = (
const env = envParts.join(" ")
const envWinParts = [
`$env:NZ_SERVER=\"${install_host}\";`,
`$env:NZ_TLS=\"${tls || false}\";`,
`$env:NZ_CLIENT_SECRET=\"${agent_secret}\";`,
`$env:NZ_SERVER="${install_host}";`,
`$env:NZ_TLS="${tls || false}";`,
`$env:NZ_CLIENT_SECRET="${agent_secret}";`,
]
if (uuid) envWinParts.push(`$env:NZ_UUID=\"${uuid}\";`)
if (uuid) envWinParts.push(`$env:NZ_UUID="${uuid}";`)
const env_win = envWinParts.join("")
switch (type) {
case OSTypes.Linux:
case OSTypes.macOS: {
return `curl -L https://raw.githubusercontent.com/nezhahq/scripts/main/agent/install.sh -o agent.sh && chmod +x agent.sh && env ${env} ./agent.sh`
}
case OSTypes.Windows: {
return `${env_win} [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Ssl3 -bor [Net.SecurityProtocolType]::Tls -bor [Net.SecurityProtocolType]::Tls11 -bor [Net.SecurityProtocolType]::Tls12;set-ExecutionPolicy RemoteSigned;Invoke-WebRequest https://raw.githubusercontent.com/nezhahq/scripts/main/agent/install.ps1 -OutFile C:\install.ps1;powershell.exe C:\install.ps1`
}
default: {
throw new Error(`Unknown OS: ${type}`)
}
case OSTypes.Linux:
case OSTypes.macOS: {
return `curl -L https://raw.githubusercontent.com/nezhahq/scripts/main/agent/install.sh -o agent.sh && chmod +x agent.sh && env ${env} ./agent.sh`
}
case OSTypes.Windows: {
return `${env_win} [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Ssl3 -bor [Net.SecurityProtocolType]::Tls -bor [Net.SecurityProtocolType]::Tls11 -bor [Net.SecurityProtocolType]::Tls12;set-ExecutionPolicy RemoteSigned;Invoke-WebRequest https://raw.githubusercontent.com/nezhahq/scripts/main/agent/install.ps1 -OutFile C:\\install.ps1;powershell.exe C:\\install.ps1`
}
default: {
throw new Error(`Unknown OS: ${type}`)
}
}
}
+39 -26
View File
@@ -46,27 +46,28 @@ const natFormSchema = z.object({
domain: z.string(),
})
type NatFormData = z.infer<typeof natFormSchema>
type NatFormInput = z.input<typeof natFormSchema>
type NatFormData = z.output<typeof natFormSchema>
export const NATCard: React.FC<NATCardProps> = ({ data, mutate }) => {
const { t } = useTranslation()
const form = useForm<NatFormData>({
resolver: zodResolver(natFormSchema as any),
const form = useForm<NatFormInput, unknown, NatFormData>({
resolver: zodResolver(natFormSchema),
defaultValues: data
? {
name: data.name ?? "",
enabled: (data as any).enabled ?? false,
server_id: data.server_id ?? 0,
host: data.host ?? "",
domain: data.domain ?? "",
}
name: data.name ?? "",
enabled: data.enabled ?? false,
server_id: data.server_id ?? 0,
host: data.host ?? "",
domain: data.domain ?? "",
}
: {
name: "",
enabled: false,
server_id: 0,
host: "",
domain: "",
},
name: "",
enabled: false,
server_id: 0,
host: "",
domain: "",
},
resetOptions: {
keepDefaultValues: false,
},
@@ -76,7 +77,11 @@ export const NATCard: React.FC<NATCardProps> = ({ data, mutate }) => {
const onSubmit = async (values: NatFormData) => {
try {
data?.id ? await updateNAT(data.id, values) : await createNAT(values)
if (data?.id) {
await updateNAT(data.id, values)
} else {
await createNAT(values)
}
} catch (e) {
console.error(e)
toast(t("Error"), {
@@ -119,15 +124,23 @@ export const NATCard: React.FC<NATCardProps> = ({ data, mutate }) => {
<FormField
control={form.control}
name="server_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Server")} ID</FormLabel>
<FormControl>
<Input type="number" placeholder="1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
render={({ field }) => {
const { value, ...fieldProps } = field
return (
<FormItem>
<FormLabel>{t("Server")} ID</FormLabel>
<FormControl>
<Input
type="number"
placeholder="1"
value={String(value ?? "")}
{...fieldProps}
/>
</FormControl>
<FormMessage />
</FormItem>
)
}}
/>
<FormField
control={form.control}
@@ -169,7 +182,7 @@ export const NATCard: React.FC<NATCardProps> = ({ data, mutate }) => {
<FormControl>
<div className="flex items-center gap-2">
<Checkbox
checked={field.value}
checked={field.value === true}
onCheckedChange={field.onChange}
/>
<Label className="text-sm">{t("Enable")}</Label>
+11 -9
View File
@@ -48,13 +48,13 @@ export const NotificationGroupCard: React.FC<NotificationGroupCardProps> = ({ da
resolver: zodResolver(notificationGroupFormSchema),
defaultValues: data
? {
name: data.group.name,
notifications: data.notifications,
}
name: data.group.name,
notifications: data.notifications,
}
: {
name: "",
notifications: [],
},
name: "",
notifications: [],
},
resetOptions: {
keepDefaultValues: false,
},
@@ -64,9 +64,11 @@ export const NotificationGroupCard: React.FC<NotificationGroupCardProps> = ({ da
const onSubmit = async (values: z.infer<typeof notificationGroupFormSchema>) => {
try {
data?.group.id
? await updateNotificationGroup(data.group.id, values)
: await createNotificationGroup(values)
if (data?.group.id) {
await updateNotificationGroup(data.group.id, values)
} else {
await createNotificationGroup(values)
}
} catch (e) {
console.error(e)
toast(t("Error"), {
+36 -29
View File
@@ -62,33 +62,36 @@ const notificationFormSchema = z.object({
export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
const { t } = useTranslation()
type notificationFormData = z.infer<typeof notificationFormSchema>
type NotificationFormInput = z.input<typeof notificationFormSchema>
type NotificationFormData = z.output<typeof notificationFormSchema>
type NotificationDefaults = ModelNotification & Partial<Pick<NotificationFormData, "skip_check">>
const notificationDefaults: NotificationDefaults | undefined = data
const form = useForm({
resolver: zodResolver(notificationFormSchema) as any,
const form = useForm<NotificationFormInput, unknown, NotificationFormData>({
resolver: zodResolver(notificationFormSchema),
defaultValues: 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,
format_metric_units: (data as any).format_metric_units ?? false,
}
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.verify_tls ?? false,
skip_check: notificationDefaults?.skip_check ?? false,
format_metric_units: data.format_metric_units ?? false,
}
: {
name: "",
url: "",
request_method: 1,
request_type: 1,
request_header: "",
request_body: "",
verify_tls: false,
skip_check: false,
format_metric_units: false,
},
name: "",
url: "",
request_method: 1,
request_type: 1,
request_header: "",
request_body: "",
verify_tls: false,
skip_check: false,
format_metric_units: false,
},
resetOptions: {
keepDefaultValues: false,
},
@@ -96,9 +99,13 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
const [open, setOpen] = useState(false)
const onSubmit = async (values: notificationFormData) => {
const onSubmit = async (values: NotificationFormData) => {
try {
data?.id ? await updateNotification(data.id, values) : await createNotification(values)
if (data?.id) {
await updateNotification(data.id, values)
} else {
await createNotification(values)
}
} catch (e) {
console.error(e)
toast(t("Error"), {
@@ -127,7 +134,7 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit as any)}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-2 my-2"
>
<FormField
@@ -254,7 +261,7 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
<FormControl>
<div className="flex items-center gap-2">
<Checkbox
checked={field.value}
checked={field.value === true}
onCheckedChange={field.onChange}
/>
<Label className="text-sm">
@@ -274,7 +281,7 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
<FormControl>
<div className="flex items-center gap-2">
<Checkbox
checked={field.value}
checked={field.value === true}
onCheckedChange={field.onChange}
/>
<Label className="text-sm">
@@ -294,7 +301,7 @@ export const NotifierCard: React.FC<NotifierCardProps> = ({ data, mutate }) => {
<FormControl>
<div className="flex items-center gap-2">
<Checkbox
checked={field.value}
checked={field.value === true}
onCheckedChange={field.onChange}
/>
<Label className="text-sm">
+3 -3
View File
@@ -49,7 +49,7 @@ const agentConfigSchema = z.object({
try {
JSON.parse(val)
return true
} catch (e) {
} catch {
return false
}
},
@@ -66,7 +66,7 @@ const agentConfigSchema = z.object({
try {
JSON.parse(val)
return true
} catch (e) {
} catch {
return false
}
},
@@ -129,7 +129,7 @@ export const ServerConfigCard = ({ sid, menuItem = false, ...props }: ServerConf
}
}
if (open) fetchData()
}, [open])
}, [open, sid, t])
const form = useForm({
resolver: zodResolver(agentConfigSchema) as any,
+11 -9
View File
@@ -48,13 +48,13 @@ export const ServerGroupCard: React.FC<ServerGroupCardProps> = ({ data, mutate }
resolver: zodResolver(serverGroupFormSchema),
defaultValues: data
? {
name: data.group.name,
servers: data.servers,
}
name: data.group.name,
servers: data.servers,
}
: {
name: "",
servers: [],
},
name: "",
servers: [],
},
resetOptions: {
keepDefaultValues: false,
},
@@ -64,9 +64,11 @@ export const ServerGroupCard: React.FC<ServerGroupCardProps> = ({ data, mutate }
const onSubmit = async (values: z.infer<typeof serverGroupFormSchema>) => {
try {
data?.group.id
? await updateServerGroup(data.group.id, values)
: await createServerGroup(values)
if (data?.group.id) {
await updateServerGroup(data.group.id, values)
} else {
await createServerGroup(values)
}
} catch (e) {
console.error(e)
toast(t("Error"), {
+12 -11
View File
@@ -51,7 +51,7 @@ import { ModelServer } from "@/types"
import { zodResolver } from "@hookform/resolvers/zod"
import { HelpCircle } from "lucide-react"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { useForm, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { KeyedMutator } from "swr"
@@ -94,7 +94,7 @@ const serverFormSchema = z.object({
try {
JSON.parse(val)
return true
} catch (e) {
} catch {
return false
}
},
@@ -120,6 +120,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
keepDefaultValues: false,
},
})
const enableDDNS = useWatch({ control: form.control, name: "enable_ddns" })
const [open, setOpen] = useState(false)
@@ -278,7 +279,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
</FormItem>
)}
/>
{form.watch("enable_ddns") ? (
{enableDDNS ? (
<>
<FormField
control={form.control}
@@ -467,7 +468,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
?.startDate
? new Date(
publicNoteObj.billingDataMod!.startDate!,
).toLocaleDateString()
).toLocaleDateString()
: "YYYY-MM-DD"}
</Button>
</PopoverTrigger>
@@ -492,7 +493,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
?.startDate
? new Date(
publicNoteObj.billingDataMod!.startDate!,
)
)
: undefined
}
onSelect={(d) => {
@@ -559,17 +560,17 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
{publicNoteObj.billingDataMod
?.endDate
? publicNoteObj
.billingDataMod
?.endDate ===
.billingDataMod
?.endDate ===
"0000-00-00T23:59:59+08:00"
? t(
"PublicNote.NoExpiry",
)
"PublicNote.NoExpiry",
)
: new Date(
publicNoteObj
.billingDataMod
?.endDate as string,
).toLocaleDateString()
).toLocaleDateString()
: "YYYY-MM-DD"}
</Button>
</PopoverTrigger>
@@ -600,7 +601,7 @@ export const ServerCard: React.FC<ServerCardProps> = ({ data, mutate }) => {
publicNoteObj
.billingDataMod
?.endDate as string,
)
)
: undefined
}
onSelect={(d) => {
+28 -25
View File
@@ -80,28 +80,28 @@ export const ServiceCard: React.FC<ServiceCardProps> = ({ data, mutate }) => {
resolver: zodResolver(serviceFormSchema) as any,
defaultValues: data
? {
...data,
fail_trigger_tasks_raw: conv.arrToStr(data.fail_trigger_tasks),
recover_trigger_tasks_raw: conv.arrToStr(data.recover_trigger_tasks),
skip_servers_raw: conv.recordToStrArr(data.skip_servers ? data.skip_servers : {}),
}
...data,
fail_trigger_tasks_raw: conv.arrToStr(data.fail_trigger_tasks),
recover_trigger_tasks_raw: conv.arrToStr(data.recover_trigger_tasks),
skip_servers_raw: conv.recordToStrArr(data.skip_servers ? data.skip_servers : {}),
}
: {
type: 1,
cover: 0,
display_index: 0,
name: "",
target: "",
max_latency: 0.0,
min_latency: 0.0,
duration: 30,
notification_group_id: 0,
fail_trigger_tasks: [],
fail_trigger_tasks_raw: "",
recover_trigger_tasks: [],
recover_trigger_tasks_raw: "",
skip_servers: {},
skip_servers_raw: [],
},
type: 1,
cover: 0,
display_index: 0,
name: "",
target: "",
max_latency: 0.0,
min_latency: 0.0,
duration: 30,
notification_group_id: 0,
fail_trigger_tasks: [],
fail_trigger_tasks_raw: "",
recover_trigger_tasks: [],
recover_trigger_tasks_raw: "",
skip_servers: {},
skip_servers_raw: [],
},
resetOptions: {
keepDefaultValues: false,
},
@@ -113,11 +113,14 @@ export const ServiceCard: React.FC<ServiceCardProps> = ({ data, mutate }) => {
values.skip_servers = conv.arrToRecord(values.skip_servers_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)
const { skip_servers_raw, ...requiredFields } = values
const requiredFields = { ...values }
delete (requiredFields as Record<string, unknown>).skip_servers_raw
try {
data?.id
? await updateService(data.id, requiredFields)
: await createService(requiredFields)
if (data?.id) {
await updateService(data.id, requiredFields)
} else {
await createService(requiredFields)
}
} catch (e) {
console.error(e)
toast(t("Error"), {
+56 -47
View File
@@ -14,7 +14,15 @@ import { FitAddon } from "@xterm/addon-fit"
import { Terminal } from "@xterm/xterm"
import "@xterm/xterm/css/xterm.css"
import { Terminal as TerminalIcon } from "lucide-react"
import { JSX, forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"
import {
JSX,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { toast } from "sonner"
@@ -28,7 +36,7 @@ interface XtermProps {
setClose: React.Dispatch<React.SetStateAction<boolean>>
}
const XtermComponent = forwardRef<HTMLDivElement, XtermProps & JSX.IntrinsicElements["div"]>(
export const XtermComponent = forwardRef<HTMLDivElement, XtermProps & JSX.IntrinsicElements["div"]>(
({ wsUrl, setClose, ...props }, ref) => {
const terminalIdRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<Terminal | null>(null)
@@ -43,42 +51,10 @@ const XtermComponent = forwardRef<HTMLDivElement, XtermProps & JSX.IntrinsicElem
}
}, [])
useEffect(() => {
return () => {
wsRef.current?.close()
terminalRef.current?.dispose()
}
}, [])
useEffect(() => {
terminalRef.current = new Terminal({
cursorBlink: true,
fontSize: 16,
})
const url = new URL(wsUrl, window.location.origin)
url.protocol = url.protocol.replace("http", "ws")
const ws = new WebSocket(url)
wsRef.current = ws
ws.binaryType = "arraybuffer"
ws.onopen = () => {
onResize()
}
ws.onclose = () => {
terminalRef.current?.dispose()
setClose(true)
}
ws.onerror = (e) => {
console.error(e)
toast("Websocket error", {
description: "View console for details.",
})
}
}, [wsUrl])
const fitAddon = useRef(new FitAddon()).current
const sendResize = useRef(false)
const doResize = () => {
const doResize = useCallback(() => {
if (!terminalIdRef.current) return
fitAddon.fit()
@@ -100,9 +76,9 @@ const XtermComponent = forwardRef<HTMLDivElement, XtermProps & JSX.IntrinsicElem
wsRef.current?.send(msg)
}
}
}, [fitAddon])
const onResize = async () => {
const onResize = useCallback(async () => {
if (sendResize.current) return
sendResize.current = true
@@ -114,22 +90,55 @@ const XtermComponent = forwardRef<HTMLDivElement, XtermProps & JSX.IntrinsicElem
} finally {
sendResize.current = false
}
}
}, [doResize])
useEffect(() => {
if (!wsRef.current || !terminalIdRef.current || !terminalRef.current) return
const attachAddon = new AttachAddon(wsRef.current)
terminalRef.current.loadAddon(attachAddon)
terminalRef.current.loadAddon(fitAddon)
terminalRef.current.open(terminalIdRef.current)
const container = terminalIdRef.current
if (!container) return
const terminal = new Terminal({
cursorBlink: true,
fontSize: 16,
})
const url = new URL(wsUrl, window.location.origin)
url.protocol = url.protocol.replace("http", "ws")
const ws = new WebSocket(url)
ws.binaryType = "arraybuffer"
terminalRef.current = terminal
wsRef.current = ws
const attachAddon = new AttachAddon(ws)
terminal.loadAddon(attachAddon)
terminal.loadAddon(fitAddon)
terminal.open(container)
window.addEventListener("resize", onResize)
ws.onopen = () => {
onResize()
}
ws.onclose = () => {
terminal.dispose()
setClose(true)
}
ws.onerror = (e) => {
console.error(e)
toast("Websocket error", {
description: "View console for details.",
})
}
return () => {
window.removeEventListener("resize", onResize)
if (wsRef.current) {
wsRef.current.close()
}
ws.onopen = null
ws.onclose = null
ws.onerror = null
ws.close()
terminal.dispose()
if (wsRef.current === ws) wsRef.current = null
if (terminalRef.current === terminal) terminalRef.current = null
}
}, [wsRef.current, terminalRef.current, terminalIdRef.current])
}, [fitAddon, onResize, setClose, wsUrl])
return <div ref={terminalIdRef} {...props} />
},
+2 -2
View File
@@ -35,8 +35,8 @@ const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
+1 -1
View File
@@ -1,7 +1,7 @@
import { cn } from "@/lib/utils"
import { InputHTMLAttributes, forwardRef } from "react"
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {}
export type InputProps = InputHTMLAttributes<HTMLInputElement>
const Input = forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
return (
+54 -54
View File
@@ -53,60 +53,60 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>((props,
>
{(() => {
switch (props.icon) {
case "clipboard": {
return <Clipboard />
}
case "check": {
return <Check />
}
case "edit": {
return <Edit2 />
}
case "trash": {
return <Trash2 />
}
case "plus": {
return <Plus />
}
case "terminal": {
return <Terminal />
}
case "update": {
return <CircleArrowUp />
}
case "folder-closed": {
return <FolderClosed />
}
case "play": {
return <Play />
}
case "download": {
return <Download />
}
case "upload": {
return <Upload />
}
case "menu": {
return <Menu />
}
case "ban": {
return <BanIcon />
}
case "expand": {
return <Expand />
}
case "cog": {
return <CogIcon />
}
case "minus": {
return <Minus />
}
case "user-pen": {
return <UserPen />
}
case "more": {
return <MoreHorizontal />
}
case "clipboard": {
return <Clipboard />
}
case "check": {
return <Check />
}
case "edit": {
return <Edit2 />
}
case "trash": {
return <Trash2 />
}
case "plus": {
return <Plus />
}
case "terminal": {
return <Terminal />
}
case "update": {
return <CircleArrowUp />
}
case "folder-closed": {
return <FolderClosed />
}
case "play": {
return <Play />
}
case "download": {
return <Download />
}
case "upload": {
return <Upload />
}
case "menu": {
return <Menu />
}
case "ban": {
return <BanIcon />
}
case "expand": {
return <Expand />
}
case "cog": {
return <CogIcon />
}
case "minus": {
return <Minus />
}
case "user-pen": {
return <UserPen />
}
case "more": {
return <MoreHorizontal />
}
}
})()}
</Button>
-1
View File
@@ -140,7 +140,6 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
animation = 0,
maxCount = 3,
modalPopover = false,
asChild = false,
className,
...props
},
+3 -3
View File
@@ -158,9 +158,9 @@ export function DataTable<TData, TValue>({
{...{
style: header.column.getCanSort()
? {
cursor: "pointer",
userSelect: "none",
}
cursor: "pointer",
userSelect: "none",
}
: {},
onClick:
header.column.getToggleSortingHandler(),