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 加密前缀保留(已加密凭证不受影响)。
This commit is contained in:
1 parent
327e282356
commit
f9c6c1d826
204 files changed
+14107
-9103
No files matched your search
@@ -0,0 +1,203 @@
|
||||
import { memo, useCallback, useEffect, useState } from 'react'
|
||||
import { Building2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { PasswordInput } from '@/components/PasswordInput'
|
||||
import { ldapApi, type LdapConfig, type LdapTestResult } from '@/api/ldap'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const EMPTY: LdapConfig = {
|
||||
enabled: false,
|
||||
host: '',
|
||||
port: 389,
|
||||
useSSL: false,
|
||||
bindDN: '',
|
||||
bindPassword: '',
|
||||
baseDN: '',
|
||||
userFilter: '(objectClass=inetOrgPerson)',
|
||||
usernameAttr: 'uid',
|
||||
nameAttr: 'displayName',
|
||||
emailAttr: 'mail',
|
||||
}
|
||||
|
||||
/**
|
||||
* LDAP 配置卡片 — 启用开关 + 表单 + 表单参数直测(未保存也可测);
|
||||
* 密码已保存时占位提示,留空提交即保留旧值。启用且配置齐全后,
|
||||
* 用户管理页才会出现「从 LDAP 导入」。
|
||||
*/
|
||||
export const LdapSection = memo(function LdapSection() {
|
||||
const [cfg, setCfg] = useState<LdapConfig>(EMPTY)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<LdapTestResult | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
ldapApi
|
||||
.getConfig()
|
||||
.then((c) => {
|
||||
setCfg({ ...EMPTY, ...c, bindPassword: '' })
|
||||
setLoaded(true)
|
||||
})
|
||||
.catch(() => toast.error('加载 LDAP 配置失败'))
|
||||
}, [])
|
||||
|
||||
const set = useCallback(<K extends keyof LdapConfig>(k: K, v: LdapConfig[K]) => {
|
||||
setCfg((prev) => ({ ...prev, [k]: v }))
|
||||
}, [])
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!cfg.host || !cfg.bindDN || !cfg.baseDN) {
|
||||
toast.error('请先填写服务器地址 / Bind DN / Base DN')
|
||||
return
|
||||
}
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
const result = await ldapApi.test(cfg)
|
||||
setTestResult(result)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '测试失败')
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const saved = await ldapApi.saveConfig(cfg)
|
||||
setCfg({ ...EMPTY, ...saved, bindPassword: '' })
|
||||
toast.success('LDAP 配置已保存')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-border">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4" />
|
||||
LDAP 接入
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">启用后支持 LDAP 登录与批量导入</span>
|
||||
<Switch checked={cfg.enabled} onCheckedChange={(v) => set('enabled', v)} />
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loaded && !cfg.enabled ? (
|
||||
<p className="text-sm text-muted-foreground py-2 text-center">
|
||||
LDAP 未启用 —— 打开开关填写目录服务信息(服务器 / Bind DN / Base DN),
|
||||
保存后用户管理页将出现「从 LDAP 导入」
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>服务器地址 *</Label>
|
||||
<Input
|
||||
placeholder="8.217.205.92"
|
||||
value={cfg.host}
|
||||
onChange={(e) => set('host', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label>端口</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={cfg.port || 389}
|
||||
onChange={(e) => set('port', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 flex flex-col">
|
||||
<Label>LDAPS</Label>
|
||||
<div className="pt-2">
|
||||
<Switch checked={cfg.useSSL} onCheckedChange={(v) => set('useSSL', v)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Bind DN *</Label>
|
||||
<Input
|
||||
placeholder="cn=admin,dc=example,dc=com"
|
||||
value={cfg.bindDN}
|
||||
onChange={(e) => set('bindDN', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Bind 密码 *</Label>
|
||||
<PasswordInput
|
||||
placeholder={cfg.hasPassword ? '已保存,输入新密码以修改' : '管理员密码'}
|
||||
value={cfg.bindPassword}
|
||||
onChange={(e) => set('bindPassword', e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Base DN *</Label>
|
||||
<Input
|
||||
placeholder="dc=example,dc=com"
|
||||
value={cfg.baseDN}
|
||||
onChange={(e) => set('baseDN', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>用户过滤器</Label>
|
||||
<Input value={cfg.userFilter} onChange={(e) => set('userFilter', e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>用户名</Label>
|
||||
<Input value={cfg.usernameAttr} onChange={(e) => set('usernameAttr', e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>姓名</Label>
|
||||
<Input value={cfg.nameAttr} onChange={(e) => set('nameAttr', e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>邮箱</Label>
|
||||
<Input value={cfg.emailAttr} onChange={(e) => set('emailAttr', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testResult && (
|
||||
<div
|
||||
role="status"
|
||||
className={`p-3 rounded-lg border text-sm ${
|
||||
testResult.success
|
||||
? 'bg-success/10 border-success/30 text-success'
|
||||
: 'bg-destructive/10 border-destructive/30 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{testResult.message}
|
||||
{testResult.userInfo && (
|
||||
<span className="block text-xs mt-1 opacity-80">
|
||||
{testResult.userInfo.displayName} · {testResult.userInfo.email || '无邮箱'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleTest} disabled={testing || !cfg.enabled}>
|
||||
{testing ? '测试中...' : '测试连接'}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !cfg.enabled}>
|
||||
{saving ? '保存中...' : '保存配置'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { KeyRound, Settings as SettingsIcon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { PasswordInput } from '@/components/PasswordInput'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { authApi } from '@/api'
|
||||
import { clearAuth } from '@/lib/auth-storage'
|
||||
import { toast } from 'sonner'
|
||||
import { PageContainer } from '@/components/PageContainer'
|
||||
import { LdapSection } from './components/LdapSection'
|
||||
|
||||
// 构建指纹由 vite define 注入;本地 tsc/预览时回退
|
||||
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
|
||||
const COMMIT_HASH = typeof __COMMIT_HASH__ !== 'undefined' ? __COMMIT_HASH__ : '-'
|
||||
|
||||
// 系统信息常量
|
||||
const SYSTEM_INFO = [
|
||||
{ label: '版本', value: `${APP_VERSION} (${COMMIT_HASH})` },
|
||||
{ label: '后端', value: 'Go + Gin + JSON 文件存储' },
|
||||
{ label: '前端', value: 'React 19 + TypeScript + Tailwind CSS' },
|
||||
{ label: '部署工具', value: 'kubectl + helm(镜像内置)' },
|
||||
{ label: '简介', value: '轻量级 K8s Loki 部署平台' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 系统设置 — 修改密码 + 关于。
|
||||
* 表单保持手写(字段少、校验已达标准);成功后清空表单即反馈(成功静默)。
|
||||
*/
|
||||
const SettingsPage = memo(function SettingsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [changing, setChanging] = useState(false)
|
||||
|
||||
const handleChangePassword = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
toast.error('新密码至少需要 6 位')
|
||||
return
|
||||
}
|
||||
setChanging(true)
|
||||
try {
|
||||
await authApi.changePassword(oldPassword, newPassword)
|
||||
// 改密后后端已吊销当前会话(TokenVersion+1),主动登出回登录页
|
||||
clearAuth()
|
||||
navigate('/login', { replace: true })
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '密码修改失败'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setChanging(false)
|
||||
}
|
||||
},
|
||||
[oldPassword, newPassword, confirmPassword, navigate]
|
||||
)
|
||||
|
||||
return (
|
||||
<PageContainer title="系统设置" description="修改登录密码、LDAP 接入与系统信息">
|
||||
<LdapSection />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 修改密码 */}
|
||||
<Card className="border-border">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<KeyRound className="w-4 h-4" />
|
||||
修改密码
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="old-pwd">当前密码</Label>
|
||||
<PasswordInput
|
||||
id="old-pwd"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
placeholder="请输入当前密码"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-pwd">新密码</Label>
|
||||
<PasswordInput
|
||||
id="new-pwd"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="请输入新密码(至少 6 位)"
|
||||
required
|
||||
minLength={6}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-pwd">确认新密码</Label>
|
||||
<PasswordInput
|
||||
id="confirm-pwd"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="请再次输入新密码"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-primary hover:bg-primary/90"
|
||||
disabled={changing}
|
||||
>
|
||||
{changing ? '修改中...' : '修改密码'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 关于 */}
|
||||
<Card className="border-border">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
关于 opsx-loki
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-0">
|
||||
{SYSTEM_INFO.map((item, index) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className={`flex justify-between py-2.5 ${
|
||||
index < SYSTEM_INFO.length - 1
|
||||
? 'border-b border-border'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm text-muted-foreground">{item.label}</span>
|
||||
<span className="text-sm font-medium">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageContainer>
|
||||
)
|
||||
})
|
||||
|
||||
export default SettingsPage
|
||||
Reference in new issue
Block a user