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:
opsx committed 2026-10-28 17:05:57 +00:00
1 parent 53c7d64b35
commit a32f93f533
12 files changed
+265 -97

No files matched your search

+5 -2
View File
@@ -67,12 +67,15 @@ func TestGeneratedLokiConfigKeepsRequiredSettingsWithoutDefaults(t *testing.T) {
// loki_config.go 里对 s3/oss/minio/cos/obs 刻意把 compactor 的删除请求存到 // loki_config.go 里对 s3/oss/minio/cos/obs 刻意把 compactor 的删除请求存到
// 本地盘(见该处注释),避免各云 S3 兼容实现差异导致 compactor 启动失败。 // 本地盘(见该处注释),避免各云 S3 兼容实现差异导致 compactor 启动失败。
// loki_object_store_test.go 断言的也是 filesystem —— 两处必须一致。 // loki_object_store_test.go 断言的也是 filesystem —— 两处必须一致。
for _, required := range []string{"retention_period: 2160h", "retention_enabled: true", "delete_request_store: filesystem", "bucketnames: logs", "store: tsdb", "schema: v13"} { for _, required := range []string{"retention_period: 2160h", "retention_enabled: true", "delete_request_store: filesystem", "retention_delete_delay: 10m", "bucketnames: logs", "store: tsdb", "schema: v13"} {
if !strings.Contains(config, required) { if !strings.Contains(config, required) {
t.Fatalf("required setting %q missing", required) t.Fatalf("required setting %q missing", required)
} }
} }
for _, defaultSetting := range []string{"http_listen_port:", "grpc_listen_port:", "chunk_idle_period:", "compaction_interval:", "retention_delete_delay:", "max_entries_limit_per_query:", "frontend_worker:", "chunk_store_config:", "autoforget_unhealthy:"} { // 注意:retention_delete_delay 已从"不该出现"清单移除 —— 现在**有意**配置为 10m。
// 默认 2h 会让对象存储里多留 2 小时数据(实测桶内超期对象一度占 24.6GB),
// 预发环境用 10m 既保留安全窗口又不白付存储费。
for _, defaultSetting := range []string{"http_listen_port:", "grpc_listen_port:", "chunk_idle_period:", "compaction_interval:", "max_entries_limit_per_query:", "frontend_worker:", "chunk_store_config:", "autoforget_unhealthy:"} {
if strings.Contains(config, defaultSetting) { if strings.Contains(config, defaultSetting) {
t.Fatalf("unnecessary setting %q remained", defaultSetting) t.Fatalf("unnecessary setting %q remained", defaultSetting)
} }
+21 -6
View File
@@ -23,10 +23,11 @@ import (
"lokistack/repository" "lokistack/repository"
) )
// maxLogQueryLimit 单页硬上限。依据:实测每条日志约 671B—— // maxLogQueryLimit 单页硬上限,与前端下拉选项保持**完全一致**,
// 1000 条 ≈ 655KB 响应;而前端结果表**没有虚拟滚动**,1000 行会全部渲染成 // 避免"前端能选 5000、后端静默截成 1000"这种假选项。
// DOM 节点导致卡顿。前端下拉只给到 1000,超过会被这里静默收敛。 // 取值 5000 = Loki limits_config.max_entries_limit_per_query 的默认上限;
const maxLogQueryLimit = 1000 // 前端结果表已加虚拟滚动(>100 条只渲染可视区),5000 行不会卡。
const maxLogQueryLimit = 5000
const logQueryTimeout = 120 * time.Second const logQueryTimeout = 120 * time.Second
var lokiHTTPClients = [2]*http.Client{ var lokiHTTPClients = [2]*http.Client{
@@ -417,8 +418,13 @@ const (
maxHistogramRange = 7 * 24 * time.Hour maxHistogramRange = 7 * 24 * time.Hour
// 重查询(histogram/metrics)单独的更短超时,避免长时间占用 CPU // 重查询(histogram/metrics)单独的更短超时,避免长时间占用 CPU
heavyQueryTimeout = 30 * time.Second heavyQueryTimeout = 30 * time.Second
// 全局并发重查询上限:超过直接快速失败,保护单实例 Loki // 全局并发重查询上限。取 4 而不是 2:
maxConcurrentHeavyQueries = 2 // 日志分析页一次会并发 5 条 count_over_time,限制 2 会让大部分请求拿不到令牌。
maxConcurrentHeavyQueries = 4
// 拿不到令牌时最多排队 10 秒再拒绝。
// 原来"立即 429"会把分析页 5 条并发里的 3 条直接打回,
// 用户看到的就是"当前有较多日志量统计在进行"。
heavyQueryWait = 10 * time.Second
) )
// ErrTooManyQueries 并发重查询超限(handler 映射为 429) // ErrTooManyQueries 并发重查询超限(handler 映射为 429)
@@ -427,11 +433,20 @@ var ErrTooManyQueries = errors.New("当前有较多日志量统计在进行,
// heavyQuerySem 全局重查询信号量 // heavyQuerySem 全局重查询信号量
var heavyQuerySem = make(chan struct{}, maxConcurrentHeavyQueries) var heavyQuerySem = make(chan struct{}, maxConcurrentHeavyQueries)
// acquireHeavyQuery 先尝试立即获取;拿不到则最多排队 heavyQueryWait。
// 突发(如分析页并发 5 条)会自然排队消化,只有持续超载才返回 429。
func acquireHeavyQuery() bool { func acquireHeavyQuery() bool {
select { select {
case heavyQuerySem <- struct{}{}: case heavyQuerySem <- struct{}{}:
return true return true
default: default:
}
timer := time.NewTimer(heavyQueryWait)
defer timer.Stop()
select {
case heavyQuerySem <- struct{}{}:
return true
case <-timer.C:
return false return false
} }
} }
+4
View File
@@ -53,6 +53,10 @@ compactor:
working_directory: /var/lib/loki/compactor working_directory: /var/lib/loki/compactor
retention_enabled: true retention_enabled: true
delete_request_store: {{ .CompactorStoreType }} delete_request_store: {{ .CompactorStoreType }}
# 过期 chunk 的「标记 → 真正删除」间隔。默认 2h 会让对象存储里
# 多留 2 小时的数据(实测桶内超过保留期的对象一度占到 24.6 GB)。
# 预发环境取 10m:既保留短暂的安全窗口,又不会长期占用存储成本。
retention_delete_delay: 10m
limits_config: limits_config:
retention_period: {{ .RetentionPeriod }}h retention_period: {{ .RetentionPeriod }}h
+3
View File
@@ -14,6 +14,9 @@ export default {
filter: { filter: {
source: 'Source', source: 'Source',
sourceEmpty: 'No sources', sourceEmpty: 'No sources',
namespace: 'Namespace',
namespaceEmpty: 'Loading namespaces…',
namespaceAll: 'All namespaces (slow)',
refresh: 'Refresh', refresh: 'Refresh',
}, },
stats: { stats: {
+12 -13
View File
@@ -89,26 +89,25 @@ export default {
storageSizeMin: 'Alibaba Cloud ESSD requires at least 20Gi — enter 20Gi or more', storageSizeMin: 'Alibaba Cloud ESSD requires at least 20Gi — enter 20Gi or more',
}, },
retention: { retention: {
title: 'Edit Loki Config · {{name}}', title: 'Edit retention policy · {{name}}',
locationLabel: 'Config location: ', locationLabel: 'Config location: ',
locationHostBinary: '/etc/loki/loki.yaml → limits_config.retention_period', locationHostBinary: '/etc/loki/loki.yaml → limits_config.retention_period',
locationHostDocker: 'Host /etc/lokistack/loki-{{id}}/loki.yaml → limits_config.retention_period', locationHostDocker: 'Host /etc/lokistack/loki-{{id}}/loki.yaml → limits_config.retention_period',
locationK8s: 'ConfigMap {{namespace}}/loki-config → loki.yaml', locationK8s: 'ConfigMap {{namespace}}/loki-config → loki.yaml',
compactorNote: 'Retention is enforced by the Loki compactor; MinIO only stores the log objects.', compactorNote: 'Retention is enforced by the Loki compactor; MinIO only stores the log objects.',
liveTitle: 'Full live loki.yaml', liveHint: 'Read from the deployment target with storage credentials masked.',
liveHint: daysLabel: 'Retention days',
'Read from the deployment target with storage credentials masked; retention days below are edited separately.', daysUnit: 'days',
view: 'View Full Config', daysConvert: '= {{hours}} hours',
refresh: 'Refresh', daysHint: 'Just enter a number — no YAML needed. Range 1–3650 days.',
daysInteger: 'Enter a whole number of days',
daysRange: 'Days must be between 1 and 3650',
costWarning: 'Keeping {{days}} days grows object-storage usage linearly with retention.',
loading: 'Loading…', loading: 'Loading…',
templateNote: 'Generated by the deployment template', liveToggle: 'Show the full live loki.yaml (read-only)',
yamlLabel: 'Retention Config YAML', applyHintRunning: 'Save & Apply redeploys Loki; log queries may be briefly interrupted (~1 min).',
yamlHint: 'Edit retention_period, e.g. 90d / 2160h; only this setting is accepted.', applyHintIdle: 'Saves and deploys this Loki instance immediately.',
applyHintRunning:
'Save only updates the deployment record; Save & Apply redeploys Loki and queries may be briefly interrupted.',
applyHintIdle: 'Takes effect on the next deployment.',
cancel: 'Cancel', cancel: 'Cancel',
saveOnly: 'Save Only',
saveApply: 'Save & Apply', saveApply: 'Save & Apply',
processing: 'Processing…', processing: 'Processing…',
savedApplied: 'Saved {{days}} days — Loki is being redeployed', savedApplied: 'Saved {{days}} days — Loki is being redeployed',
+3
View File
@@ -13,6 +13,9 @@ export default {
filter: { filter: {
source: '数据源', source: '数据源',
sourceEmpty: '暂无数据源', sourceEmpty: '暂无数据源',
namespace: '命名空间',
namespaceEmpty: '正在加载命名空间…',
namespaceAll: '全部命名空间(较慢)',
refresh: '刷新', refresh: '刷新',
}, },
stats: { stats: {
+11 -9
View File
@@ -88,7 +88,7 @@ export default {
storageSizeMin: '阿里云 ESSD 最小 20Gi,请填 20Gi 或以上', storageSizeMin: '阿里云 ESSD 最小 20Gi,请填 20Gi 或以上',
}, },
retention: { retention: {
title: '编辑 Loki 配置 · {{name}}', title: '编辑保留策略 · {{name}}',
locationLabel: '配置位置:', locationLabel: '配置位置:',
locationHostBinary: '/etc/loki/loki.yaml → limits_config.retention_period', locationHostBinary: '/etc/loki/loki.yaml → limits_config.retention_period',
locationHostDocker: '主机 /etc/lokistack/loki-{{id}}/loki.yaml → limits_config.retention_period', locationHostDocker: '主机 /etc/lokistack/loki-{{id}}/loki.yaml → limits_config.retention_period',
@@ -96,16 +96,18 @@ export default {
compactorNote: '保留策略由 Loki compactor 执行;MinIO 只负责存储日志对象。', compactorNote: '保留策略由 Loki compactor 执行;MinIO 只负责存储日志对象。',
liveTitle: '运行中的完整 loki.yaml', liveTitle: '运行中的完整 loki.yaml',
liveHint: '从部署目标读取,存储凭证已遮盖;下方保留天数仍单独编辑。', liveHint: '从部署目标读取,存储凭证已遮盖;下方保留天数仍单独编辑。',
view: '查看完整配置', daysLabel: '保留天数',
refresh: '刷新配置', daysUnit: '天',
daysConvert: '= {{hours}} 小时',
daysHint: '填一个数字即可,无需写 YAML。范围 1–3650 天。',
daysInteger: '请填写整数天数',
daysRange: '天数需在 1–3650 之间',
costWarning: '保留 {{days}} 天会让对象存储占用随天数线性增长;清空历史日志请谨慎。',
loading: '读取中…', loading: '读取中…',
templateNote: '由部署模板生成', liveToggle: '查看运行中的完整 loki.yaml(只读)',
yamlLabel: '保留配置 YAML', applyHintRunning: '保存并应用会重新部署 Loki,查询可能短暂中断(约 1 分钟)。',
yamlHint: '编辑 retention_period,如 90d / 2160h;仅支持这一项。', applyHintIdle: '保存后会立即部署该 Loki 实例。',
applyHintRunning: '仅保存会更新部署记录;保存并应用会重新部署 Loki,查询可能短暂中断。',
applyHintIdle: '保存后在下次部署时生效。',
cancel: '取消', cancel: '取消',
saveOnly: '仅保存',
saveApply: '保存并应用', saveApply: '保存并应用',
processing: '处理中…', processing: '处理中…',
savedApplied: '已保存 {{days}} 天,Loki 正在重新部署', savedApplied: '已保存 {{days}} 天,Loki 正在重新部署',
@@ -95,16 +95,40 @@ export function useAnalysisMetrics({ sourceId, start, end, namespace }: Analysis
const range = { sourceId, start: Number(ns(from)), end: Number(ns(to)) } const range = { sourceId, start: Number(ns(from)), end: Number(ns(to)) }
const fetchMetrics = (query: string) => logsApi.metrics({ ...range, query }, { signal: controller.signal }) const fetchMetrics = (query: string) => logsApi.metrics({ ...range, query }, { signal: controller.signal })
// 遇到 429(并发重查询已满)等一小会儿重试一次,避免分析页 5 条并发时
// 部分卡片直接报"当前有较多日志量统计在进行"。
const fetchRetry = async (query: string) => {
try {
return await fetchMetrics(query)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
if (!msg.includes('较多日志量统计') && !msg.includes('429')) throw e
await new Promise(resolve => setTimeout(resolve, 1500))
return await fetchMetrics(query)
}
}
// 分批(每批 2 条)而不是一次性 5 条并发:
// Loki 单实例被 5 条 count_over_time 同时打会互相拖慢,
// 而且会撞后端并发护栏。分批后总耗时接近,但不会互相踩。
const runInBatches = async <T,>(tasks: Array<() => Promise<T>>, size = 2): Promise<T[]> => {
const out: T[] = []
for (let index = 0; index < tasks.length; index += size) {
out.push(...await Promise.all(tasks.slice(index, index + size).map(task => task())))
}
return out
}
setLoading(true) setLoading(true)
setError('') setError('')
void (async () => { void (async () => {
try { try {
const [totalSeries, errorSeries, warnSeries, byAppSeries, errByAppSeries] = await Promise.all([ const [totalSeries, errorSeries, warnSeries, byAppSeries, errByAppSeries] = await runInBatches([
fetchMetrics(`sum(count_over_time(${selector}[$step]))`), () => fetchRetry(`sum(count_over_time(${selector}[$step]))`),
fetchMetrics(`sum(count_over_time(${selector} |= "error"[$step]))`), () => fetchRetry(`sum(count_over_time(${selector} |= "error"[$step]))`),
fetchMetrics(`sum(count_over_time(${selector} |= "warn"[$step]))`), () => fetchRetry(`sum(count_over_time(${selector} |= "warn"[$step]))`),
fetchMetrics(`sum by (${appLabel}, namespace) (count_over_time(${selector}[$step]))`), () => fetchRetry(`sum by (${appLabel}, namespace) (count_over_time(${selector}[$step]))`),
fetchMetrics(`sum by (${appLabel}, namespace) (count_over_time(${selector} |= "error"[$step]))`), () => fetchRetry(`sum by (${appLabel}, namespace) (count_over_time(${selector} |= "error"[$step]))`),
]) ])
if (version !== versionRef.current) return if (version !== versionRef.current) return
+57 -3
View File
@@ -25,6 +25,9 @@ import { IngressAnalysisSection } from './components/IngressAnalysisSection'
const toLocal = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19) const toLocal = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
/** "全部命名空间"哨兵值:对应后端 {namespace=~".+"},代价高,仅作显式选项 */
const ALL_NAMESPACES = '__all__'
/** /**
* 日志分析页(admin 专用):SLS 风格仪表盘 — 统计卡 + 趋势折线 + 命名空间饼图 + * 日志分析页(admin 专用):SLS 风格仪表盘 — 统计卡 + 趋势折线 + 命名空间饼图 +
* 应用流图 + 应用排行表;数据来自 5 条 LogQL 指标查询(POST /logs/metrics)。 * 应用流图 + 应用排行表;数据来自 5 条 LogQL 指标查询(POST /logs/metrics)。
@@ -39,6 +42,16 @@ const AnalysisPage = memo(function AnalysisPage() {
const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 3600000))) const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 3600000)))
const [end, setEnd] = useState(() => toLocal(new Date())) const [end, setEnd] = useState(() => toLocal(new Date()))
/**
* 命名空间筛选。
* 之前这里写死 namespace: '',等价于 {namespace=~".+"} —— 一次分析就扫**全集群**
* (实测约 370 GB/天),5 条 count_over_time 跑 30 秒都出不来,页面永远转圈。
* 现在默认只查业务命名空间,用户可下拉切换;"全部命名空间"作为显式选项并列提示较慢。
*/
const [namespaces, setNamespaces] = useState<string[]>([])
const [namespace, setNamespace] = useState<string>(() => localStorage.getItem('analysisNamespace') ?? '')
const [namespaceReady, setNamespaceReady] = useState(false)
useEffect(() => { useEffect(() => {
logsApi.sources() logsApi.sources()
.then(result => { .then(result => {
@@ -52,9 +65,32 @@ const AnalysisPage = memo(function AnalysisPage() {
.catch(() => setSources([])) .catch(() => setSources([]))
}, []) }, [])
const { data, loading, error, appLabel, refresh } = useAnalysisMetrics({ sourceId, start, end, namespace: '' }) // 拉取可用命名空间;默认优先业务命名空间 astralx,其次列表第一个
useEffect(() => {
if (!sourceId) return
let cancelled = false
logsApi.labelValues(sourceId, 'namespace')
.then(list => {
if (cancelled) return
const values = (list || []).filter(Boolean).sort()
setNamespaces(values)
setNamespace(current => {
if (current && (current === ALL_NAMESPACES || values.includes(current))) return current
const saved = localStorage.getItem('analysisNamespace')
if (saved && (saved === ALL_NAMESPACES || values.includes(saved))) return saved
return values.includes('astralx') ? 'astralx' : values[0] ?? ''
})
})
.catch(() => { if (!cancelled) setNamespaces([]) })
.finally(() => { if (!cancelled) setNamespaceReady(true) })
return () => { cancelled = true }
}, [sourceId])
// hook 内部把空串当作 {namespace=~".+"}(全部),这里做哨兵转换
const scopedNamespace = namespace === ALL_NAMESPACES ? '' : namespace
const { data, loading, error, appLabel, refresh } = useAnalysisMetrics({ sourceId, start, end, namespace: scopedNamespace })
// 每日统计固定近 7 天,不随上方时间范围变化;错误就地展示,不影响主区 // 每日统计固定近 7 天,不随上方时间范围变化;错误就地展示,不影响主区
const daily = useDailyVolume({ sourceId, namespace: '', appLabel }) const daily = useDailyVolume({ sourceId, namespace: scopedNamespace, appLabel })
// 非 admin:菜单不可见,直访时给明确提示(后端 AdminAuth 403 兜底) // 非 admin:菜单不可见,直访时给明确提示(后端 AdminAuth 403 兜底)
if (!isAdmin) { if (!isAdmin) {
@@ -92,10 +128,28 @@ const AnalysisPage = memo(function AnalysisPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select
value={namespace || undefined}
onValueChange={value => {
setNamespace(value)
try { localStorage.setItem('analysisNamespace', value) } catch { /* 隐私模式忽略 */ }
}}
>
<SelectTrigger className="h-9 min-w-44" aria-label={t('filter.namespace')}>
<SelectValue placeholder={t('filter.namespaceEmpty')} />
</SelectTrigger>
<SelectContent>
{namespaces.map(item => <SelectItem key={item} value={item}>{item}</SelectItem>)}
<SelectItem value={ALL_NAMESPACES}>{t('filter.namespaceAll')}</SelectItem>
</SelectContent>
</Select>
<TimeRangePicker start={start} end={end} onChange={(nextStart, nextEnd) => { setStart(nextStart); setEnd(nextEnd) }} /> <TimeRangePicker start={start} end={end} onChange={(nextStart, nextEnd) => { setStart(nextStart); setEnd(nextEnd) }} />
</div> </div>
{sources.length === 0 ? ( {!namespaceReady && sourceId ? (
/* 命名空间列表未就绪先等待:否则会先用空串发起一次"全集群"查询,非常慢 */
<LoadingSpinner className="h-64" />
) : sources.length === 0 ? (
<EmptyState <EmptyState
icon={BarChart3} icon={BarChart3}
title={t('emptySource.title')} title={t('emptySource.title')}
@@ -6,15 +6,37 @@ import { z } from 'zod'
import { toast } from 'sonner' import { toast } from 'sonner'
import { deploymentApi } from '@/api' import { deploymentApi } from '@/api'
import { Button } from '@/components/ui/button' 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 { 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 i18n from '@/i18n'
import type { LokiDeployment } from '@/types' 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> type Values = z.infer<typeof schema>
const PRESETS = [1, 7, 14, 30, 90]
interface Props { interface Props {
deployment: LokiDeployment | null deployment: LokiDeployment | null
onClose: () => void onClose: () => void
@@ -35,19 +57,19 @@ export const RetentionDialog = memo(function RetentionDialog({ deployment, onClo
const [configError, setConfigError] = useState('') const [configError, setConfigError] = useState('')
const form = useForm<Values>({ const form = useForm<Values>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { yaml: 'limits_config:\n retention_period: 30d\n' }, defaultValues: { days: 30 },
}) })
const days = form.watch('days')
useEffect(() => { useEffect(() => {
if (deployment) { if (deployment) form.reset({ days: deployment.retentionDays || 30 })
form.reset({ yaml: `limits_config:\n retention_period: ${deployment.retentionDays || 30}d\n` })
}
setLiveConfig(null) setLiveConfig(null)
setConfigError('') setConfigError('')
}, [deployment, form]) }, [deployment, form])
/** 展开「运行中的完整配置」时才去读,避免一打开弹框就打后端 */
const loadConfig = async () => { const loadConfig = async () => {
if (!deployment) return if (!deployment || liveConfig || configLoading) return
setConfigLoading(true) setConfigLoading(true)
setConfigError('') setConfigError('')
try { 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 if (!deployment) return
setSaving(true) setSaving(true)
try { 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() onSaved()
onClose() onClose()
if (apply) toast.info(t('retention.savedApplied', { days: result.retentionDays })) toast.info(t('retention.savedApplied', { days: result.retentionDays }))
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : t('retention.errSave')) toast.error(err instanceof Error ? err.message : t('retention.errSave'))
onSaved() onSaved()
@@ -77,63 +101,99 @@ export const RetentionDialog = memo(function RetentionDialog({ deployment, onClo
return ( return (
<Dialog open={deployment !== null} onOpenChange={(open) => { if (!open && !saving) onClose() }}> <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> <DialogHeader>
<DialogTitle>{deployment ? t('retention.title', { name: deployment.name }) : ''}</DialogTitle> <DialogTitle>{deployment ? t('retention.title', { name: deployment.name }) : ''}</DialogTitle>
<DialogDescription className="text-xs">
{t('retention.locationLabel')}{deployment ? configLocation(deployment, t) : ''}
</DialogDescription>
</DialogHeader> </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 {...form}>
<form onSubmit={save(false)} className="space-y-4"> <form onSubmit={submit} className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="yaml" name="days"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('retention.yamlLabel')}</FormLabel> <FormLabel>{t('retention.daysLabel')}</FormLabel>
<FormControl> <div className="flex flex-wrap items-center gap-2">
<Textarea rows={4} spellCheck={false} className="font-mono text-sm" {...field} /> <FormControl>
</FormControl> <Input
<p className="text-xs text-muted-foreground">{t('retention.yamlHint')}</p> 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 /> <FormMessage />
</FormItem> </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"> <p className="text-sm text-muted-foreground">
{deployment?.status === 'running' ? t('retention.applyHintRunning') : t('retention.applyHintIdle')} {deployment?.status === 'running' ? t('retention.applyHintRunning') : t('retention.applyHintIdle')}
</p> </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"> <div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>{t('retention.cancel')}</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> <Button type="submit" disabled={saving}>
{deployment?.status === 'running' && ( {saving ? t('retention.processing') : t('retention.saveApply')}
<Button type="button" onClick={save(true)} disabled={saving}> </Button>
{saving ? t('retention.processing') : t('retention.saveApply')}
</Button>
)}
</div> </div>
</form> </form>
</Form> </Form>
@@ -9,11 +9,11 @@ import { QueryEmptyState } from './QueryEmptyState'
import { LogRow, VirtualLogList } from './VirtualLogList' import { LogRow, VirtualLogList } from './VirtualLogList'
/** /**
* 超过这个条数才启用虚拟滚动。 * 超过这个条数启用虚拟滚动。
* 默认每页 200 条走普通渲染(已验证的外观/行为不变), * 取 100:默认每页 200 条即走虚拟滚动;
* 只有用户主动把每页调到 500/1000 时才走虚拟滚动 —— 降低改动风险。 * 若虚拟滚动在某环境下表现异常,把每页切到 100 可回退到普通渲染(100 不 > 100)。
*/ */
const VIRTUAL_THRESHOLD = 300 const VIRTUAL_THRESHOLD = 100
// 只保留原始日志视图;表格/折线/流图/饼图等可视化已迁至「日志分析」页(LogDetailSection) // 只保留原始日志视图;表格/折线/流图/饼图等可视化已迁至「日志分析」页(LogDetailSection)
+4 -3
View File
@@ -68,8 +68,9 @@ export default function LogsPage() {
const [executedRange, setExecutedRange] = useState<{ start: number; end: number } | null>(null) const [executedRange, setExecutedRange] = useState<{ start: number; end: number } | null>(null)
const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 300000))) const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 300000)))
const [end, setEnd] = useState(() => toLocal(new Date())) const [end, setEnd] = useState(() => toLocal(new Date()))
// 默认 200:实测 671B/条,200 条约 131KB、DOM 200 行渲染顺畅。 // 默认 200:实测 671B/条,200 条约 131KB。
// 后端 maxLogQueryLimit=1000,所以下拉不给 2000/5000(否则静默截断成 1000,是假选项) // 上限 5000 与后端 maxLogQueryLimit 完全对齐(= Loki max_entries_limit_per_query)。
// 结果表 >100 条即走虚拟滚动,所以选大页也不会卡。
const [limit, setLimit] = useState(200) const [limit, setLimit] = useState(200)
const [queryHistory, setQueryHistory] = useState(readQueryHistory) const [queryHistory, setQueryHistory] = useState(readQueryHistory)
const [entries, setEntries] = useState<LogEntry[]>([]) const [entries, setEntries] = useState<LogEntry[]>([])
@@ -275,7 +276,7 @@ export default function LogsPage() {
<Select value={String(limit)} onValueChange={value => setLimit(Number(value))}> <Select value={String(limit)} onValueChange={value => setLimit(Number(value))}>
<SelectTrigger className="h-9 w-24" aria-label={t('toolbar.limitLabel')}><SelectValue /></SelectTrigger> <SelectTrigger className="h-9 w-24" aria-label={t('toolbar.limitLabel')}><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
{[100, 200, 500, 1000].map(value => <SelectItem key={value} value={String(value)}>{value}</SelectItem>)} {[100, 200, 500, 1000, 2000, 5000].map(value => <SelectItem key={value} value={String(value)}>{value}</SelectItem>)}
</SelectContent> </SelectContent>
</Select> </Select>
</label> </label>