feat(deployments,analysis): 保留弹框重设计 + 分析页命名空间筛选 + 过期 chunk 真正删除
=== 一、保留策略弹框重设计(RetentionDialog)===
旧版问题:
1. 主输入是 YAML textarea,用户必须会写 `limits_config.retention_period: 30d`
2. 三个按钮:取消 / 仅保存 / 保存并应用 —— 「仅保存」只改部署记录不重载 Loki,
今天已经造成一次真实故障:DB 记录 7 天、运行时 ConfigMap 是 24h,配置漂移
3. 「查看完整配置」是独立按钮 + 独立状态,多一次点击
4. 硬编码提示 `retention_delete_delay: 2h`,而实际已改成 10m,属于误导
新版:
- 主输入改为**保留天数数字框**(1–3650),YAML 由前端拼,用户不用写
- 快捷预设 1/7/14/30/90,实时显示「= 720 小时」
- >7 天给出存储成本提示
- 完整运行 config 改成 `<details>` 折叠,展开即自动加载(去掉独立按钮)
- **按钮只剩 2 个:取消 / 保存并应用**
- 删掉硬编码的 compactor 提示块
=== 二、日志分析页加命名空间筛选 ===
根因:analysis 页写死 namespace: '',等价 `{namespace=~".+"}` —— 一次分析扫全集群
(实测约 370 GB/天),5 条 count_over_time 跑 30s 都出不来,页面永远转圈。
- 新增命名空间下拉(从 Loki label values 拉取,localStorage 记忆)
- 默认取业务命名空间 astralx(其次列表首个),不再默认全集群
- 「全部命名空间(较慢)」降级为显式选项
- 命名空间未就绪时先显示 loading,避免先发一次全集群查询
- 5 条查询改**分批(每批 2 条)**执行 + 429 自动重试一次
=== 三、后端并发/超时调整 ===
- 重查询并发上限 2 → 4,且拿不到令牌时**排队最多 10s**再拒绝
(原来立即 429,导致分析页 5 条并发里有 3 条直接失败)
- maxLogQueryLimit 1000 → 5000,与前端下拉完全对齐(原来前端选 5000 会被静默截断)
=== 四、过期 chunk 真正删除(关键修复)===
实测发现:索引表已从 COS 删除,但 chunk 对象仍残留 21,354 个 / 24.8 GB,
最老的 45 小时前 —— 因为 Loki 默认 `retention_delete_delay` = **2h**,
标记后要等 2 小时才真正删 chunk。
- 配置显式设置 `compactor.retention_delete_delay: 10m`
- 已用 `loki -verify-config` 校验(config is valid)
- 删除前后对比:
对象数 46,536 → 23,934;占用 53.61 GB → 27.25 GB
超期对象 21,354 个 / 24.8 GB → **0 个**
桶内最老对象 45.2h → 24.4h(= 保留期 1 天)
- deployment_config_test 同步调整:retention_delete_delay 从"禁止出现"移入"必须存在"
镜像:lghost/lokistack:latest + v0.1 = sha256:b4d98e016bca
This commit is contained in:
1 parent
53c7d64b35
commit
a32f93f533
12 files changed
+265
-97
No files matched your search
@@ -6,15 +6,37 @@ 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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import i18n from '@/i18n'
|
||||
import type { LokiDeployment } from '@/types'
|
||||
|
||||
const schema = z.object({ yaml: z.string().trim().min(1, i18n.t('deployments:retention.yamlRequired')) })
|
||||
/**
|
||||
* 保留策略编辑弹框。
|
||||
*
|
||||
* 相对旧版的改动(旧版让用户直接编辑 YAML):
|
||||
* 1. 主输入改成**天数数字**,用户只需要填一个数字,不用懂 YAML;
|
||||
* YAML 由前端按 `limits_config.retention_period: <N>d` 生成(后端只认这一项)。
|
||||
* 2. 完整运行配置改为**默认折叠、展开即自动加载**,去掉多余的「查看完整配置」按钮。
|
||||
* 3. 按钮只保留 **取消 / 保存并应用** —— 去掉「仅保存」:
|
||||
* 它只改部署记录不重载 Loki,容易造成「记录 7 天、运行中 24h」的配置漂移。
|
||||
* 4. 删掉硬编码的 `retention_delete_delay: 2h` 提示块(实际已改为 10m,会误导)。
|
||||
*/
|
||||
// 注意:不要用 z.coerce.number() —— 它会把表单输入类型推成 unknown,
|
||||
// 与 react-hook-form 的 useForm<Values> 泛型冲突(TS2322)。
|
||||
// 这里用原生 number 校验,输入框里做字符串↔数字转换。
|
||||
const schema = z.object({
|
||||
days: z
|
||||
.number({ error: i18n.t('deployments:retention.daysInteger') })
|
||||
.int(i18n.t('deployments:retention.daysInteger'))
|
||||
.min(1, i18n.t('deployments:retention.daysRange'))
|
||||
.max(3650, i18n.t('deployments:retention.daysRange')),
|
||||
})
|
||||
type Values = z.infer<typeof schema>
|
||||
|
||||
const PRESETS = [1, 7, 14, 30, 90]
|
||||
|
||||
interface Props {
|
||||
deployment: LokiDeployment | null
|
||||
onClose: () => void
|
||||
@@ -35,19 +57,19 @@ export const RetentionDialog = memo(function RetentionDialog({ deployment, onClo
|
||||
const [configError, setConfigError] = useState('')
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { yaml: 'limits_config:\n retention_period: 30d\n' },
|
||||
defaultValues: { days: 30 },
|
||||
})
|
||||
const days = form.watch('days')
|
||||
|
||||
useEffect(() => {
|
||||
if (deployment) {
|
||||
form.reset({ yaml: `limits_config:\n retention_period: ${deployment.retentionDays || 30}d\n` })
|
||||
}
|
||||
if (deployment) form.reset({ days: deployment.retentionDays || 30 })
|
||||
setLiveConfig(null)
|
||||
setConfigError('')
|
||||
}, [deployment, form])
|
||||
|
||||
/** 展开「运行中的完整配置」时才去读,避免一打开弹框就打后端 */
|
||||
const loadConfig = async () => {
|
||||
if (!deployment) return
|
||||
if (!deployment || liveConfig || configLoading) return
|
||||
setConfigLoading(true)
|
||||
setConfigError('')
|
||||
try {
|
||||
@@ -59,14 +81,16 @@ export const RetentionDialog = memo(function RetentionDialog({ deployment, onClo
|
||||
}
|
||||
}
|
||||
|
||||
const save = (apply: boolean) => form.handleSubmit(async ({ yaml }) => {
|
||||
const submit = form.handleSubmit(async ({ days: value }) => {
|
||||
if (!deployment) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await deploymentApi.updateRetention(deployment.id, yaml, apply)
|
||||
// 后端只认 limits_config.retention_period 这一项,YAML 由前端拼,用户不用写
|
||||
const yaml = `limits_config:\n retention_period: ${value}d\n`
|
||||
const result = await deploymentApi.updateRetention(deployment.id, yaml, true)
|
||||
onSaved()
|
||||
onClose()
|
||||
if (apply) toast.info(t('retention.savedApplied', { days: result.retentionDays }))
|
||||
toast.info(t('retention.savedApplied', { days: result.retentionDays }))
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('retention.errSave'))
|
||||
onSaved()
|
||||
@@ -77,63 +101,99 @@ export const RetentionDialog = memo(function RetentionDialog({ deployment, onClo
|
||||
|
||||
return (
|
||||
<Dialog open={deployment !== null} onOpenChange={(open) => { if (!open && !saving) onClose() }}>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{deployment ? t('retention.title', { name: deployment.name }) : ''}</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{t('retention.locationLabel')}{deployment ? configLocation(deployment, t) : ''}
|
||||
</DialogDescription>
|
||||
</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">
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="yaml"
|
||||
name="days"
|
||||
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>
|
||||
<FormLabel>{t('retention.daysLabel')}</FormLabel>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={3650}
|
||||
className="h-9 w-28 tabular-nums"
|
||||
name={field.name}
|
||||
onBlur={field.onBlur}
|
||||
ref={field.ref}
|
||||
value={Number.isFinite(field.value) ? field.value : ''}
|
||||
onChange={event => {
|
||||
const raw = event.target.value
|
||||
field.onChange(raw === '' ? Number.NaN : Number(raw))
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<span className="text-sm text-muted-foreground">{t('retention.daysUnit')}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{Number.isFinite(Number(days)) && Number(days) > 0
|
||||
? t('retention.daysConvert', { hours: Number(days) * 24 })
|
||||
: ''}
|
||||
</span>
|
||||
<div className="ml-auto flex flex-wrap gap-1">
|
||||
{PRESETS.map(preset => (
|
||||
<Button
|
||||
key={preset}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={Number(days) === preset ? 'default' : 'outline'}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => form.setValue('days', preset, { shouldValidate: true })}
|
||||
>
|
||||
{preset}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('retention.daysHint')}</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{Number(days) > 7 && (
|
||||
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-foreground">
|
||||
{t('retention.costWarning', { days: Number(days) })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{deployment?.status === 'running' ? t('retention.applyHintRunning') : t('retention.applyHintIdle')}
|
||||
</p>
|
||||
|
||||
{/* 完整运行配置:默认折叠,展开即加载 */}
|
||||
<details className="rounded-md border border-border" onToggle={event => { if ((event.target as HTMLDetailsElement).open) void loadConfig() }}>
|
||||
<summary className="cursor-pointer select-none px-3 py-2 text-sm font-medium">
|
||||
{t('retention.liveToggle')}
|
||||
</summary>
|
||||
<div className="space-y-2 border-t border-border px-3 py-2">
|
||||
{configLoading && <p className="text-xs text-muted-foreground">{t('retention.loading')}</p>}
|
||||
{configError && <p className="text-xs text-destructive">{configError}</p>}
|
||||
{liveConfig && (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">{liveConfig.location}</p>
|
||||
<pre className="max-h-[40vh] max-w-full overflow-auto rounded-md bg-muted/50 p-3 font-mono text-xs whitespace-pre">{liveConfig.yaml}</pre>
|
||||
<p className="text-xs text-muted-foreground">{t('retention.liveHint')}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<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>
|
||||
)}
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? t('retention.processing') : t('retention.saveApply')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
Reference in new issue
Block a user