diff --git a/backend/controllers/logs.go b/backend/controllers/logs.go index 2d2c6ae..600d59e 100644 --- a/backend/controllers/logs.go +++ b/backend/controllers/logs.go @@ -386,7 +386,7 @@ func (ctrl *LogsController) Stats(c *gin.Context) { utils.Forbidden(c, err.Error()) return } - st, err := services.LokiStatsFor(c.Request.Context(), src, query, req.Start, req.End) + st, err := services.LokiStatsCached(c.Request.Context(), src, query, req.Start, req.End) if err != nil { // Loki 的 index/stats 只支持标签选择器。查询里带 `|= "error"` 这类行过滤时 // Loki 回 400 "only label matchers are supported",原来被包装成 502 diff --git a/backend/services/daily_volume.go b/backend/services/daily_volume.go index dc38056..aa50f01 100644 --- a/backend/services/daily_volume.go +++ b/backend/services/daily_volume.go @@ -3,7 +3,6 @@ package services import ( "context" "log" - "sort" "time" "lokistack/models" @@ -19,6 +18,10 @@ type DailyVolumePoint struct { Partial bool `json:"partial"` // FromHistory true=来自平台留存历史;false=刚从 Loki 实时查的 FromHistory bool `json:"fromHistory"` + // Missing 该天平台没有抓到快照(超过留存期/平台当天没运行)。 + // 必须和"当天日志量真的是 0"区分开:前者是"不知道",后者是"确实没日志"。 + // 否则 30/90/365 天的图上会出现大片 0,被误读成"日志量暴跌"。 + Missing bool `json:"missing"` } const ( @@ -216,30 +219,38 @@ func GetDailyVolumeHistory(sourceID uint, days int) []DailyVolumePoint { // 而且这条重查询还和页面的 histogram/stats 抢 Loki,导致那两个接口同时 502。 // 当天那根柱子由后台定时任务(每 30 分钟)补,慢几分钟完全可接受。 - from := dateKey(time.Now().AddDate(0, 0, -(days - 1))) - points := []DailyVolumePoint{} + // 始终返回 days 个点(缺失的那天标 Missing)。 + // 之前只返回"已抓到的快照",于是 7/30/90/365 全都返回同样 4 个点, + // 用户切范围看不到任何变化,以为"切换没反应"。 + now := time.Now() + byDate := map[string]models.DailyVolumeSnapshot{} db := repository.GetDB() - if db == nil { - return points + if db != nil { + db.RLock() + for _, snap := range db.DailyVolumeSnapshots { + if snap.SourceID != sourceID || snap.Namespace != "" { + continue + } + byDate[snap.Date] = snap + } + db.RUnlock() } - db.RLock() - defer db.RUnlock() - for _, snap := range db.DailyVolumeSnapshots { - if snap.SourceID != sourceID || snap.Namespace != "" { + + points := make([]DailyVolumePoint, 0, days) + for i := days - 1; i >= 0; i-- { + date := dateKey(now.AddDate(0, 0, -i)) + if snap, ok := byDate[date]; ok { + points = append(points, DailyVolumePoint{ + Date: snap.Date, + Bytes: snap.Bytes, + Entries: snap.Entries, + Partial: snap.Partial, + FromHistory: true, + }) continue } - if snap.Date < from { - continue - } - points = append(points, DailyVolumePoint{ - Date: snap.Date, - Bytes: snap.Bytes, - Entries: snap.Entries, - Partial: snap.Partial, - FromHistory: true, - }) + points = append(points, DailyVolumePoint{Date: date, Missing: true, FromHistory: true}) } - sort.Slice(points, func(i, j int) bool { return points[i].Date < points[j].Date }) return points } diff --git a/backend/services/log_query.go b/backend/services/log_query.go index 33e0348..a6e0137 100644 --- a/backend/services/log_query.go +++ b/backend/services/log_query.go @@ -701,6 +701,61 @@ func LokiMetrics(ctx context.Context, src *models.LogSource, query string, start return series, nil } +// ── 总条数/总体积的短缓存 ───────────────────────────────────────────── +// /logs/stats 走 index/stats,实测 5 分钟窗口要 1.2 秒(60 分钟更慢)。 +// 分析页每次进页面、每次切时间范围都会打它,而这类聚合值的可容忍陈旧度 +// 与指标查询一致(分钟级),所以用同样的 90s TTL + 2 分钟取整。 +const ( + statsCacheTTL = 90 * time.Second + statsCacheMax = 200 +) + +type statsCacheEntry struct { + stats *LokiStats + expiresAt time.Time +} + +var ( + statsCacheMu sync.Mutex + statsCache = map[string]statsCacheEntry{} +) + +// LokiStatsCached 带短缓存的总量统计。 +func LokiStatsCached(ctx context.Context, src *models.LogSource, query string, start, end int64) (*LokiStats, error) { + key := "" + if src != nil { + bucket := int64(metricCacheBucket) + key = fmt.Sprintf("%d|%s|%d|%d", src.ID, query, start/bucket, end/bucket) + statsCacheMu.Lock() + if entry, ok := statsCache[key]; ok && time.Now().Before(entry.expiresAt) { + statsCacheMu.Unlock() + return entry.stats, nil + } + statsCacheMu.Unlock() + } + + st, err := LokiStatsFor(ctx, src, query, start, end) + if err != nil || key == "" { + return st, err + } + + statsCacheMu.Lock() + if len(statsCache) >= statsCacheMax { + now := time.Now() + for k, v := range statsCache { + if now.After(v.expiresAt) { + delete(statsCache, k) + } + } + if len(statsCache) >= statsCacheMax { + statsCache = map[string]statsCacheEntry{} + } + } + statsCache[key] = statsCacheEntry{stats: st, expiresAt: time.Now().Add(statsCacheTTL)} + statsCacheMu.Unlock() + return st, nil +} + // lokiMetricsUncached 真正打 Loki 的实现(不带缓存)。 func lokiMetricsUncached(ctx context.Context, src *models.LogSource, query string, start, end int64) ([]MetricSeries, error) { if end <= 0 { diff --git a/frontend/src/api/logs/index.ts b/frontend/src/api/logs/index.ts index de35bcc..f342cb7 100644 --- a/frontend/src/api/logs/index.ts +++ b/frontend/src/api/logs/index.ts @@ -26,6 +26,8 @@ export interface DailyVolumePoint { entries: number /** 当天未结束或抓取时数据不全 */ partial: boolean + /** 该天平台没有留存快照(超过留存期/当天平台没运行)——与"日志量真的是 0"不同 */ + missing?: boolean } export const logsApi = { sources: () => http.get>('/logs/sources'), diff --git a/frontend/src/i18n/locales/en/analysis.ts b/frontend/src/i18n/locales/en/analysis.ts index 027bc84..806ac41 100644 --- a/frontend/src/i18n/locales/en/analysis.ts +++ b/frontend/src/i18n/locales/en/analysis.ts @@ -52,6 +52,8 @@ export default { other: 'Other', }, daily: { + notRetained: 'Not retained', + missingHint: '{{count}} day(s) have no retained snapshot (grey bars) — that does NOT mean zero logs on those days.', fixedBadge: 'Platform history', badge: 'Platform history', title: 'Daily volume', diff --git a/frontend/src/i18n/locales/zh/analysis.ts b/frontend/src/i18n/locales/zh/analysis.ts index 902947d..1cdfa64 100644 --- a/frontend/src/i18n/locales/zh/analysis.ts +++ b/frontend/src/i18n/locales/zh/analysis.ts @@ -52,6 +52,8 @@ export default { other: '其他', }, daily: { + notRetained: '未留存', + missingHint: '其中 {{count}} 天平台没有留存记录(灰色柱),不是"当天没有日志"。', fixedBadge: '平台留存历史', badge: '平台留存历史', title: '每日日志量', diff --git a/frontend/src/pages/analysis/components/DailyVolumeChart.tsx b/frontend/src/pages/analysis/components/DailyVolumeChart.tsx index aa38ace..b69b009 100644 --- a/frontend/src/pages/analysis/components/DailyVolumeChart.tsx +++ b/frontend/src/pages/analysis/components/DailyVolumeChart.tsx @@ -29,10 +29,15 @@ export const DailyVolumeChart = memo(function DailyVolumeChart({ points, days, o bytes: point.bytes, entries: point.entries, partial: point.partial, + missing: Boolean(point.missing), })), [points]) - const hasData = data.some(item => item.bytes > 0) - const coverage = points.length + // 有留存的天数与"完全没留存"的天数分开统计: + // 后者是"不知道",不能画成 0 让人以为那天没日志。 + const covered = data.filter(item => !item.missing) + const missingCount = data.length - covered.length + const hasData = covered.some(item => item.bytes > 0) + const coverage = covered.length return (
@@ -68,8 +73,9 @@ export const DailyVolumeChart = memo(function DailyVolumeChart({ points, days, o formatBytes(value)} /> { - const partial = (item?.payload as { partial?: boolean } | undefined)?.partial - return [`${formatBytes(value)}${partial ? ` · ${t('daily.inProgress')}` : ''}`, t('daily.seriesVolume')] + const payload = item?.payload as { partial?: boolean; missing?: boolean } | undefined + if (payload?.missing) return [t('daily.notRetained'), t('daily.seriesVolume')] + return [`${formatBytes(value)}${payload?.partial ? ` · ${t('daily.inProgress')}` : ''}`, t('daily.seriesVolume')] }} /> {/* 圆柱形柱子:顶部圆角 + 渐变,当天未结束的用空心区分 */} @@ -77,7 +83,13 @@ export const DailyVolumeChart = memo(function DailyVolumeChart({ points, days, o {data.map(item => ( @@ -89,6 +101,14 @@ export const DailyVolumeChart = memo(function DailyVolumeChart({ points, days, o ) : ( )} + + {/* 灰柱 = 平台没有留存那天(不是"日志量为 0"),必须显式说明, + 否则 30/90/365 天视角下会被误读成"以前日志很少" */} + {missingCount > 0 && ( +

+ {t('daily.missingHint', { count: missingCount })} +

+ )}
) })