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.
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
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>
|
||
)
|
||
})
|