Files
LokiStack/frontend/src/pages/hosts/components/HostFormDialog.tsx
T
logan 2d73661dd1 feat: complete Loki query, insights, settings and deployment workflows
Add the Loki query UI, access policies, ingress insights, alert and registry settings, and their backend APIs. Include the related Kubernetes deployment and collector updates required by the integrated application.

Validated with go test ./..., npm run build, and npm run lint.
2026-10-25 21:28:32 +08:00

319 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { memo, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Key, Lock } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { hostApi } from '@/api'
import { PasswordInput } from '@/components/PasswordInput'
import { toast } from 'sonner'
// 表单 schema:zod 为唯一校验源,消息中文且具体
// (port 在表单内为字符串、提交时转数字,避免 z.coerce 输入/输出类型分叉导致 RHF 泛型不兼容)
const hostFormSchema = z
.object({
name: z.string().min(1, '请输入主机名称'),
hostIP: z.string().min(1, '请输入主机 IP'),
port: z
.string()
.min(1, '请输入端口')
.regex(/^\d+$/, '端口必须为数字')
.refine((v) => Number(v) >= 1 && Number(v) <= 65535, '端口必须在 1-65535 之间'),
user: z.string().min(1, '请输入用户名'),
authType: z.enum(['password', 'key']),
password: z.string().optional(),
privateKey: z.string().optional(),
keyPhrase: z.string().optional(),
labels: z.string().optional(),
})
.superRefine((data, ctx) => {
if (EDITING_REF.current) return
if (data.authType === 'password' && !data.password?.trim()) {
ctx.addIssue({
code: 'custom',
path: ['password'],
message: '请填写 SSH 登录密码',
})
}
if (data.authType === 'key' && !data.privateKey?.trim()) {
ctx.addIssue({
code: 'custom',
path: ['privateKey'],
message: '请粘贴 SSH 私钥内容',
})
}
})
type HostFormValues = z.infer<typeof hostFormSchema>
const DEFAULT_VALUES: HostFormValues = {
name: '',
hostIP: '',
port: '22',
user: 'root',
authType: 'password',
password: '',
privateKey: '',
keyPhrase: '',
labels: '',
}
interface HostFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
/** 创建成功回调(父级刷新列表并关闭弹窗) */
onCreated: () => void /** 编辑目标(编辑模式:凭证留空=保留) */
editing?: import('@/types').Host | null
}
/** 添加 SSH 主机弹窗 — react-hook-form + zod,自包含表单逻辑,页面只传开关与回调 */
const EDITING_REF = { current: false as boolean | null }
export const HostFormDialog = memo(function HostFormDialog({
open,
onOpenChange,
onCreated,
editing,
}: HostFormDialogProps) {
const [submitting, setSubmitting] = useState(false)
const form = useForm<HostFormValues>({
resolver: zodResolver(hostFormSchema),
defaultValues: DEFAULT_VALUES,
})
const authType = form.watch('authType')
// 打开时重置(默认值),关闭/重开时机明确
EDITING_REF.current = !!editing
useEffect(() => {
if (open) {
form.reset(editing ? {
name: editing.name, hostIP: editing.hostIP, port: String(editing.port),
user: editing.user, authType: editing.authType,
password: '', privateKey: '', keyPhrase: '', labels: editing.labels || '',
} as never : DEFAULT_VALUES)
}
}, [open, form, editing])
const onSubmit = form.handleSubmit(async (values) => {
setSubmitting(true)
try {
await (editing
? hostApi.update(editing.id, {
name: values.name, hostIP: values.hostIP, port: Number(values.port),
user: values.user, authType: values.authType,
password: values.password || undefined,
privateKey: values.privateKey || undefined,
keyPhrase: values.keyPhrase || undefined,
labels: values.labels,
})
: hostApi.create({
name: values.name, hostIP: values.hostIP, port: Number(values.port),
user: values.user, authType: values.authType,
password: values.password, privateKey: values.privateKey,
keyPhrase: values.keyPhrase, labels: values.labels,
}))
onOpenChange(false)
onCreated()
} catch (err) {
toast.error(err instanceof Error ? err.message : '添加主机失败')
} finally {
setSubmitting(false)
}
})
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? `编辑主机${editing.name ? ` · ${editing.name}` : ''}` : '添加 SSH 主机'}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>名称 *</FormLabel>
<FormControl>
<Input placeholder="loki-server-1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="hostIP"
render={({ field }) => (
<FormItem>
<FormLabel>主机 IP *</FormLabel>
<FormControl>
<Input placeholder="192.168.1.100" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>端口</FormLabel>
<FormControl>
<Input type="number" placeholder="22" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="user"
render={({ field }) => (
<FormItem>
<FormLabel>用户名 *</FormLabel>
<FormControl>
<Input placeholder="root" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="authType"
render={({ field }) => (
<FormItem>
<FormLabel>认证方式</FormLabel>
<FormControl>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="authType"
value="password"
checked={field.value === 'password'}
onChange={() => field.onChange('password')}
/>
<Lock className="w-4 h-4" />
<span className="text-sm">密码</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="authType"
value="key"
checked={field.value === 'key'}
onChange={() => field.onChange('key')}
/>
<Key className="w-4 h-4" />
<span className="text-sm">SSH 密钥</span>
</label>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{authType === 'password' ? (
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>密码{editing ? '(留空保持不变)' : ' *'}</FormLabel>
<FormControl>
<PasswordInput placeholder="SSH 登录密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
) : (
<>
<FormField
control={form.control}
name="privateKey"
render={({ field }) => (
<FormItem>
<FormLabel>私钥{editing ? '(留空保持不变)' : ' *'}</FormLabel>
<FormControl>
<Textarea
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
className="min-h-[120px] font-mono text-xs"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="keyPhrase"
render={({ field }) => (
<FormItem>
<FormLabel>密钥口令(可选)</FormLabel>
<FormControl>
<PasswordInput placeholder="无口令则留空" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<FormField
control={form.control}
name="labels"
render={({ field }) => (
<FormItem>
<FormLabel>标签(JSON)</FormLabel>
<FormControl>
<Input placeholder='["loki", "logging"]' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full bg-primary hover:bg-primary/90"
disabled={submitting}
>
{submitting ? (editing ? '保存中...' : '添加中...') : editing ? '保存修改' : '添加主机'}
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
)
})