feat: 品牌统一 LokiStack + 全站中英 i18n + 用户批量删除 + 主题定时切换与浅色侧栏
- 品牌: Go 模块/数据文件/二进制/构建部署资产全部 opsx→lokistack;前端 localStorage 命名空间迁移(读旧写新,含按用户 id 前缀迁移);修复 nginx upstream 与 Makefile 引用已改名服务的断裂 - i18n: react-i18next 落地,common + 11 个页面域命名空间全站中英(菜单/顶栏/登录/列表页/表单/确认弹窗/状态徽章);设置页语言即时切换;修复词典自注册导致的循环导入白屏 - 用户管理: 批量删除(POST /users/batch-delete,不删自己/最后管理员,部分失败不阻断)+ 行复选/全选/批量工具条 - 主题: 浅色侧栏(中性灰激活态)、四态模式(浅色/深色/跟随系统/定时切换,按时区判定)、顶栏太阳月亮二态、设置页紧凑分段选择器 - UI: 登录记住用户名、CardActionButton 统一五页卡片操作钮、shadcn Badge 替换手写徽章、语义色修正 - 清理: 移除主机 Web 终端(前后端)、k8s-helm/Helm 遗留代码与字段、README 重写精简 - 修复: 侧栏与日志告警 order 重复、路由标题 i18n key 未翻译、词典双逗号等
This commit is contained in:
1 parent
02d1bd87a7
commit
f3733f0bfc
201 files changed
+5438
-2388
No files matched your search
@@ -1,18 +1,12 @@
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { Play, Square, RotateCw, Trash2, Terminal, Pencil, Rocket } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Play, Square, RotateCw, Trash2, Terminal, Settings, Rocket } from 'lucide-react'
|
||||
import { StatusBadge } from '@/components/StatusBadge'
|
||||
import { deploymentApi } from '@/api'
|
||||
import { ResourceCard } from '@/components/ResourceCard'
|
||||
import { CardActionButton } from '@/components/CardActionButton'
|
||||
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 态) */
|
||||
@@ -36,6 +30,7 @@ export const DeploymentCard = memo(function DeploymentCard({
|
||||
onViewLogs,
|
||||
onEditRetention,
|
||||
}: DeploymentCardProps) {
|
||||
const { t } = useTranslation('deployments')
|
||||
// Loki 就绪探测(运行中时经数据源代理 /ready)
|
||||
const [ready, setReady] = useState<boolean | null>(null)
|
||||
useEffect(() => {
|
||||
@@ -58,6 +53,13 @@ export const DeploymentCard = memo(function DeploymentCard({
|
||||
const isBusy = operating === d.id
|
||||
const canDeploy = d.status === 'pending' || d.status === 'failed' || d.status === 'stopped'
|
||||
|
||||
// 部署目标展示:优先集群,其次主机
|
||||
const targetLabel = d.cluster
|
||||
? t('card.targetCluster', { name: d.cluster.name })
|
||||
: d.host
|
||||
? t('card.targetHost', { name: d.host.name })
|
||||
: t('card.targetUnknown')
|
||||
|
||||
return (
|
||||
<ResourceCard
|
||||
icon={Rocket}
|
||||
@@ -67,22 +69,22 @@ export const DeploymentCard = memo(function DeploymentCard({
|
||||
d.status === 'running' && ready !== null ? (
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${ready ? 'bg-success' : 'bg-destructive'}`}
|
||||
title={ready ? 'Loki 就绪' : 'Loki 未就绪'}
|
||||
title={ready ? t('card.ready') : t('card.notReady')}
|
||||
/>
|
||||
) : 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) },
|
||||
{ label: t('card.mode'), value: d.mode === 'host-docker' ? t('card.modeDocker') : d.mode === 'k8s-yaml' ? t('card.modeK8s') : d.mode },
|
||||
{ label: t('card.version'), value: d.version },
|
||||
{ label: t('card.target'), value: targetLabel },
|
||||
...(d.mode !== 'host-docker' ? [
|
||||
{ label: '命名空间', value: d.namespace || 'log' },
|
||||
{ label: '副本', value: `${d.replicas} · 保留 ${d.retentionDays || 30} 天` },
|
||||
] : [{ label: '保留', value: `${d.retentionDays || 30} 天` }]),
|
||||
{ label: '存储', value: d.storageConfig?.name || '本地' },
|
||||
// 不再展示 endpoint:K8s 模式下它是 http://loki.log.svc.cluster.local:3100
|
||||
{ label: t('card.namespace'), value: d.namespace || 'log' },
|
||||
{ label: t('card.replicas'), value: t('card.replicasValue', { replicas: d.replicas, days: d.retentionDays || 30 }) },
|
||||
] : [{ label: t('card.retention'), value: t('card.retentionValue', { days: d.retentionDays || 30 }) }]),
|
||||
{ label: t('card.storage'), value: d.storageConfig?.name || t('card.localStorage') },
|
||||
// 不再展示 endpoint:Kubernetes 模式下它是 http://loki.log.svc.cluster.local:3100
|
||||
// 这类集群内地址,浏览器点不开,纯属噪音
|
||||
...(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)`}>
|
||||
@@ -93,38 +95,30 @@ export const DeploymentCard = memo(function DeploymentCard({
|
||||
message={
|
||||
d.statusMessage ||
|
||||
(d.status === 'running'
|
||||
? '提示:到「日志查询 → 数据源」关联 Loki 后即可查询;跨集群/公网访问需先为 Loki 配置 Ingress 域名。'
|
||||
? t('card.runningHint')
|
||||
: undefined)
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="text-xs" onClick={() => onEditRetention(d)} disabled={isDeploying || isBusy}>
|
||||
<Pencil className="size-3 mr-1" />配置
|
||||
</Button>
|
||||
<CardActionButton icon={Settings} label={t('card.configure')} disabled={isDeploying || isBusy} onClick={() => onEditRetention(d)} />
|
||||
{canDeploy ? (
|
||||
<Button variant="outline" size="sm" className="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>
|
||||
<CardActionButton
|
||||
icon={Play}
|
||||
label={isBusy ? t('card.deploying') : t('card.deploy')}
|
||||
className="bg-success/10 text-success hover:bg-success/20 border-success/30"
|
||||
disabled={isBusy}
|
||||
onClick={() => onDeploy(d.id)}
|
||||
/>
|
||||
) : isDeploying ? (
|
||||
<Button variant="outline" size="sm" className="text-xs" disabled>
|
||||
<RotateCw className="size-3 mr-1 animate-spin" />部署中...
|
||||
</Button>
|
||||
<CardActionButton icon={RotateCw} label={t('card.deploying')} spinning disabled />
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="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={`size-3 mr-1 ${isBusy ? 'animate-spin' : ''}`} />重启
|
||||
</Button>
|
||||
<CardActionButton icon={Square} label={t('card.stop')} onClick={() => onStop(d.id)} />
|
||||
<CardActionButton icon={RotateCw} label={t('card.restart')} spinning={isBusy} disabled={isBusy} onClick={() => onRestart(d.id)} />
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="text-xs" onClick={() => onViewLogs(d)}>
|
||||
<Terminal className="size-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="size-3.5" />
|
||||
</Button>
|
||||
<CardActionButton icon={Terminal} label={t('card.logs')} onClick={() => onViewLogs(d)} />
|
||||
<CardActionButton icon={Trash2} ariaLabel={t('card.deleteAria', { name: d.name })} destructive onClick={() => onDelete(d)} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { memo, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -27,13 +28,12 @@ import {
|
||||
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,
|
||||
buildDeploymentFormSchema,
|
||||
DEFAULT_VALUES,
|
||||
type DeploymentFormValues,
|
||||
} from '../form-schema'
|
||||
@@ -57,6 +57,9 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
hosts,
|
||||
storages,
|
||||
}: DeploymentFormDialogProps) {
|
||||
const { t } = useTranslation('deployments')
|
||||
// 集群状态标签走 common.status.*(替代 lib/utils 的硬编码中文映射)
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
// 版本列表来自后端 LokiVersionList,失败回退兜底
|
||||
const [versions, setVersions] = useState<string[]>(FALLBACK_VERSIONS)
|
||||
@@ -76,6 +79,8 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
}
|
||||
}, [])
|
||||
|
||||
// schema 随语言重建(校验消息跟随语言切换)
|
||||
const deploymentFormSchema = useMemo(() => buildDeploymentFormSchema(t), [t])
|
||||
const form = useForm<DeploymentFormValues>({
|
||||
resolver: zodResolver(deploymentFormSchema),
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
@@ -114,7 +119,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
onOpenChange(false)
|
||||
onCreated()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '创建部署失败')
|
||||
toast.error(err instanceof Error ? err.message : t('form.errCreate'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -123,11 +128,18 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
const showK8s = isK8sMode(mode)
|
||||
const showHost = mode === 'host-docker'
|
||||
|
||||
// 状态标签走 common.status.*;未收录的状态原样展示(数据层保留英文状态值)
|
||||
const clusterStatusLabel = (s: string) => {
|
||||
const key = `status.${s}`
|
||||
const label = tCommon(key)
|
||||
return label === key ? s : label
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建 Loki 部署</DialogTitle>
|
||||
<DialogTitle>{t('form.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
@@ -136,7 +148,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>部署名称 *</FormLabel>
|
||||
<FormLabel>{t('form.name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="production-loki" {...field} />
|
||||
</FormControl>
|
||||
@@ -149,9 +161,9 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>描述</FormLabel>
|
||||
<FormLabel>{t('form.description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="生产环境 Loki 日志服务" {...field} />
|
||||
<Textarea placeholder={t('form.descriptionPlaceholder')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -164,7 +176,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
name="version"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>版本</FormLabel>
|
||||
<FormLabel>{t('form.version')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
@@ -188,7 +200,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
name="mode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>部署方式</FormLabel>
|
||||
<FormLabel>{t('form.mode')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={(next) => {
|
||||
field.onChange(next)
|
||||
@@ -198,8 +210,8 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="k8s-yaml">Kubernetes</SelectItem>
|
||||
<SelectItem value="host-docker">主机 Docker</SelectItem>
|
||||
<SelectItem value="k8s-yaml">{t('form.modeK8s')}</SelectItem>
|
||||
<SelectItem value="host-docker">{t('form.modeDocker')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
@@ -216,16 +228,16 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
name="clusterID"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>目标集群 *</FormLabel>
|
||||
<FormLabel>{t('form.cluster')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="请选择集群" />
|
||||
<SelectValue placeholder={t('form.clusterPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clusters.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>
|
||||
{c.name}({statusLabel(c.status)})
|
||||
{c.name}({clusterStatusLabel(c.status)})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -240,7 +252,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
name="namespace"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>命名空间 *</FormLabel>
|
||||
<FormLabel>{t('form.namespace')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="log" {...field} />
|
||||
</FormControl>
|
||||
@@ -257,11 +269,11 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
name="hostID"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>目标主机 *</FormLabel>
|
||||
<FormLabel>{t('form.host')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="请选择主机" />
|
||||
<SelectValue placeholder={t('form.hostPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{hosts.map((h) => (
|
||||
@@ -278,13 +290,13 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
/>
|
||||
<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>
|
||||
<FormItem><FormLabel>{t('form.listenIP')}</FormLabel><FormControl><Input placeholder={hosts.find((h) => String(h.id) === hostID)?.hostIP || t('form.listenIPPlaceholder')} {...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>
|
||||
<FormItem><FormLabel>{t('form.listenPort')}</FormLabel><FormControl><Input type="number" min={1} max={65535} {...field} /></FormControl><FormMessage /></FormItem>
|
||||
)} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">通过 SSH 在主机启动 Docker 容器,配置和数据保存在主机本地。</p>
|
||||
<p className="text-xs text-muted-foreground">{t('form.dockerHint')}</p>
|
||||
</>)}
|
||||
|
||||
<FormField
|
||||
@@ -292,14 +304,14 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
name="storageConfigID"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>存储配置</FormLabel>
|
||||
<FormLabel>{t('form.storage')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="请选择存储(可选)" />
|
||||
<SelectValue placeholder={t('form.storagePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">本地存储(默认)</SelectItem>
|
||||
<SelectItem value="0">{t('form.localStorage')}</SelectItem>
|
||||
{storages.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.name}({s.storageType})
|
||||
@@ -319,7 +331,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
name="replicas"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>副本数</FormLabel>
|
||||
<FormLabel>{t('form.replicas')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" min={1} max={10} {...field} />
|
||||
</FormControl>
|
||||
@@ -332,7 +344,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
name="retentionDays"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>保留天数</FormLabel>
|
||||
<FormLabel>{t('form.retentionDays')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" min={1} {...field} />
|
||||
</FormControl>
|
||||
@@ -345,7 +357,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
name="storageSize"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>存储容量</FormLabel>
|
||||
<FormLabel>{t('form.storageSize')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="20Gi" {...field} />
|
||||
</FormControl>
|
||||
@@ -374,7 +386,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
name="resourcesMem"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>内存</FormLabel>
|
||||
<FormLabel>{t('form.memory')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="2Gi" {...field} />
|
||||
</FormControl>
|
||||
@@ -395,7 +407,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<FormLabel>启用多租户鉴权</FormLabel>
|
||||
<FormLabel>{t('form.authEnabled')}</FormLabel>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -408,7 +420,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
||||
className="w-full bg-primary hover:bg-primary/90"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? '创建中...' : '创建部署'}
|
||||
{submitting ? t('form.creating') : t('form.create')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Terminal } from 'lucide-react'
|
||||
import {
|
||||
@@ -21,6 +22,7 @@ export const LogViewerDialog = memo(function LogViewerDialog({
|
||||
deployment,
|
||||
onClose,
|
||||
}: LogViewerDialogProps) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [logs, setLogs] = useState<DeploymentLog[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -37,7 +39,7 @@ export const LogViewerDialog = memo(function LogViewerDialog({
|
||||
setLogs(res.list ?? [])
|
||||
} catch (err) {
|
||||
if (current !== versionRef.current) return
|
||||
setError(err instanceof Error ? err.message : '加载部署日志失败')
|
||||
setError(err instanceof Error ? err.message : t('logs.errLoad'))
|
||||
} finally {
|
||||
if (current === versionRef.current) {
|
||||
setLoading(false)
|
||||
@@ -58,7 +60,7 @@ export const LogViewerDialog = memo(function LogViewerDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5 text-primary" />
|
||||
部署日志 - {deployment?.name}
|
||||
{t('logs.title', { name: deployment?.name ?? '' })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -79,10 +81,11 @@ export const LogViewerDialog = memo(function LogViewerDialog({
|
||||
|
||||
/** 终端风格日志列表 */
|
||||
const LogView = memo(function LogView({ logs }: { logs: DeploymentLog[] }) {
|
||||
const { t } = useTranslation('deployments')
|
||||
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>
|
||||
<p className="text-muted-foreground">{t('logs.empty')}</p>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
@@ -8,9 +9,10 @@ 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 i18n from '@/i18n'
|
||||
import type { LokiDeployment } from '@/types'
|
||||
|
||||
const schema = z.object({ yaml: z.string().trim().min(1, '请输入 YAML 配置') })
|
||||
const schema = z.object({ yaml: z.string().trim().min(1, i18n.t('deployments:retention.yamlRequired')) })
|
||||
type Values = z.infer<typeof schema>
|
||||
|
||||
interface Props {
|
||||
@@ -19,14 +21,14 @@ interface Props {
|
||||
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`
|
||||
function configLocation(deployment: LokiDeployment, t: (k: string, o?: Record<string, unknown>) => string) {
|
||||
if (deployment.mode === 'host-binary') return t('retention.locationHostBinary')
|
||||
if (deployment.mode === 'host-docker') return t('retention.locationHostDocker', { id: deployment.id })
|
||||
return t('retention.locationK8s', { namespace: deployment.namespace || 'log' })
|
||||
}
|
||||
|
||||
export const RetentionDialog = memo(function RetentionDialog({ deployment, onClose, onSaved }: Props) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [liveConfig, setLiveConfig] = useState<{ location: string; yaml: string } | null>(null)
|
||||
const [configLoading, setConfigLoading] = useState(false)
|
||||
@@ -51,7 +53,7 @@ export const RetentionDialog = memo(function RetentionDialog({ deployment, onClo
|
||||
try {
|
||||
setLiveConfig(await deploymentApi.liveConfig(deployment.id))
|
||||
} catch (err) {
|
||||
setConfigError(err instanceof Error ? err.message : '读取运行配置失败')
|
||||
setConfigError(err instanceof Error ? err.message : t('retention.errLoad'))
|
||||
} finally {
|
||||
setConfigLoading(false)
|
||||
}
|
||||
@@ -64,9 +66,9 @@ export const RetentionDialog = memo(function RetentionDialog({ deployment, onClo
|
||||
const result = await deploymentApi.updateRetention(deployment.id, yaml, apply)
|
||||
onSaved()
|
||||
onClose()
|
||||
if (apply) toast.info(`已保存 ${result.retentionDays} 天,Loki 正在重新部署`)
|
||||
if (apply) toast.info(t('retention.savedApplied', { days: result.retentionDays }))
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '保存 Loki 配置失败')
|
||||
toast.error(err instanceof Error ? err.message : t('retention.errSave'))
|
||||
onSaved()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
@@ -77,23 +79,22 @@ export const RetentionDialog = memo(function RetentionDialog({ deployment, onClo
|
||||
<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>
|
||||
<DialogTitle>{deployment ? t('retention.title', { name: deployment.name }) : ''}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>配置位置:{deployment ? configLocation(deployment) : ''}</p>
|
||||
<p>保留策略由 Loki compactor 执行;MinIO 只负责存储日志对象。</p>
|
||||
<p>{t('retention.locationLabel')}{deployment ? configLocation(deployment, t) : ''}</p>
|
||||
<p>{t('retention.compactorNote')}</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>
|
||||
<p className="text-sm font-medium">{t('retention.liveTitle')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('retention.liveHint')}</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={loadConfig} disabled={configLoading || deployment?.mode === 'k8s-helm'}>
|
||||
{configLoading ? '读取中…' : liveConfig ? '刷新配置' : '查看完整配置'}
|
||||
<Button type="button" variant="outline" size="sm" onClick={loadConfig} disabled={configLoading}>
|
||||
{configLoading ? t('retention.loading') : liveConfig ? t('retention.refresh') : t('retention.view')}
|
||||
</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>
|
||||
@@ -103,7 +104,7 @@ export const RetentionDialog = memo(function RetentionDialog({ deployment, onClo
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/40 p-3 text-xs">
|
||||
<p className="mb-1 font-medium text-foreground">由部署模板生成</p>
|
||||
<p className="mb-1 font-medium text-foreground">{t('retention.templateNote')}</p>
|
||||
<pre className="text-muted-foreground">{'compactor:\n retention_enabled: true\n retention_delete_delay: 2h'}</pre>
|
||||
</div>
|
||||
<Form {...form}>
|
||||
@@ -113,28 +114,24 @@ export const RetentionDialog = memo(function RetentionDialog({ deployment, onClo
|
||||
name="yaml"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>保留配置 YAML</FormLabel>
|
||||
<FormLabel>{t('retention.yamlLabel')}</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>
|
||||
<p className="text-xs text-muted-foreground">{t('retention.yamlHint')}</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{deployment?.status === 'running'
|
||||
? '仅保存会更新部署记录;保存并应用会重新部署 Loki,查询可能短暂中断。'
|
||||
: '保存后在下次部署时生效。'}
|
||||
{deployment?.status === 'running' ? t('retention.applyHintRunning') : t('retention.applyHintIdle')}
|
||||
</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>
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>{t('retention.cancel')}</Button>
|
||||
<Button type="submit" variant="outline" disabled={saving}>{t('retention.saveOnly')}</Button>
|
||||
{deployment?.status === 'running' && (
|
||||
<Button type="button" onClick={save(true)} disabled={saving}>
|
||||
{saving ? '处理中…' : '保存并应用'}
|
||||
{saving ? t('retention.processing') : t('retention.saveApply')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
import type { TFunction } from 'i18next'
|
||||
|
||||
// 部署表单 schema 与常量 — zod 为唯一校验源,消息中文且具体
|
||||
// (纯数据/纯逻辑抽离,见开发标准 07 章页面内聚 utils 范式)
|
||||
// 部署表单 schema 与常量 — zod 为唯一校验源,校验消息经 t 取词典
|
||||
// (纯数据/纯逻辑抽离,见开发标准 07 章页面内聚 utils 范式;
|
||||
// schema 为工厂函数:t 随语言切换变化,组件内 useMemo 按 t 重建)
|
||||
// 版本下拉兜底值;实际列表运行时从 GET /deployments/versions 拉取
|
||||
export const FALLBACK_VERSIONS = ['3.0.0', '2.9.8', '2.9.7', '2.9.6']
|
||||
|
||||
@@ -26,51 +28,52 @@ const parseGiB = (v: string): number | null => {
|
||||
return n
|
||||
}
|
||||
|
||||
export const deploymentFormSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, '请输入部署名称'),
|
||||
description: z.string(),
|
||||
version: z.string().min(1, '请选择 Loki 版本'),
|
||||
mode: z.enum(['k8s-yaml', 'host-docker']),
|
||||
clusterID: z.string(),
|
||||
hostID: z.string(),
|
||||
listenIP: z.string(),
|
||||
listenPort: z.string(),
|
||||
namespace: z.string(),
|
||||
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, '请填写存储容量(如 20Gi)'),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (isK8sMode(data.mode) && !data.clusterID) {
|
||||
ctx.addIssue({ code: 'custom', path: ['clusterID'], message: '请选择目标集群' })
|
||||
}
|
||||
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: '请填写命名空间' })
|
||||
}
|
||||
if (isK8sMode(data.mode)) {
|
||||
const gi = parseGiB(data.storageSize)
|
||||
if (gi === null || gi < 20) {
|
||||
ctx.addIssue({ code: 'custom', path: ['storageSize'], message: '阿里云 ESSD 最小 20Gi,请填 20Gi 或以上' })
|
||||
export const buildDeploymentFormSchema = (t: TFunction) =>
|
||||
z
|
||||
.object({
|
||||
name: z.string().min(1, t('form.nameRequired')),
|
||||
description: z.string(),
|
||||
version: z.string().min(1, t('form.versionRequired')),
|
||||
mode: z.enum(['k8s-yaml', 'host-docker']),
|
||||
clusterID: z.string(),
|
||||
hostID: z.string(),
|
||||
listenIP: z.string(),
|
||||
listenPort: z.string(),
|
||||
namespace: z.string(),
|
||||
storageConfigID: z.string(),
|
||||
authEnabled: z.boolean(),
|
||||
retentionDays: z
|
||||
.string()
|
||||
.refine((v) => isPositiveInt(v, 1), t('form.retentionInvalid')),
|
||||
replicas: z
|
||||
.string()
|
||||
.refine((v) => isPositiveInt(v, 1, 10), t('form.replicasInvalid')),
|
||||
resourcesCPU: z.string().min(1, t('form.cpuRequired')),
|
||||
resourcesMem: z.string().min(1, t('form.memRequired')),
|
||||
storageSize: z.string().min(1, t('form.storageSizeRequired')),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (isK8sMode(data.mode) && !data.clusterID) {
|
||||
ctx.addIssue({ code: 'custom', path: ['clusterID'], message: t('form.clusterRequired') })
|
||||
}
|
||||
}
|
||||
})
|
||||
if (data.mode === 'host-docker' && !data.hostID) {
|
||||
ctx.addIssue({ code: 'custom', path: ['hostID'], message: t('form.hostRequired') })
|
||||
}
|
||||
if (data.mode === 'host-docker' && !isPositiveInt(data.listenPort, 1, 65535)) {
|
||||
ctx.addIssue({ code: 'custom', path: ['listenPort'], message: t('form.portInvalid') })
|
||||
}
|
||||
if (isK8sMode(data.mode) && !data.namespace.trim()) {
|
||||
ctx.addIssue({ code: 'custom', path: ['namespace'], message: t('form.namespaceRequired') })
|
||||
}
|
||||
if (isK8sMode(data.mode)) {
|
||||
const gi = parseGiB(data.storageSize)
|
||||
if (gi === null || gi < 20) {
|
||||
ctx.addIssue({ code: 'custom', path: ['storageSize'], message: t('form.storageSizeMin') })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export type DeploymentFormValues = z.infer<typeof deploymentFormSchema>
|
||||
export type DeploymentFormValues = z.infer<ReturnType<typeof buildDeploymentFormSchema>>
|
||||
|
||||
export const DEFAULT_VALUES: DeploymentFormValues = {
|
||||
name: '',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Rocket, Plus } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { clusterApi, deploymentApi, hostApi, storageApi } from '@/api'
|
||||
@@ -22,6 +23,7 @@ import { LogViewerDialog } from './components/LogViewerDialog'
|
||||
* 卡片/表单/日志查看均为域内子组件。成功操作静默(列表刷新即反馈)。
|
||||
*/
|
||||
const DeploymentsPage = memo(function DeploymentsPage() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const query = useListQuery<LokiDeployment>((params, signal) =>
|
||||
deploymentApi.list(params, { signal })
|
||||
)
|
||||
@@ -67,12 +69,12 @@ const DeploymentsPage = memo(function DeploymentsPage() {
|
||||
setStorages(storageRes.list)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err instanceof Error ? err.message : '加载集群/主机/存储数据失败')
|
||||
toast.error(err instanceof Error ? err.message : t('page.errLoadRefs'))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
}, [t])
|
||||
|
||||
const handleDeploy = useCallback(
|
||||
async (id: number) => {
|
||||
@@ -81,12 +83,12 @@ const DeploymentsPage = memo(function DeploymentsPage() {
|
||||
await deploymentApi.deploy(id)
|
||||
addTimeout(query.refresh, 2000)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '部署失败')
|
||||
toast.error(err instanceof Error ? err.message : t('page.errDeploy'))
|
||||
} finally {
|
||||
addTimeout(() => setOperating(null), 5000)
|
||||
}
|
||||
},
|
||||
[query, addTimeout]
|
||||
[query, addTimeout, t]
|
||||
)
|
||||
|
||||
const handleStop = useCallback(
|
||||
@@ -95,10 +97,10 @@ const DeploymentsPage = memo(function DeploymentsPage() {
|
||||
await deploymentApi.stop(id)
|
||||
query.refresh()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '停止失败')
|
||||
toast.error(err instanceof Error ? err.message : t('page.errStop'))
|
||||
}
|
||||
},
|
||||
[query]
|
||||
[query, t]
|
||||
)
|
||||
|
||||
const handleRestart = useCallback(
|
||||
@@ -108,12 +110,12 @@ const DeploymentsPage = memo(function DeploymentsPage() {
|
||||
await deploymentApi.restart(id)
|
||||
addTimeout(query.refresh, 3000)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '重启失败')
|
||||
toast.error(err instanceof Error ? err.message : t('page.errRestart'))
|
||||
} finally {
|
||||
addTimeout(() => setOperating(null), 5000)
|
||||
}
|
||||
},
|
||||
[query, addTimeout]
|
||||
[query, addTimeout, t]
|
||||
)
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
@@ -125,23 +127,20 @@ const DeploymentsPage = memo(function DeploymentsPage() {
|
||||
setDeleteTarget(null)
|
||||
query.refresh()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '删除部署失败')
|
||||
toast.error(err instanceof Error ? err.message : t('page.errDelete'))
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}, [deleteTarget, query])
|
||||
}, [deleteTarget, query, t])
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="部署Loki"
|
||||
description="部署与管理 Grafana Loki 日志聚合服务"
|
||||
title={t('page.title')}
|
||||
description={t('page.description')}
|
||||
action={
|
||||
<Button
|
||||
className="bg-primary hover:bg-primary/90"
|
||||
onClick={() => setFormOpen(true)}
|
||||
>
|
||||
<Button onClick={() => setFormOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
新建部署
|
||||
{t('page.create')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
@@ -152,8 +151,8 @@ const DeploymentsPage = memo(function DeploymentsPage() {
|
||||
) : query.list.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Rocket}
|
||||
title="暂无部署"
|
||||
description="创建你的第一个 Loki 部署"
|
||||
title={t('page.emptyTitle')}
|
||||
description={t('page.emptyDescription')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -208,6 +207,11 @@ const DeploymentsPage = memo(function DeploymentsPage() {
|
||||
}}
|
||||
name={deleteTarget?.name}
|
||||
loading={deleting}
|
||||
description={
|
||||
deleteTarget?.mode === 'host-docker'
|
||||
? t('page.deleteDockerDesc', { name: deleteTarget?.name })
|
||||
: t('page.deleteRecordDesc', { name: deleteTarget?.name })
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</PageContainer>
|
||||
|
||||
Reference in new issue
Block a user