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,74 @@
import { memo } from 'react'
import { useNavigate } from 'react-router'
import { Rocket } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { EmptyState } from '@/components/EmptyState'
import { StatusIcon } from '@/components/StatusBadge'
import { getStatusColorClass } from '@/components/status-config'
import { statusLabel } from '@/lib/utils'
import type { LokiDeployment } from '@/types'
/** 最近部署卡片 — 展示最近 5 条部署,点击跳转部署页 */
export const RecentDeploymentsCard = memo(function RecentDeploymentsCard({
deployments,
onViewAll,
}: {
deployments: LokiDeployment[]
onViewAll: () => void
}) {
const navigate = useNavigate()
return (
<Card className="border-border">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">最近部署</CardTitle>
<button
onClick={onViewAll}
className="text-xs text-primary hover:text-primary/80 font-medium"
>
查看全部
</button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{deployments.length === 0 ? (
<EmptyState
icon={Rocket}
title="暂无部署"
description="创建你的第一个 Loki 部署"
/>
) : (
deployments.map((d) => (
<div
key={d.id}
className="flex items-center justify-between p-3 rounded-lg bg-muted/50 hover:bg-muted dark:hover:bg-slate-800 transition-colors cursor-pointer"
onClick={() => navigate('/deployments')}
>
<div className="flex items-center gap-3 min-w-0">
<StatusIcon status={d.status} size="sm" />
<div className="min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{d.name}
</p>
<p className="text-xs text-muted-foreground">
{d.mode} &middot; {d.namespace || 'loki'}
</p>
</div>
</div>
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0 ${getStatusColorClass(
d.status
)}`}
>
{statusLabel(d.status)}
</span>
</div>
))
)}
</div>
</CardContent>
</Card>
)
})
@@ -0,0 +1,85 @@
import { memo } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { getStatusColorClass } from '@/components/status-config'
import { statusLabel } from '@/lib/utils'
// 用具体结构类型而非泛型:memo 包裹泛型组件会导致 TS 无法从 props 推断 T
interface ResourceItem {
id: number
name: string
status: string
hostIP?: string
}
interface ResourceListCardProps {
title: string
icon: React.ReactNode
items: ResourceItem[]
onManage: () => void
getItemIcon: (item: ResourceItem) => React.ReactNode
getItemLabel: (item: ResourceItem) => string
getItemStatus: (item: ResourceItem) => string
getItemExtra?: (item: ResourceItem) => string | undefined
}
/** 资源列表卡片 — 集群/主机快捷列表(仪表盘) */
export const ResourceListCard = memo(function ResourceListCard({
title,
icon,
items,
onManage,
getItemIcon,
getItemLabel,
getItemStatus,
getItemExtra,
}: ResourceListCardProps) {
return (
<Card className="border-border">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
{icon}
{title}
</CardTitle>
<button
onClick={onManage}
className="text-xs text-primary hover:text-primary/80 font-medium"
>
管理
</button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
{items.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">暂无{title}</p>
) : (
items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between p-2 rounded-md hover:bg-muted dark:hover:bg-slate-800 transition-colors"
>
<div className="flex items-center gap-2 min-w-0">
{getItemIcon(item)}
<span className="text-sm truncate">{getItemLabel(item)}</span>
{getItemExtra?.(item) && (
<span className="text-xs text-muted-foreground flex-shrink-0">
{getItemExtra(item)}
</span>
)}
</div>
<span
className={`px-2 py-0.5 rounded-full text-xs flex-shrink-0 ${getStatusColorClass(
getItemStatus(item)
)}`}
>
{statusLabel(getItemStatus(item))}
</span>
</div>
))
)}
</div>
</CardContent>
</Card>
)
})
@@ -0,0 +1,52 @@
import { memo } from 'react'
import type { LucideIcon } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
interface StatCardProps {
label: string
value: string | number
icon: LucideIcon
iconColor: string
iconBg: string
sub?: {
icon: LucideIcon
color: string
textColor: string
text: string
}
}
/** 指标卡 — 数字 + 图标 + 副指标(仪表盘通用件) */
export const StatCard = memo(function StatCard({
label,
value,
icon: Icon,
iconColor,
iconBg,
sub,
}: StatCardProps) {
const SubIcon = sub?.icon
return (
<Card className="border-border">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-3xl font-bold text-foreground mt-1 tabular-nums">
{value}
</p>
</div>
<div className={`w-12 h-12 rounded-xl ${iconBg} flex items-center justify-center`}>
<Icon className={`w-6 h-6 ${iconColor}`} />
</div>
</div>
{sub && SubIcon && (
<div className="flex items-center gap-2 mt-4">
<SubIcon className={`w-4 h-4 ${sub.color}`} />
<span className={`text-xs ${sub.textColor}`}>{sub.text}</span>
</div>
)}
</CardContent>
</Card>
)
})
@@ -0,0 +1,82 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { clusterApi, deploymentApi, hostApi } from '@/api'
import type { Cluster, DashboardStats, Host, LokiDeployment } from '@/types'
export interface DashboardData {
stats: DashboardStats
deployments: LokiDeployment[]
clusters: Cluster[]
hosts: Host[]
}
const REFRESH_INTERVAL = 10000 // 10 秒自动刷新
/**
* 仪表盘数据 hook — 轮询 + AbortController/版本号双重防护。
* - 页面隐藏(document.hidden)时暂停轮询,重新可见立即刷新
* - 首次加载失败暴露 error(由页面渲染 ErrorState,不再吞错)
*/
export function useDashboardStats(intervalMs: number = REFRESH_INTERVAL) {
const [data, setData] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const abortRef = useRef<AbortController | null>(null)
const versionRef = useRef(0)
const load = useCallback(async () => {
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
const version = ++versionRef.current
try {
const [stats, deployRes, clusterRes, hostRes] = await Promise.all([
deploymentApi.stats(),
deploymentApi.list({ page: 1, pageSize: 5 }, { signal: controller.signal }),
clusterApi.list({ page: 1, pageSize: 5 }, { signal: controller.signal }),
hostApi.list({ page: 1, pageSize: 5 }, { signal: controller.signal }),
])
if (version !== versionRef.current) return
setData({
stats,
deployments: deployRes.list,
clusters: clusterRes.list,
hosts: hostRes.list,
})
setError(null)
} catch (err) {
if (version !== versionRef.current) return
setError(err instanceof Error ? err.message : '加载仪表盘数据失败')
} finally {
if (version === versionRef.current) {
setLoading(false)
}
}
}, [])
useEffect(() => {
void load()
const intervalId = setInterval(() => {
if (!document.hidden) void load()
}, intervalMs)
// 重新可见时立即刷新一次
const onVisibilityChange = () => {
if (!document.hidden) void load()
}
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
clearInterval(intervalId)
document.removeEventListener('visibilitychange', onVisibilityChange)
// 写入而非读取:递增使在途响应按版本号判过期(exhaustive-deps 误报)
// eslint-disable-next-line react-hooks/exhaustive-deps
versionRef.current++
abortRef.current?.abort()
}
}, [load, intervalMs])
return { data, loading, error, refresh: load }
}
+183
View File
@@ -0,0 +1,183 @@
import { memo, useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router'
import { Server, Monitor, Activity, TrendingUp, CheckCircle, Rocket, Wand2 } from 'lucide-react'
import { Link } from 'react-router'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { PageContainer } from '@/components/PageContainer'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { ErrorState } from '@/components/ErrorState'
import { useDashboardStats } from './hooks/useDashboardStats'
import { StatCard } from './components/StatCard'
import { RecentDeploymentsCard } from './components/RecentDeploymentsCard'
import { ResourceListCard } from './components/ResourceListCard'
import type { DashboardStats } from '@/types'
const STATUS_ITEMS = [
{ key: 'running', label: '运行中', colorClass: 'text-primary' },
{ key: 'failed', label: '失败', colorClass: 'text-destructive' },
{ key: 'pending', label: '等待中', colorClass: 'text-warning' },
] as const
/**
* 仪表盘 — 指标卡 + 最近部署 + 资源快捷列表。
* 数据由 useDashboardStats 轮询(页面隐藏暂停);首次失败渲染错误态而非空数据。
*/
const DashboardPage = memo(function DashboardPage() {
const { data, loading, error, refresh } = useDashboardStats()
const navigate = useNavigate()
const navigateTo = useCallback(
(path: string) => () => navigate(path),
[navigate]
)
const stats = data?.stats ?? null
const statCards = useMemo(() => {
if (!stats) return null
const cards: Array<React.ReactNode> = [
<StatCard
key="deployments"
label="部署总数"
value={stats.totalDeployments}
icon={Rocket}
iconColor="text-primary"
iconBg="bg-success/10"
sub={{
icon: TrendingUp,
color: 'text-primary',
textColor: 'text-primary',
text: `${stats.running} 个运行中`,
}}
/>,
<StatCard
key="clusters"
label="集群"
value={stats.clusterCount}
icon={Server}
iconColor="text-info"
iconBg="bg-info/10"
sub={{
icon: CheckCircle,
color: 'text-primary',
textColor: 'text-muted-foreground',
text: '已接入',
}}
/>,
<StatCard
key="hosts"
label="主机"
value={stats.hostCount}
icon={Monitor}
iconColor="text-purple-600"
iconBg="bg-purple-100 dark:bg-purple-900/30"
sub={{
icon: CheckCircle,
color: 'text-primary',
textColor: 'text-muted-foreground',
text: 'SSH 就绪',
}}
/>,
]
return cards
}, [stats])
if (loading) {
return <LoadingSpinner className="h-64" />
}
if (error && !data) {
return <ErrorState message={error} onRetry={refresh} />
}
return (
<PageContainer>
{/* 快速开始引导:尚无任何部署时置顶(状态推导引导,opsx-sec 范式) */}
{(data?.stats.totalDeployments ?? 0) === 0 && (
<Card className="border-primary/30 bg-primary/5">
<CardContent className="p-5 flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<Wand2 className="w-5 h-5 text-primary" />
</div>
<div>
<p className="font-medium">三步开始收集日志</p>
<p className="text-xs text-muted-foreground mt-0.5">
环境与目标 → 存储 → 部署 Loki 与 Grafana,最后添加采集策略
</p>
</div>
</div>
<Button asChild className="bg-primary hover:bg-primary/90">
<Link to="/wizard">
进入部署向导
<Wand2 className="w-4 h-4 ml-1" />
</Link>
</Button>
</CardContent>
</Card>
)}
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{statCards}
{/* 状态概览卡 */}
<Card className="border-border">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">状态</p>
<div className="flex gap-3 mt-1">
{STATUS_ITEMS.map((item) => (
<div key={item.key} className="text-center">
<p className={`text-lg font-bold tabular-nums ${item.colorClass}`}>
{(stats?.[item.key as keyof DashboardStats] as number) || 0}
</p>
<p className="text-xs text-muted-foreground">{item.label}</p>
</div>
))}
</div>
</div>
<div className="w-12 h-12 rounded-xl bg-warning/10 flex items-center justify-center">
<Activity className="w-6 h-6 text-warning" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Recent Deployments & Quick Resources */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<RecentDeploymentsCard
deployments={data?.deployments ?? []}
onViewAll={navigateTo('/deployments')}
/>
<div className="space-y-4">
<ResourceListCard
title="集群"
icon={<Server className="w-4 h-4 text-info" />}
items={(data?.clusters ?? []).slice(0, 3)}
onManage={navigateTo('/clusters')}
getItemIcon={() => <Server className="w-4 h-4 text-info" />}
getItemLabel={(c) => c.name}
getItemStatus={(c) => c.status}
/>
<ResourceListCard
title="主机"
icon={<Monitor className="w-4 h-4 text-purple-500" />}
items={(data?.hosts ?? []).slice(0, 3)}
onManage={navigateTo('/hosts')}
getItemIcon={() => <Monitor className="w-4 h-4 text-purple-500" />}
getItemLabel={(h) => h.name}
getItemStatus={(h) => h.status}
getItemExtra={(h) => h.hostIP}
/>
</div>
</div>
</PageContainer>
)
})
export default DashboardPage