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,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} · {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 }
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user