Files
LokiStack/frontend/src/pages/hosts/components/HostFormDialog.tsx
T
logan f9c6c1d826 feat: opsx-loki 全面升级 — 前端重构/用户与LDAP/部署向导/日志查询
基于 WEB-UI-Development-Guide 标准的整体升级,并更名 LokiDeploy → opsx-loki。

前端(Gin+React+shadcn/ui):
- 按 02/07 章重构:页面域化拆分(无超 400 行文件)、PageContainer/ListQuery、
  反馈分级(成功静默/失败 toast/危险确认)、401 单飞刷新+队列重放
- 暗色模式(next-themes)+ 语义 Token(--primary 品牌色/success/warning/info)
- 部署向导(状态推导四步:环境→存储→Loki→采集,内嵌表单闭环)
- 日志查询页:LogQL 标签构建器/拖拽直方图/日志上下文/Live Tail/查询历史
- 按用户+标签策略的查询授权(服务端强制合并策略)

后端(Go+Gin+JSON DB):
- 简单用户管理(仅 admin,无 RBAC)+ TokenVersion 会话吊销 + 登录 IP 限流
- LDAP 接入:配置(AES 加密存储/hasPassword 脱敏)/测试连接/批量导入/登录
- 存储配置测试连接(SigV4 零依赖)+ 桶不存在自动创建
- Loki 三模式部署修复:SSH 密钥认证、3.x 配置模板、ruler 对象存储嵌套块、
  凭证解密、孤儿 deploying 状态恢复、Grafana 部署(可选默认关)+面板导入
- DaemonSet 采集纯 YAML(去 helm 依赖)+ DNS 自愈回退 ClusterIP + 集群名标签
- 日志查询代理:cluster-svc 经 kube-apiserver proxy/url 直连 + cursor 翻页
- 统一 log 命名空间;部署就绪探活;Grafana 访问地址自动记录

修复:run.sh 增量编译检测、Sanitized 脱敏补全、LokiDeployment Update 白名单、
健康检查 CreatedAt 零值等 10+ 存量 bug。

数据兼容:DB 沿用旧 lokideploy.json(新名优先);localStorage 一次性迁移;
HKDF 加密前缀保留(已加密凭证不受影响)。
2026-10-24 13:20:12 +08:00

305 lines
9.8 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 (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
}
/** 添加 SSH 主机弹窗 — react-hook-form + zod,自包含表单逻辑,页面只传开关与回调 */
export const HostFormDialog = memo(function HostFormDialog({
open,
onOpenChange,
onCreated,
}: HostFormDialogProps) {
const [submitting, setSubmitting] = useState(false)
const form = useForm<HostFormValues>({
resolver: zodResolver(hostFormSchema),
defaultValues: DEFAULT_VALUES,
})
const authType = form.watch('authType')
// 打开时重置(默认值),关闭/重开时机明确
useEffect(() => {
if (open) {
form.reset(DEFAULT_VALUES)
}
}, [open, form])
const onSubmit = form.handleSubmit(async (values) => {
setSubmitting(true)
try {
await hostApi.create({
name: values.name,
hostIP: values.hostIP,
port: Number(values.port),
user: values.user,
authType: values.authType,
password: values.authType === 'password' ? values.password : undefined,
privateKey: values.authType === 'key' ? values.privateKey : undefined,
keyPhrase: values.authType === 'key' ? values.keyPhrase : undefined,
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>添加 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>密码 *</FormLabel>
<FormControl>
<PasswordInput placeholder="SSH 登录密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
) : (
<>
<FormField
control={form.control}
name="privateKey"
render={({ field }) => (
<FormItem>
<FormLabel>私钥 *</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 ? '添加中...' : '添加主机'}
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
)
})