feat: batch set server config (#114)

* feat: batch set server config

* make every field optional

* chore: auto-fix linting and formatting issues

* update

* [WIP] improve batch edit ux

* chore: auto-fix linting and formatting issues
This commit is contained in:
UUBulb
2025-02-04 11:19:52 +08:00
committed by GitHub
parent 38c3467106
commit dfe7d57ea4
10 changed files with 349 additions and 77 deletions
+104
View File
@@ -0,0 +1,104 @@
import { setServerConfig } from "@/api/server"
import { Button, ButtonProps } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Textarea } from "@/components/ui/textarea"
import { IconButton } from "@/components/xui/icon-button"
import { ModelServerTaskResponse } from "@/types"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { Pusher } from "./xui/pusher"
interface ServerConfigCardBatchProps extends ButtonProps {
sid: number[]
}
export const ServerConfigCardBatch: React.FC<ServerConfigCardBatchProps> = ({ sid, ...props }) => {
const { t } = useTranslation()
const [data, setData] = useState<Record<string, any>>({})
const [open, setOpen] = useState(false)
const [currentKey, setCurrentKey] = useState<string>("")
const [currentVal, setCurrentVal] = useState<string>("")
const onSubmit = async () => {
let resp: ModelServerTaskResponse = {}
try {
resp = await setServerConfig({ config: JSON.stringify(data), servers: sid })
} catch (e) {
console.error(e)
toast(t("Error"), {
description: t("Results.UnExpectedError"),
})
return
}
toast(t("Done"), {
description:
t("Results.ForceUpdate") +
(resp.success?.length ? t(`Success`) + ` [${resp.success.join(",")}]` : "") +
(resp.failure?.length ? t(`Failure`) + ` [${resp.failure.join(",")}]` : "") +
(resp.offline?.length ? t(`Offline`) + ` [${resp.offline.join(",")}]` : ""),
})
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<IconButton {...props} icon="cog" />
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<ScrollArea className="max-h-[calc(100dvh-5rem)] p-3">
<div className="items-center mx-1">
<DialogHeader>
<DialogTitle>{t("EditServerConfig")}</DialogTitle>
<DialogDescription />
</DialogHeader>
<div className="flex flex-col gap-3 mt-4">
<Label>Option</Label>
<Input
type="text"
placeholder="option"
value={currentKey}
onChange={(e) => {
setCurrentKey(e.target.value)
}}
/>
<Label>Value</Label>
<Textarea
className="resize-y"
value={currentVal}
onChange={(e) => {
setCurrentVal(e.target.value)
}}
/>
<Pusher property={[currentKey, currentVal]} setData={setData} />
<DialogFooter className="justify-end">
<DialogClose asChild>
<Button type="button" className="my-2" variant="secondary">
{t("Close")}
</Button>
</DialogClose>
<Button type="submit" className="my-2" onClick={onSubmit}>
{t("Submit")}
</Button>
</DialogFooter>
</div>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}
+35 -8
View File
@@ -1,5 +1,5 @@
import { getServerConfig, setServerConfig } from "@/api/server"
import { Button } from "@/components/ui/button"
import { Button, ButtonProps } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
@@ -25,6 +25,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"
import { Textarea } from "@/components/ui/textarea"
import { IconButton } from "@/components/xui/icon-button"
import { asOptionalField } from "@/lib/utils"
import { ModelServerTaskResponse } from "@/types"
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useState } from "react"
import { useForm } from "react-hook-form"
@@ -56,7 +57,7 @@ const agentConfigSchema = z.object({
},
),
),
ip_report_period: z.coerce.number().int().min(30),
ip_report_period: asOptionalField(z.coerce.number().int().min(30)),
nic_allowlist: asOptionalField(z.record(z.boolean())),
nic_allowlist_raw: asOptionalField(
z.string().refine(
@@ -73,7 +74,7 @@ const agentConfigSchema = z.object({
},
),
),
report_delay: z.coerce.number().int().min(1).max(4),
report_delay: asOptionalField(z.coerce.number().int().min(1).max(4)),
skip_connection_count: asOptionalField(z.boolean()),
skip_procs_count: asOptionalField(z.boolean()),
temperature: asOptionalField(z.boolean()),
@@ -99,7 +100,11 @@ for (let i = 0; i < boolFields.length; i += 2) {
groupedBoolFields.push(boolFields.slice(i, i + 2))
}
export const ServerConfigCard = ({ id }: { id: number }) => {
interface ServerConfigCardProps extends ButtonProps {
sid: number[]
}
export const ServerConfigCard = ({ sid, ...props }: ServerConfigCardProps) => {
const { t } = useTranslation()
const [data, setData] = useState<AgentConfig | undefined>(undefined)
const [loading, setLoading] = useState(true)
@@ -108,7 +113,11 @@ export const ServerConfigCard = ({ id }: { id: number }) => {
useEffect(() => {
const fetchData = async () => {
try {
const result = await getServerConfig(id)
if (sid.length > 1) {
setLoading(false)
return
}
const result = await getServerConfig(sid[0])
setData(JSON.parse(result))
} catch (error) {
console.error(error)
@@ -151,6 +160,7 @@ export const ServerConfigCard = ({ id }: { id: number }) => {
}, [data, form])
const onSubmit = async (values: AgentConfig) => {
let resp: ModelServerTaskResponse = {}
try {
values.nic_allowlist = values.nic_allowlist_raw
? JSON.parse(values.nic_allowlist_raw)
@@ -158,7 +168,7 @@ export const ServerConfigCard = ({ id }: { id: number }) => {
values.hard_drive_partition_allowlist = values.hard_drive_partition_allowlist_raw
? JSON.parse(values.hard_drive_partition_allowlist_raw)
: undefined
await setServerConfig(id, JSON.stringify(values))
resp = await setServerConfig({ config: JSON.stringify(values), servers: sid })
} catch (e) {
console.error(e)
toast(t("Error"), {
@@ -166,14 +176,31 @@ export const ServerConfigCard = ({ id }: { id: number }) => {
})
return
}
toast(t("Done"), {
description:
t("Results.ForceUpdate") +
(resp.success?.length ? t(`Success`) + ` [${resp.success.join(",")}]` : "") +
(resp.failure?.length ? t(`Failure`) + ` [${resp.failure.join(",")}]` : "") +
(resp.offline?.length ? t(`Offline`) + ` [${resp.offline.join(",")}]` : ""),
})
setOpen(false)
form.reset()
}
return (
return sid.length < 1 ? (
<IconButton
{...props}
icon="cog"
onClick={() => {
toast(t("Error"), {
description: t("Results.NoRowsAreSelected"),
})
}}
/>
) : (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<IconButton variant="outline" icon="cog" />
<IconButton {...props} icon="cog" />
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
{loading ? (
+5
View File
@@ -10,6 +10,7 @@ import {
Expand,
FolderClosed,
Menu,
Minus,
Play,
Plus,
Terminal,
@@ -35,6 +36,7 @@ export interface IconButtonProps extends ButtonProps {
| "ban"
| "expand"
| "cog"
| "minus"
}
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>((props, ref) => {
@@ -92,6 +94,9 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>((props,
case "cog": {
return <CogIcon />
}
case "minus": {
return <Minus />
}
}
})()}
</Button>
+50
View File
@@ -0,0 +1,50 @@
"use client"
import { useState } from "react"
import { Label } from "../ui/label"
import { Textarea } from "../ui/textarea"
import { IconButton } from "./icon-button"
interface PusherProps {
property: [string, string]
setData: React.Dispatch<React.SetStateAction<Record<string, any>>>
}
export const Pusher: React.FC<PusherProps> = ({ property, setData }) => {
const [cData, setCData] = useState<Record<string, any>>({})
return (
<div className="flex flex-col gap-3">
<div className="flex gap-2 ml-auto">
<IconButton
icon="plus"
onClick={() => {
const [k, v] = property
if (k && v) {
const temp = { ...cData }
temp[k] = JSON.parse(v)
setCData(temp)
setData(cData)
}
}}
/>
<IconButton
icon="minus"
variant="destructive"
onClick={() => {
const [k] = property
if (k) {
const temp = { ...cData }
temp[k] = undefined
setCData(temp)
setData(cData)
}
}}
/>
</div>
<Label>Preview</Label>
<Textarea value={JSON.stringify(cData, null, 2)} readOnly />
</div>
)
}