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>
)
})
@@ -0,0 +1,442 @@
import { memo, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { deploymentApi } from '@/api'
import { statusLabel } from '@/lib/utils'
import { toast } from 'sonner'
import type { Cluster, Host, StorageConfig } from '@/types'
import {
FALLBACK_VERSIONS,
isK8sMode,
deploymentFormSchema,
DEFAULT_VALUES,
type DeploymentFormValues,
} from '../form-schema'
interface DeploymentFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
/** 创建成功回调(父级刷新列表并回到第 1 页) */
onCreated: () => void
clusters: Cluster[]
hosts: Host[]
storages: StorageConfig[]
}
/** 创建 Loki 部署弹窗 — react-hook-form + zod;部署方式切换条件显示集群/主机选择 */
export const DeploymentFormDialog = memo(function DeploymentFormDialog({
open,
onOpenChange,
onCreated,
clusters,
hosts,
storages,
}: DeploymentFormDialogProps) {
const [submitting, setSubmitting] = useState(false)
// 版本列表来自后端 LokiVersionList,失败回退兜底
const [versions, setVersions] = useState<string[]>(FALLBACK_VERSIONS)
useEffect(() => {
let cancelled = false
deploymentApi
.versions()
.then((res) => {
if (!cancelled && res.versions?.length) setVersions(res.versions)
})
.catch(() => {
// 静默:保留兜底列表
})
return () => {
cancelled = true
}
}, [])
const form = useForm<DeploymentFormValues>({
resolver: zodResolver(deploymentFormSchema),
defaultValues: DEFAULT_VALUES,
})
const mode = form.watch('mode')
const grafanaEnabled = form.watch('grafanaEnabled')
// 打开时重置(关闭重置时机明确)
useEffect(() => {
if (open) {
form.reset(DEFAULT_VALUES)
}
}, [open, form])
const onSubmit = form.handleSubmit(async (values) => {
setSubmitting(true)
try {
await deploymentApi.create({
name: values.name,
description: values.description,
version: values.version,
mode: values.mode,
clusterID: values.clusterID ? Number(values.clusterID) : undefined,
hostID: values.hostID ? Number(values.hostID) : undefined,
namespace: values.namespace,
storageConfigID: values.storageConfigID ? Number(values.storageConfigID) : 0,
authEnabled: values.authEnabled,
retentionDays: Number(values.retentionDays),
replicas: Number(values.replicas),
resourcesCPU: values.resourcesCPU,
resourcesMem: values.resourcesMem,
storageSize: values.storageSize,
grafanaEnabled: values.grafanaEnabled,
grafanaVersion: values.grafanaEnabled ? values.grafanaVersion || undefined : undefined,
})
onOpenChange(false)
onCreated()
} catch (err) {
toast.error(err instanceof Error ? err.message : '创建部署失败')
} finally {
setSubmitting(false)
}
})
const showK8s = isK8sMode(mode)
const showHost = mode === 'host-binary'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>创建 Loki 部署</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>部署名称 *</FormLabel>
<FormControl>
<Input placeholder="production-loki" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>描述</FormLabel>
<FormControl>
<Textarea placeholder="生产环境 Loki 日志服务" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="version"
render={({ field }) => (
<FormItem>
<FormLabel>版本</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{versions.map((v) => (
<SelectItem key={v} value={v}>
{v}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>部署方式</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="k8s-helm">K8s Helm 部署</SelectItem>
<SelectItem value="k8s-yaml">K8s YAML 部署</SelectItem>
<SelectItem value="host-binary">主机二进制部署</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{showK8s && (
<>
<FormField
control={form.control}
name="clusterID"
render={({ field }) => (
<FormItem>
<FormLabel>目标集群 *</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="请选择集群" />
</SelectTrigger>
<SelectContent>
{clusters.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.name}({statusLabel(c.status)})
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="namespace"
render={({ field }) => (
<FormItem>
<FormLabel>命名空间 *</FormLabel>
<FormControl>
<Input placeholder="loki" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{showHost && (
<FormField
control={form.control}
name="hostID"
render={({ field }) => (
<FormItem>
<FormLabel>目标主机 *</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="请选择主机" />
</SelectTrigger>
<SelectContent>
{hosts.map((h) => (
<SelectItem key={h.id} value={String(h.id)}>
{h.name}({h.hostIP})
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="storageConfigID"
render={({ field }) => (
<FormItem>
<FormLabel>存储配置</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="请选择存储(可选)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">本地存储(默认)</SelectItem>
{storages.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}({s.storageType})
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-3 gap-4">
<FormField
control={form.control}
name="replicas"
render={({ field }) => (
<FormItem>
<FormLabel>副本数</FormLabel>
<FormControl>
<Input type="number" min={1} max={10} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="retentionDays"
render={({ field }) => (
<FormItem>
<FormLabel>保留天数</FormLabel>
<FormControl>
<Input type="number" min={1} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="storageSize"
render={({ field }) => (
<FormItem>
<FormLabel>存储容量</FormLabel>
<FormControl>
<Input placeholder="10Gi" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="resourcesCPU"
render={({ field }) => (
<FormItem>
<FormLabel>CPU</FormLabel>
<FormControl>
<Input placeholder="500m" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="resourcesMem"
render={({ field }) => (
<FormItem>
<FormLabel>内存</FormLabel>
<FormControl>
<Input placeholder="512Mi" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="authEnabled"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center gap-2">
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
<FormLabel>启用多租户鉴权</FormLabel>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="grafanaEnabled"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center gap-2">
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
<FormLabel>同时部署 Grafana(可选 — 平台已内置日志查询,通常无需 Grafana)</FormLabel>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{grafanaEnabled && (
<FormField
control={form.control}
name="grafanaVersion"
render={({ field }) => (
<FormItem>
<FormLabel>Grafana 版本</FormLabel>
<FormControl>
<Input placeholder="11.3.0" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button
type="submit"
className="w-full bg-primary hover:bg-primary/90"
disabled={submitting}
>
{submitting ? '创建中...' : '创建部署'}
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
)
})
@@ -0,0 +1,109 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { Terminal } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { deploymentApi } from '@/api'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { ErrorState } from '@/components/ErrorState'
import type { DeploymentLog, LokiDeployment } from '@/types'
interface LogViewerDialogProps {
deployment: LokiDeployment | null
onClose: () => void
}
/** 部署日志弹窗 — 自负责拉取日志(打开即加载,版本号丢弃过期响应,失败可重试) */
export const LogViewerDialog = memo(function LogViewerDialog({
deployment,
onClose,
}: LogViewerDialogProps) {
const [logs, setLogs] = useState<DeploymentLog[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// 版本号令牌:快速重开/切换部署时丢弃过期响应
const versionRef = useRef(0)
const loadLogs = useCallback(async (id: number) => {
const current = ++versionRef.current
setLoading(true)
setError(null)
try {
const res = await deploymentApi.logs(id, { page: 1, pageSize: 100 })
if (current !== versionRef.current) return
setLogs(res.list ?? [])
} catch (err) {
if (current !== versionRef.current) return
setError(err instanceof Error ? err.message : '加载部署日志失败')
} finally {
if (current === versionRef.current) {
setLoading(false)
}
}
}, [])
useEffect(() => {
if (!deployment) return
setLogs([])
setError(null)
void loadLogs(deployment.id)
}, [deployment, loadLogs])
return (
<Dialog open={deployment !== null} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-4xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Terminal className="w-5 h-5 text-primary" />
部署日志 - {deployment?.name}
</DialogTitle>
</DialogHeader>
{error ? (
<ErrorState
message={error}
onRetry={() => deployment && loadLogs(deployment.id)}
/>
) : loading ? (
<LoadingSpinner className="h-64" />
) : (
<LogView logs={logs} />
)}
</DialogContent>
</Dialog>
)
})
/** 终端风格日志列表 */
const LogView = memo(function LogView({ logs }: { logs: DeploymentLog[] }) {
return (
<div className="bg-card dark:bg-card text-green-400 font-mono text-xs p-4 rounded-lg h-96 overflow-auto">
{logs.length === 0 ? (
<p className="text-muted-foreground">暂无日志...</p>
) : (
logs.map((log) => (
<div
key={log.id}
className={`mb-1 ${
log.level === 'error'
? 'text-destructive'
: log.level === 'warn'
? 'text-warning'
: log.level === 'success'
? 'text-success'
: 'text-green-400'
}`}
>
<span className="text-muted-foreground">
[{new Date(log.createdAt).toLocaleTimeString()}]
</span>{' '}
<span className="font-bold uppercase">{log.level}</span> {log.message}
</div>
))
)}
</div>
)
})
@@ -0,0 +1,73 @@
import { z } from 'zod'
// 部署表单 schema 与常量 — zod 为唯一校验源,消息中文且具体
// (纯数据/纯逻辑抽离,见开发标准 07 章页面内聚 utils 范式)
// 版本下拉兜底值;实际列表运行时从 GET /deployments/versions 拉取
export const FALLBACK_VERSIONS = ['3.0.0', '2.9.8', '2.9.7', '2.9.6']
export type DeploymentMode = 'k8s-helm' | 'k8s-yaml' | 'host-binary'
export const isK8sMode = (mode: DeploymentMode) =>
mode === 'k8s-helm' || mode === 'k8s-yaml'
// 整数字段校验(输入框 type=number 产出字符串,提交前统一转换)
const isPositiveInt = (v: string, min: number, max?: number) => {
const n = Number(v)
return Number.isInteger(n) && n >= min && (max === undefined || n <= max)
}
export const deploymentFormSchema = z
.object({
name: z.string().min(1, '请输入部署名称'),
description: z.string(),
version: z.string().min(1, '请选择 Loki 版本'),
mode: z.enum(['k8s-helm', 'k8s-yaml', 'host-binary']),
clusterID: z.string(),
hostID: z.string(),
namespace: z.string(),
grafanaEnabled: z.boolean(),
grafanaVersion: z.string().optional(),
storageConfigID: z.string(),
authEnabled: z.boolean(),
retentionDays: z
.string()
.refine((v) => isPositiveInt(v, 1), '保留天数需为不小于 1 的整数'),
replicas: z
.string()
.refine((v) => isPositiveInt(v, 1, 10), '副本数需为 1-10 之间的整数'),
resourcesCPU: z.string().min(1, '请填写 CPU 配额(如 500m)'),
resourcesMem: z.string().min(1, '请填写内存配额(如 512Mi)'),
storageSize: z.string().min(1, '请填写存储容量(如 10Gi)'),
})
.superRefine((data, ctx) => {
if (isK8sMode(data.mode) && !data.clusterID) {
ctx.addIssue({ code: 'custom', path: ['clusterID'], message: '请选择目标集群' })
}
if (data.mode === 'host-binary' && !data.hostID) {
ctx.addIssue({ code: 'custom', path: ['hostID'], message: '请选择目标主机' })
}
if (isK8sMode(data.mode) && !data.namespace.trim()) {
ctx.addIssue({ code: 'custom', path: ['namespace'], message: '请填写命名空间' })
}
})
export type DeploymentFormValues = z.infer<typeof deploymentFormSchema>
export const DEFAULT_VALUES: DeploymentFormValues = {
name: '',
description: '',
version: '3.0.0',
mode: 'k8s-helm',
clusterID: '',
hostID: '',
namespace: 'log',
grafanaEnabled: false,
grafanaVersion: '11.3.0',
storageConfigID: '',
authEnabled: false,
retentionDays: '30',
replicas: '1',
resourcesCPU: '500m',
resourcesMem: '512Mi',
storageSize: '10Gi',
}
+208
View File
@@ -0,0 +1,208 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { Rocket, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { clusterApi, deploymentApi, hostApi, storageApi } from '@/api'
import { PageContainer } from '@/components/PageContainer'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { EmptyState } from '@/components/EmptyState'
import { ErrorState } from '@/components/ErrorState'
import { PaginationBar } from '@/components/PaginationBar'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { toast } from 'sonner'
import { useListQuery } from '@/hooks/useListQuery'
import type { Cluster, Host, LokiDeployment, StorageConfig } from '@/types'
import { DeploymentCard } from './components/DeploymentCard'
import { DeploymentFormDialog } from './components/DeploymentFormDialog'
import { LogViewerDialog } from './components/LogViewerDialog'
/**
* Loki 部署页 — 只做编排:部署列表走 useListQuery(取消+版本号),
* 集群/主机/存储下拉数据为页面局部加载(与列表分页解耦);
* 卡片/表单/日志查看均为域内子组件。成功操作静默(列表刷新即反馈)。
*/
const DeploymentsPage = memo(function DeploymentsPage() {
const query = useListQuery<LokiDeployment>((params, signal) =>
deploymentApi.list(params, { signal })
)
// 表单下拉数据:一次性局部加载,创建部署不依赖其刷新
const [clusters, setClusters] = useState<Cluster[]>([])
const [hosts, setHosts] = useState<Host[]>([])
const [storages, setStorages] = useState<StorageConfig[]>([])
const [formOpen, setFormOpen] = useState(false)
const [logsDeployment, setLogsDeployment] = useState<LokiDeployment | null>(null)
const [deleteTarget, setDeleteTarget] = useState<LokiDeployment | null>(null)
const [deleting, setDeleting] = useState(false)
/** 部署/重启进行中的部署 ID(按钮 loading + 防重复点击) */
const [operating, setOperating] = useState<number | null>(null)
// 操作后延时刷新列表/清除按钮态的定时器(卸载清理)
const timeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([])
useEffect(() => {
return () => {
timeoutsRef.current.forEach((id) => clearTimeout(id))
timeoutsRef.current = []
}
}, [])
const addTimeout = useCallback((cb: () => void, delay: number) => {
timeoutsRef.current.push(setTimeout(cb, delay))
}, [])
// 下拉数据加载(失败仅提示,不阻塞列表)
useEffect(() => {
let cancelled = false
Promise.all([
clusterApi.list({ page: 1, pageSize: 100 }),
hostApi.list({ page: 1, pageSize: 100 }),
storageApi.list({ page: 1, pageSize: 100 }),
])
.then(([clusterRes, hostRes, storageRes]) => {
if (cancelled) return
setClusters(clusterRes.list)
setHosts(hostRes.list)
setStorages(storageRes.list)
})
.catch((err) => {
toast.error(err instanceof Error ? err.message : '加载集群/主机/存储数据失败')
})
return () => {
cancelled = true
}
}, [])
const handleDeploy = useCallback(
async (id: number) => {
setOperating(id)
try {
await deploymentApi.deploy(id)
addTimeout(query.refresh, 2000)
} catch (err) {
toast.error(err instanceof Error ? err.message : '部署失败')
} finally {
addTimeout(() => setOperating(null), 5000)
}
},
[query, addTimeout]
)
const handleStop = useCallback(
async (id: number) => {
try {
await deploymentApi.stop(id)
query.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '停止失败')
}
},
[query]
)
const handleRestart = useCallback(
async (id: number) => {
setOperating(id)
try {
await deploymentApi.restart(id)
addTimeout(query.refresh, 3000)
} catch (err) {
toast.error(err instanceof Error ? err.message : '重启失败')
} finally {
addTimeout(() => setOperating(null), 5000)
}
},
[query, addTimeout]
)
const handleDelete = useCallback(async () => {
const id = deleteTarget?.id
if (!id) return
setDeleting(true)
try {
await deploymentApi.remove(id)
setDeleteTarget(null)
query.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '删除部署失败')
} finally {
setDeleting(false)
}
}, [deleteTarget, query])
return (
<PageContainer
title="部署Loki"
description="部署与管理 Grafana Loki 日志聚合服务"
action={
<Button
className="bg-primary hover:bg-primary/90"
onClick={() => setFormOpen(true)}
>
<Plus className="w-4 h-4 mr-1" />
新建部署
</Button>
}
>
{query.loading ? (
<LoadingSpinner className="h-64" />
) : query.error ? (
<ErrorState message={query.error} onRetry={query.refresh} />
) : query.list.length === 0 ? (
<EmptyState
icon={Rocket}
title="暂无部署"
description="创建你的第一个 Loki 部署"
/>
) : (
<>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{query.list.map((deployment) => (
<DeploymentCard
key={deployment.id}
deployment={deployment}
operating={operating}
onDeploy={handleDeploy}
onStop={handleStop}
onRestart={handleRestart}
onDelete={setDeleteTarget}
onViewLogs={setLogsDeployment}
/>
))}
</div>
<PaginationBar
page={query.page}
pageSize={query.pageSize}
total={query.total}
onPageChange={query.setPage}
/>
</>
)}
<DeploymentFormDialog
open={formOpen}
onOpenChange={setFormOpen}
onCreated={query.reload}
clusters={clusters}
hosts={hosts}
storages={storages}
/>
<LogViewerDialog
deployment={logsDeployment}
onClose={() => setLogsDeployment(null)}
/>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null)
}}
name={deleteTarget?.name}
loading={deleting}
onConfirm={handleDelete}
/>
</PageContainer>
)
})
export default DeploymentsPage