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:
logan committed 2026-10-24 13:20:12 +08:00
1 parent 327e282356
commit f9c6c1d826
204 files changed
+14107 -9103

No files matched your search

@@ -0,0 +1,179 @@
import { memo, useEffect, useState } from 'react'
import { Play, Square, RotateCw, Trash2, Terminal, ExternalLink } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { StatusBadge, StatusIcon } from '@/components/StatusBadge'
import { InfoRow } from '@/components/InfoRow'
import { deploymentApi } from '@/api'
import type { LokiDeployment } from '@/types'
/** 部署目标展示:优先集群,其次主机 */
const getTargetLabel = (d: LokiDeployment) => {
if (d.cluster) return `集群:${d.cluster.name}`
if (d.host) return `主机:${d.host.name}`
return '未知'
}
interface DeploymentCardProps {
deployment: LokiDeployment
/** 部署/重启进行中的部署 ID(按钮 loading 态) */
operating: number | null
onDeploy: (id: number) => void
onStop: (id: number) => void
onRestart: (id: number) => void
onDelete: (d: LokiDeployment) => void
onViewLogs: (d: LokiDeployment) => void
}
/** 部署卡片 — 列表项展示与操作入口(部署/停止/重启/日志/访问/删除) */
export const DeploymentCard = memo(function DeploymentCard({
deployment: d,
operating,
onDeploy,
onStop,
onRestart,
onDelete,
onViewLogs,
}: DeploymentCardProps) {
// Loki 就绪探测(运行中时经数据源代理 /ready)
const [ready, setReady] = useState<boolean | null>(null)
useEffect(() => {
if (d.status !== 'running') return
let cancelled = false
deploymentApi
.ready(d.id)
.then((r) => {
if (!cancelled) setReady(r.ready)
})
.catch(() => {
if (!cancelled) setReady(false)
})
return () => {
cancelled = true
}
}, [d.id, d.status])
const isDeploying = d.status === 'deploying'
const isBusy = operating === d.id
const canDeploy = d.status === 'pending' || d.status === 'failed' || d.status === 'stopped'
return (
<Card className="border-border hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
<StatusIcon status={d.status} size="md" />
<div className="min-w-0">
<h3 className="font-semibold text-foreground truncate">
{d.name}
</h3>
<p className="text-xs text-muted-foreground">{d.uuid}</p>
</div>
</div>
<StatusBadge status={d.status} />
{d.status === 'running' && ready !== null && (
<span
className={`w-2 h-2 rounded-full ${ready ? 'bg-success' : 'bg-destructive'}`}
title={ready ? 'Loki 就绪' : 'Loki 未就绪'}
/>
)}
</div>
<p className="text-sm text-muted-foreground mb-3">{d.description || '无描述'}</p>
<div className="space-y-1.5 text-xs text-muted-foreground mb-4">
<InfoRow label="方式" value={d.mode} />
<InfoRow label="版本" value={d.version} />
<InfoRow label="目标" value={getTargetLabel(d)} />
<InfoRow label="命名空间" value={d.namespace || 'loki'} />
<InfoRow label="副本数" value={String(d.replicas)} />
<InfoRow label="存储" value={d.storageConfig?.name || '本地'} />
{d.endpoint && (
<InfoRow label="访问地址" value={d.endpoint} valueClass="text-primary truncate max-w-[200px]" />
)}
{d.grafanaEnabled && d.grafanaEndpoint && (
<div className="flex justify-between items-center gap-2">
<span>Grafana</span>
<a
href={d.grafanaEndpoint}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary hover:underline truncate max-w-[200px]"
title={`${d.grafanaEndpoint}(admin/admin)`}
>
{d.grafanaEndpoint.replace(/^https?:\/\//, '')}
</a>
</div>
)}
{d.statusMessage && (
<div className="p-2 bg-muted rounded text-xs">
{d.statusMessage}
</div>
)}
</div>
<div className="flex gap-1 pt-3 border-t border-border">
{canDeploy ? (
<Button
variant="outline"
size="sm"
className="flex-1 text-xs bg-success/10 text-primary hover:bg-success/20 border-success/30"
onClick={() => onDeploy(d.id)}
disabled={isBusy}
>
<Play className="w-3 h-3 mr-1" />
{isBusy ? '部署中...' : '部署'}
</Button>
) : isDeploying ? (
<Button variant="outline" size="sm" className="flex-1 text-xs" disabled>
<RotateCw className="w-3 h-3 mr-1 animate-spin" />
部署中...
</Button>
) : (
<>
<Button variant="outline" size="sm" className="text-xs" onClick={() => onStop(d.id)}>
<Square className="w-3 h-3 mr-1" />
停止
</Button>
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => onRestart(d.id)}
disabled={isBusy}
>
<RotateCw className={`w-3 h-3 mr-1 ${isBusy ? 'animate-spin' : ''}`} />
重启
</Button>
</>
)}
<Button variant="outline" size="sm" className="text-xs" onClick={() => onViewLogs(d)}>
<Terminal className="w-3 h-3 mr-1" />
日志
</Button>
{d.endpoint && (
<Button
variant="outline"
size="sm"
className="text-xs text-primary hover:text-primary/80"
onClick={() => window.open(d.endpoint, '_blank', 'noopener,noreferrer')}
title="浏览器访问"
>
<ExternalLink className="w-3 h-3 mr-1" />
访问
</Button>
)}
<Button
variant="outline"
size="sm"
aria-label={`删除部署 ${d.name}`}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onDelete(d)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</CardContent>
</Card>
)
})