Files
LokiStack/frontend/src/pages/deployments/components/RetentionDialog.tsx
T
opsx f3733f0bfc 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 未翻译、词典双逗号等
2026-10-27 17:11:38 +08:00

144 lines
6.2 KiB
TypeScript

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'
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 i18n from '@/i18n'
import type { LokiDeployment } from '@/types'
const schema = z.object({ yaml: z.string().trim().min(1, i18n.t('deployments:retention.yamlRequired')) })
type Values = z.infer<typeof schema>
interface Props {
deployment: LokiDeployment | null
onClose: () => void
onSaved: () => void
}
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)
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 : t('retention.errLoad'))
} 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(t('retention.savedApplied', { days: result.retentionDays }))
} catch (err) {
toast.error(err instanceof Error ? err.message : t('retention.errSave'))
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>{deployment ? t('retention.title', { name: deployment.name }) : ''}</DialogTitle>
</DialogHeader>
<div className="space-y-1 text-sm text-muted-foreground">
<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">{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}>
{configLoading ? t('retention.loading') : liveConfig ? t('retention.refresh') : t('retention.view')}
</Button>
</div>
{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] max-w-full 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">{t('retention.templateNote')}</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>{t('retention.yamlLabel')}</FormLabel>
<FormControl>
<Textarea rows={4} spellCheck={false} className="font-mono text-sm" {...field} />
</FormControl>
<p className="text-xs text-muted-foreground">{t('retention.yamlHint')}</p>
<FormMessage />
</FormItem>
)}
/>
<p className="text-sm text-muted-foreground">
{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}>{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 ? t('retention.processing') : t('retention.saveApply')}
</Button>
)}
</div>
</form>
</Form>
</DialogContent>
</Dialog>
)
})