feat: complete Loki query, insights, settings and deployment workflows
Add the Loki query UI, access policies, ingress insights, alert and registry settings, and their backend APIs. Include the related Kubernetes deployment and collector updates required by the integrated application. Validated with go test ./..., npm run build, and npm run lint.
This commit is contained in:
1 parent
f9c6c1d826
commit
2d73661dd1
127 files changed
+8117
-2876
No files matched your search
@@ -1,10 +1,9 @@
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { Play, Square, RotateCw, Trash2, Terminal, ExternalLink } from 'lucide-react'
|
||||
import { Play, Square, RotateCw, Trash2, Terminal, ExternalLink, Pencil, Rocket } 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 { StatusBadge } from '@/components/StatusBadge'
|
||||
import { deploymentApi } from '@/api'
|
||||
import { ResourceCard } from '@/components/ResourceCard'
|
||||
import type { LokiDeployment } from '@/types'
|
||||
|
||||
/** 部署目标展示:优先集群,其次主机 */
|
||||
@@ -23,6 +22,7 @@ interface DeploymentCardProps {
|
||||
onRestart: (id: number) => void
|
||||
onDelete: (d: LokiDeployment) => void
|
||||
onViewLogs: (d: LokiDeployment) => void
|
||||
onEditRetention: (d: LokiDeployment) => void
|
||||
}
|
||||
|
||||
/** 部署卡片 — 列表项展示与操作入口(部署/停止/重启/日志/访问/删除) */
|
||||
@@ -34,6 +34,7 @@ export const DeploymentCard = memo(function DeploymentCard({
|
||||
onRestart,
|
||||
onDelete,
|
||||
onViewLogs,
|
||||
onEditRetention,
|
||||
}: DeploymentCardProps) {
|
||||
// Loki 就绪探测(运行中时经数据源代理 /ready)
|
||||
const [ready, setReady] = useState<boolean | null>(null)
|
||||
@@ -58,122 +59,75 @@ export const DeploymentCard = memo(function DeploymentCard({
|
||||
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">
|
||||
<ResourceCard
|
||||
icon={Rocket}
|
||||
iconClassName={d.status === 'running' ? 'text-success' : d.status === 'failed' ? 'text-destructive' : 'text-muted-foreground'}
|
||||
title={d.name}
|
||||
titleExtra={
|
||||
d.status === 'running' && ready !== null ? (
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${ready ? 'bg-success' : 'bg-destructive'}`}
|
||||
title={ready ? 'Loki 就绪' : 'Loki 未就绪'}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
badges={<StatusBadge status={d.status} />}
|
||||
description={d.description || d.uuid}
|
||||
fields={[
|
||||
{ label: '方式', value: d.mode === 'host-docker' ? '主机 Docker' : d.mode === 'k8s-yaml' ? 'Kubernetes' : d.mode },
|
||||
{ label: '版本', value: d.version },
|
||||
{ label: '目标', value: getTargetLabel(d) },
|
||||
...(d.mode !== 'host-docker' ? [
|
||||
{ label: '命名空间', value: d.namespace || 'loki' },
|
||||
{ label: '副本', value: `${d.replicas} · 保留 ${d.retentionDays || 30} 天` },
|
||||
] : [{ label: '保留', value: `${d.retentionDays || 30} 天` }]),
|
||||
{ label: '存储', value: d.storageConfig?.name || '本地' },
|
||||
...(d.endpoint ? [{ label: '访问', value: (
|
||||
<span className="text-primary">{d.endpoint.replace(/^https?:\/\//, '')}</span>
|
||||
), title: d.endpoint }] : []),
|
||||
...(d.grafanaEnabled && d.grafanaEndpoint ? [{ label: 'Grafana', value: (
|
||||
<a href={d.grafanaEndpoint} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline" title={`${d.grafanaEndpoint}(admin/admin)`}>
|
||||
{d.grafanaEndpoint.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
) }] : []),
|
||||
]}
|
||||
message={d.statusMessage || undefined}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="text-xs" onClick={() => onEditRetention(d)} disabled={isDeploying || isBusy}>
|
||||
<Pencil className="size-3 mr-1" />配置
|
||||
</Button>
|
||||
{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 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="size-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" />
|
||||
部署中...
|
||||
<RotateCw className="size-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 variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onStop(d.id)}>
|
||||
<Square className="size-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 variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onRestart(d.id)} disabled={isBusy}>
|
||||
<RotateCw className={`size-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" />
|
||||
日志
|
||||
<Terminal className="size-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 variant="outline" size="sm" className="text-xs text-primary hover:text-primary/80" onClick={() => window.open(d.endpoint, '_blank', 'noopener,noreferrer')} title="浏览器访问">
|
||||
<ExternalLink className="size-3" />
|
||||
</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 variant="outline" size="sm" aria-label={`删除部署 ${d.name}`} className="text-destructive hover:text-destructive hover:bg-destructive/10" onClick={() => onDelete(d)}>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -81,7 +81,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
})
|
||||
const mode = form.watch('mode')
|
||||
const grafanaEnabled = form.watch('grafanaEnabled')
|
||||
const hostID = form.watch('hostID')
|
||||
|
||||
// 打开时重置(关闭重置时机明确)
|
||||
useEffect(() => {
|
||||
@@ -100,6 +100,8 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
mode: values.mode,
|
||||
clusterID: values.clusterID ? Number(values.clusterID) : undefined,
|
||||
hostID: values.hostID ? Number(values.hostID) : undefined,
|
||||
listenIP: values.mode === 'host-docker' ? (values.listenIP || hosts.find((h) => String(h.id) === values.hostID)?.hostIP) : undefined,
|
||||
listenPort: values.mode === 'host-docker' ? Number(values.listenPort) : undefined,
|
||||
namespace: values.namespace,
|
||||
storageConfigID: values.storageConfigID ? Number(values.storageConfigID) : 0,
|
||||
authEnabled: values.authEnabled,
|
||||
@@ -108,8 +110,6 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
resourcesCPU: values.resourcesCPU,
|
||||
resourcesMem: values.resourcesMem,
|
||||
storageSize: values.storageSize,
|
||||
grafanaEnabled: values.grafanaEnabled,
|
||||
grafanaVersion: values.grafanaEnabled ? values.grafanaVersion || undefined : undefined,
|
||||
})
|
||||
onOpenChange(false)
|
||||
onCreated()
|
||||
@@ -121,7 +121,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
})
|
||||
|
||||
const showK8s = isK8sMode(mode)
|
||||
const showHost = mode === 'host-binary'
|
||||
const showHost = mode === 'host-docker'
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -171,7 +171,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{versions.map((v) => (
|
||||
{versions.filter((v) => mode !== 'host-docker' || v.startsWith('3.')).map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</SelectItem>
|
||||
@@ -190,14 +190,16 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
<FormItem>
|
||||
<FormLabel>部署方式</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<Select value={field.value} onValueChange={(next) => {
|
||||
field.onChange(next)
|
||||
if (next === 'host-docker' && !form.getValues('version').startsWith('3.')) form.setValue('version', '3.0.0')
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="k8s-helm">K8s Helm 部署</SelectItem>
|
||||
<SelectItem value="k8s-yaml">K8s YAML 部署</SelectItem>
|
||||
<SelectItem value="host-binary">主机二进制部署</SelectItem>
|
||||
<SelectItem value="k8s-yaml">Kubernetes</SelectItem>
|
||||
<SelectItem value="host-docker">主机 Docker</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
@@ -249,7 +251,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
</>
|
||||
)}
|
||||
|
||||
{showHost && (
|
||||
{showHost && (<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hostID"
|
||||
@@ -274,7 +276,16 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField control={form.control} name="listenIP" render={({ field }) => (
|
||||
<FormItem><FormLabel>监听内网 IP</FormLabel><FormControl><Input placeholder={hosts.find((h) => String(h.id) === hostID)?.hostIP || '选择主机后默认使用主机 IP'} {...field} /></FormControl><FormMessage /></FormItem>
|
||||
)} />
|
||||
<FormField control={form.control} name="listenPort" render={({ field }) => (
|
||||
<FormItem><FormLabel>访问端口</FormLabel><FormControl><Input type="number" min={1} max={65535} {...field} /></FormControl><FormMessage /></FormItem>
|
||||
)} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">通过 SSH 在主机启动 Docker 容器,配置和数据保存在主机本地。</p>
|
||||
</>)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -303,7 +314,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField
|
||||
{showK8s && <FormField
|
||||
control={form.control}
|
||||
name="replicas"
|
||||
render={({ field }) => (
|
||||
@@ -315,7 +326,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
/>}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="retentionDays"
|
||||
@@ -329,7 +340,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
{showK8s && <FormField
|
||||
control={form.control}
|
||||
name="storageSize"
|
||||
render={({ field }) => (
|
||||
@@ -341,7 +352,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
/>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -392,41 +403,6 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
)}
|
||||
/>
|
||||
|
||||
<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"
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { toast } from 'sonner'
|
||||
import { deploymentApi } from '@/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import type { LokiDeployment } from '@/types'
|
||||
|
||||
const schema = z.object({ yaml: z.string().trim().min(1, '请输入 YAML 配置') })
|
||||
type Values = z.infer<typeof schema>
|
||||
|
||||
interface Props {
|
||||
deployment: LokiDeployment | null
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
function configLocation(deployment: LokiDeployment) {
|
||||
if (deployment.mode === 'k8s-helm') return 'Helm values.yaml → loki.limits_config.retention_period'
|
||||
if (deployment.mode === 'host-binary') return '/etc/loki/loki.yaml → limits_config.retention_period'
|
||||
if (deployment.mode === 'host-docker') return `主机 /etc/lokideploy/loki-${deployment.id}/loki.yaml → limits_config.retention_period`
|
||||
return `ConfigMap ${deployment.namespace || 'log'}/loki-config → loki.yaml`
|
||||
}
|
||||
|
||||
export const RetentionDialog = memo(function RetentionDialog({ deployment, onClose, onSaved }: Props) {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [liveConfig, setLiveConfig] = useState<{ location: string; yaml: string } | null>(null)
|
||||
const [configLoading, setConfigLoading] = useState(false)
|
||||
const [configError, setConfigError] = useState('')
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { yaml: 'limits_config:\n retention_period: 30d\n' },
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (deployment) {
|
||||
form.reset({ yaml: `limits_config:\n retention_period: ${deployment.retentionDays || 30}d\n` })
|
||||
}
|
||||
setLiveConfig(null)
|
||||
setConfigError('')
|
||||
}, [deployment, form])
|
||||
|
||||
const loadConfig = async () => {
|
||||
if (!deployment) return
|
||||
setConfigLoading(true)
|
||||
setConfigError('')
|
||||
try {
|
||||
setLiveConfig(await deploymentApi.liveConfig(deployment.id))
|
||||
} catch (err) {
|
||||
setConfigError(err instanceof Error ? err.message : '读取运行配置失败')
|
||||
} finally {
|
||||
setConfigLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const save = (apply: boolean) => form.handleSubmit(async ({ yaml }) => {
|
||||
if (!deployment) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await deploymentApi.updateRetention(deployment.id, yaml, apply)
|
||||
onSaved()
|
||||
onClose()
|
||||
if (apply) toast.info(`已保存 ${result.retentionDays} 天,Loki 正在重新部署`)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '保存 Loki 配置失败')
|
||||
onSaved()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={deployment !== null} onOpenChange={(open) => { if (!open && !saving) onClose() }}>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑 Loki 配置 · {deployment?.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>配置位置:{deployment ? configLocation(deployment) : ''}</p>
|
||||
<p>保留策略由 Loki compactor 执行;MinIO 只负责存储日志对象。</p>
|
||||
</div>
|
||||
<div className="space-y-2 rounded-md border border-border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">运行中的完整 loki.yaml</p>
|
||||
<p className="text-xs text-muted-foreground">从部署目标读取,存储凭证已遮盖;下方保留天数仍单独编辑。</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={loadConfig} disabled={configLoading || deployment?.mode === 'k8s-helm'}>
|
||||
{configLoading ? '读取中…' : liveConfig ? '刷新配置' : '查看完整配置'}
|
||||
</Button>
|
||||
</div>
|
||||
{deployment?.mode === 'k8s-helm' && <p className="text-xs text-muted-foreground">Helm 模式暂不支持读取运行配置。</p>}
|
||||
{configError && <p className="text-sm text-destructive">{configError}</p>}
|
||||
{liveConfig && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs text-muted-foreground">{liveConfig.location}</p>
|
||||
<pre className="max-h-[45vh] overflow-auto rounded-md bg-muted/50 p-3 font-mono text-xs whitespace-pre">{liveConfig.yaml}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/40 p-3 text-xs">
|
||||
<p className="mb-1 font-medium text-foreground">由部署模板生成</p>
|
||||
<pre className="text-muted-foreground">{'compactor:\n retention_enabled: true\n retention_delete_delay: 2h'}</pre>
|
||||
</div>
|
||||
<Form {...form}>
|
||||
<form onSubmit={save(false)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="yaml"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>保留配置 YAML</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={4} spellCheck={false} className="font-mono text-sm" {...field} />
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
编辑 limits_config.retention_period,例如 90d 或 2160h。此处只接受这一项配置。
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{deployment?.status === 'running'
|
||||
? '仅保存会更新部署记录;保存并应用会重新部署 Loki,查询可能短暂中断。'
|
||||
: '保存后在下次部署时生效。'}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>取消</Button>
|
||||
<Button type="submit" variant="outline" disabled={saving}>仅保存</Button>
|
||||
{deployment?.status === 'running' && (
|
||||
<Button type="button" onClick={save(true)} disabled={saving}>
|
||||
{saving ? '处理中…' : '保存并应用'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
})
|
||||
@@ -5,10 +5,9 @@ import { z } from 'zod'
|
||||
// 版本下拉兜底值;实际列表运行时从 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 type DeploymentMode = 'k8s-yaml' | 'host-docker'
|
||||
|
||||
export const isK8sMode = (mode: DeploymentMode) =>
|
||||
mode === 'k8s-helm' || mode === 'k8s-yaml'
|
||||
export const isK8sMode = (mode: DeploymentMode) => mode === 'k8s-yaml'
|
||||
|
||||
// 整数字段校验(输入框 type=number 产出字符串,提交前统一转换)
|
||||
const isPositiveInt = (v: string, min: number, max?: number) => {
|
||||
@@ -21,12 +20,12 @@ export const deploymentFormSchema = z
|
||||
name: z.string().min(1, '请输入部署名称'),
|
||||
description: z.string(),
|
||||
version: z.string().min(1, '请选择 Loki 版本'),
|
||||
mode: z.enum(['k8s-helm', 'k8s-yaml', 'host-binary']),
|
||||
mode: z.enum(['k8s-yaml', 'host-docker']),
|
||||
clusterID: z.string(),
|
||||
hostID: z.string(),
|
||||
listenIP: z.string(),
|
||||
listenPort: z.string(),
|
||||
namespace: z.string(),
|
||||
grafanaEnabled: z.boolean(),
|
||||
grafanaVersion: z.string().optional(),
|
||||
storageConfigID: z.string(),
|
||||
authEnabled: z.boolean(),
|
||||
retentionDays: z
|
||||
@@ -43,9 +42,12 @@ export const deploymentFormSchema = z
|
||||
if (isK8sMode(data.mode) && !data.clusterID) {
|
||||
ctx.addIssue({ code: 'custom', path: ['clusterID'], message: '请选择目标集群' })
|
||||
}
|
||||
if (data.mode === 'host-binary' && !data.hostID) {
|
||||
if (data.mode === 'host-docker' && !data.hostID) {
|
||||
ctx.addIssue({ code: 'custom', path: ['hostID'], message: '请选择目标主机' })
|
||||
}
|
||||
if (data.mode === 'host-docker' && !isPositiveInt(data.listenPort, 1, 65535)) {
|
||||
ctx.addIssue({ code: 'custom', path: ['listenPort'], message: '端口须在 1-65535 之间' })
|
||||
}
|
||||
if (isK8sMode(data.mode) && !data.namespace.trim()) {
|
||||
ctx.addIssue({ code: 'custom', path: ['namespace'], message: '请填写命名空间' })
|
||||
}
|
||||
@@ -57,12 +59,12 @@ export const DEFAULT_VALUES: DeploymentFormValues = {
|
||||
name: '',
|
||||
description: '',
|
||||
version: '3.0.0',
|
||||
mode: 'k8s-helm',
|
||||
mode: 'k8s-yaml',
|
||||
clusterID: '',
|
||||
hostID: '',
|
||||
listenIP: '',
|
||||
listenPort: '3100',
|
||||
namespace: 'log',
|
||||
grafanaEnabled: false,
|
||||
grafanaVersion: '11.3.0',
|
||||
storageConfigID: '',
|
||||
authEnabled: false,
|
||||
retentionDays: '30',
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useListQuery } from '@/hooks/useListQuery'
|
||||
import type { Cluster, Host, LokiDeployment, StorageConfig } from '@/types'
|
||||
import { DeploymentCard } from './components/DeploymentCard'
|
||||
import { DeploymentFormDialog } from './components/DeploymentFormDialog'
|
||||
import { RetentionDialog } from './components/RetentionDialog'
|
||||
import { LogViewerDialog } from './components/LogViewerDialog'
|
||||
|
||||
/**
|
||||
@@ -32,6 +33,7 @@ const DeploymentsPage = memo(function DeploymentsPage() {
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [logsDeployment, setLogsDeployment] = useState<LokiDeployment | null>(null)
|
||||
const [retentionDeployment, setRetentionDeployment] = useState<LokiDeployment | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<LokiDeployment | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
/** 部署/重启进行中的部署 ID(按钮 loading + 防重复点击) */
|
||||
@@ -155,7 +157,7 @@ const DeploymentsPage = memo(function DeploymentsPage() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{query.list.map((deployment) => (
|
||||
<DeploymentCard
|
||||
key={deployment.id}
|
||||
@@ -166,6 +168,7 @@ const DeploymentsPage = memo(function DeploymentsPage() {
|
||||
onRestart={handleRestart}
|
||||
onDelete={setDeleteTarget}
|
||||
onViewLogs={setLogsDeployment}
|
||||
onEditRetention={setRetentionDeployment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -192,6 +195,12 @@ const DeploymentsPage = memo(function DeploymentsPage() {
|
||||
onClose={() => setLogsDeployment(null)}
|
||||
/>
|
||||
|
||||
<RetentionDialog
|
||||
deployment={retentionDeployment}
|
||||
onClose={() => setRetentionDeployment(null)}
|
||||
onSaved={query.refresh}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
Reference in new issue
Block a user