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