diff --git a/backend/controllers/logs.go b/backend/controllers/logs.go
index 2496a2f..1a4c3b3 100644
--- a/backend/controllers/logs.go
+++ b/backend/controllers/logs.go
@@ -317,3 +317,27 @@ func (ctrl *LogsController) Histogram(c *gin.Context) {
}
utils.Success(c, buckets)
}
+
+// Metrics 日志分析指标查询(仅 admin,路由层 AdminAuth 拦截)
+func (ctrl *LogsController) Metrics(c *gin.Context) {
+ var req dto.LogMetricQueryRequest
+ if !utils.BindAndValidate(c, &req) {
+ return
+ }
+ src := services.GetLogSource(req.SourceID)
+ if src == nil {
+ utils.BadRequest(c, "数据源不存在")
+ return
+ }
+ query, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", req.Query)
+ if err != nil {
+ utils.Forbidden(c, err.Error())
+ return
+ }
+ series, err := services.LokiMetrics(c.Request.Context(), src, query, req.Start, req.End)
+ if err != nil {
+ utils.Error(c, 502, err.Error())
+ return
+ }
+ utils.Success(c, series)
+}
diff --git a/backend/dto/logs.go b/backend/dto/logs.go
index d486335..0291952 100644
--- a/backend/dto/logs.go
+++ b/backend/dto/logs.go
@@ -32,6 +32,15 @@ type LogQueryRequest struct {
Cursor string `json:"cursor"`
}
+// LogMetricQueryRequest 日志分析指标查询:Query 为完整 LogQL 指标表达式,
+// 其中的 [$step] 占位符由服务端按时间跨度替换为自适应步长(与直方图同款)
+type LogMetricQueryRequest struct {
+ SourceID uint `json:"sourceId" binding:"required"`
+ Query string `json:"query" binding:"required"`
+ Start int64 `json:"start"` // epoch 纳秒
+ End int64 `json:"end"`
+}
+
type LogContextRequest struct {
SourceID uint `json:"sourceId" binding:"required"`
TimestampNs string `json:"timestampNs" binding:"required"`
diff --git a/backend/routers/router.go b/backend/routers/router.go
index b3da124..651b5d2 100644
--- a/backend/routers/router.go
+++ b/backend/routers/router.go
@@ -91,6 +91,7 @@ func SetupRouter() *gin.Engine {
logs.GET("/label/:name/values", logsCtrl.LabelValues)
logs.GET("/my-scope", logsCtrl.MyScope)
logs.POST("/histogram", logsCtrl.Histogram)
+ logs.POST("/metrics", middleware.AdminAuth(), logsCtrl.Metrics)
logs.GET("/health", logsCtrl.Health)
}
diff --git a/backend/services/log_metrics_test.go b/backend/services/log_metrics_test.go
new file mode 100644
index 0000000..1af33d6
--- /dev/null
+++ b/backend/services/log_metrics_test.go
@@ -0,0 +1,82 @@
+package services
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "lokistack/models"
+)
+
+// TestLokiMetricsParsesMatrixSeries 用假 Loki 验证指标代理:
+// [$step] 占位符替换 + matrix 多序列解析(按 app/namespace 分组)。
+func TestLokiMetricsParsesMatrixSeries(t *testing.T) {
+ var gotQuery, gotStep string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotQuery = r.URL.Query().Get("query")
+ gotStep = r.URL.Query().Get("step")
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{
+ "status": "success",
+ "data": {
+ "resultType": "matrix",
+ "result": [
+ {
+ "metric": {"app": "web", "namespace": "prod"},
+ "values": [[1.79e+09, "10"], [1.79e+09, "20"]]
+ },
+ {
+ "metric": {"app": "api", "namespace": "prod"},
+ "values": [[1.79e+09, "5"]]
+ },
+ {
+ "metric": {"app": "empty", "namespace": "prod"},
+ "values": []
+ }
+ ]
+ }
+ }`))
+ }))
+ defer server.Close()
+
+ src := &models.LogSource{TargetType: "url", URL: server.URL, AuthType: "none"}
+ query := `sum by (app, namespace) (count_over_time({namespace=~".+"}[$step]))`
+ end := time.Now().UnixNano()
+ start := end - int64(time.Hour)
+
+ series, err := LokiMetrics(context.Background(), src, query, start, end)
+ if err != nil {
+ t.Fatalf("LokiMetrics: %v", err)
+ }
+
+ // 占位符被替换为 1h 跨度对应的步长,且 query_range 带上了同样的 step
+ if gotQuery == query || !strings.Contains(gotQuery, `count_over_time({namespace=~".+"}[`) {
+ t.Fatalf("[$step] not replaced, got %q", gotQuery)
+ }
+ if gotStep == "" || !strings.Contains(gotQuery, "["+gotStep+"]") {
+ t.Fatalf("step mismatch: query=%q step=%q", gotQuery, gotStep)
+ }
+
+ // 空序列被丢弃,两条有效序列带标签返回
+ if len(series) != 2 {
+ t.Fatalf("want 2 series, got %d", len(series))
+ }
+ if series[0].Labels["app"] != "web" || series[0].Labels["namespace"] != "prod" {
+ t.Fatalf("labels not parsed: %+v", series[0].Labels)
+ }
+ if len(series[0].Points) != 2 || series[0].Points[0].Value != 10 || series[0].Points[1].Value != 20 {
+ t.Fatalf("points not parsed: %+v", series[0].Points)
+ }
+ if series[0].Points[0].Timestamp == "" {
+ t.Fatal("timestamp not formatted")
+ }
+
+ // 响应可 JSON 序列化(controller 直接 utils.Success 包装)
+ if _, err := json.Marshal(series); err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+}
diff --git a/backend/services/log_query.go b/backend/services/log_query.go
index 735aef2..bbfb8b9 100644
--- a/backend/services/log_query.go
+++ b/backend/services/log_query.go
@@ -17,9 +17,9 @@ import (
"strings"
"time"
+ "k8s.io/client-go/rest"
"lokistack/models"
"lokistack/repository"
- "k8s.io/client-go/rest"
)
const maxLogQueryLimit = 1000
@@ -449,6 +449,71 @@ func LokiHistogram(ctx context.Context, src *models.LogSource, query string, sta
return buckets, nil
}
+// MetricSeriesPoint 指标序列上的一个采样点
+type MetricSeriesPoint struct {
+ Timestamp string `json:"timestamp"`
+ Value float64 `json:"value"`
+}
+
+// MetricSeries 一条按标签分组的指标序列(如 sum by (app)(...) 的每个 app 一条)
+type MetricSeries struct {
+ Labels map[string]string `json:"labels"`
+ Points []MetricSeriesPoint `json:"points"`
+}
+
+// LokiMetrics 通用 LogQL 指标查询(日志分析页用):query 为完整指标表达式,
+// 其中所有 [$step] 占位符替换为按时间跨度自适应的步长后走 query_range,
+// 返回矩阵多序列(直方图只取单序列,这里按 by (...) 分组整表返回)
+func LokiMetrics(ctx context.Context, src *models.LogSource, query string, start, end int64) ([]MetricSeries, error) {
+ if end <= 0 {
+ end = time.Now().UnixNano()
+ }
+ if start <= 0 {
+ start = end - int64(time.Hour)
+ }
+ step := pickHistogramStep(end - start)
+ wrapped := strings.ReplaceAll(query, "[$step]", "["+step+"]")
+
+ q := url.Values{}
+ q.Set("query", wrapped)
+ q.Set("start", strconv.FormatInt(start, 10))
+ q.Set("end", strconv.FormatInt(end, 10))
+ q.Set("step", step)
+ body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/query_range", q)
+ if err != nil {
+ return nil, err
+ }
+ var resp struct {
+ Data struct {
+ Result []struct {
+ Metric map[string]string `json:"metric"`
+ Values [][2]interface{} `json:"values"`
+ } `json:"result"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(body, &resp); err != nil {
+ return nil, err
+ }
+ series := make([]MetricSeries, 0, len(resp.Data.Result))
+ for _, item := range resp.Data.Result {
+ points := make([]MetricSeriesPoint, 0, len(item.Values))
+ for _, v := range item.Values {
+ // metric API 的 ts 为秒级浮点数(如 1.79e+09)
+ tsf, _ := strconv.ParseFloat(fmt.Sprintf("%v", v[0]), 64)
+ cnt, _ := strconv.ParseFloat(fmt.Sprintf("%v", v[1]), 64)
+ points = append(points, MetricSeriesPoint{
+ Timestamp: time.Unix(int64(tsf), 0).Format(time.RFC3339),
+ Value: cnt,
+ })
+ }
+ if len(points) == 0 {
+ continue
+ }
+ series = append(series, MetricSeries{Labels: item.Metric, Points: points})
+ }
+ return series, nil
+}
+
// pickHistogramStep 按时间跨度自适应步长(aiops 同款)
func pickHistogramStep(spanNano int64) string {
span := time.Duration(spanNano)
diff --git a/frontend/src/pages/logs/components/LogHistogram.tsx b/frontend/src/pages/logs/components/LogHistogram.tsx
deleted file mode 100644
index 8d92982..0000000
--- a/frontend/src/pages/logs/components/LogHistogram.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import { memo } from 'react'
-import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recharts'
-import { useTranslation } from 'react-i18next'
-import type { HistogramBucket } from '@/api/logs'
-
-interface LogHistogramProps {
- buckets: HistogramBucket[]
- loading: boolean
- /** 点击柱体 → 缩放到相邻 bucket 时间范围 */
- onZoomRange: (start: number, end: number) => void
-}
-
-/** 日志量直方图(服务端 count_over_time 聚合);点击柱体缩放时间范围 */
-export const LogHistogram = memo(function LogHistogram({ buckets, loading, onZoomRange }: LogHistogramProps) {
- const { t } = useTranslation('logs')
- const data = buckets.map((b) => ({
- ts: b.timestamp.slice(11, 16),
- count: b.count,
- raw: new Date(b.timestamp).getTime(),
- }))
-
- if (loading && data.length === 0) {
- return
{t('volume.histogramLoading')}
- }
- if (data.length === 0) {
- return {t('volume.histogramEmpty')}
- }
-
- const handleClick = (idx: number) => {
- const cur = data[idx]?.raw
- if (!cur) return
- const step = data.length > 1 ? Math.max(data[1].raw - data[0].raw, 60000) : 300000
- onZoomRange(cur - step / 2, cur + step / 2)
- }
-
- return (
-
-
- {
- if (state?.activeTooltipIndex !== undefined) handleClick(state.activeTooltipIndex)
- }}
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-})
diff --git a/frontend/src/pages/logs/components/LogResults.tsx b/frontend/src/pages/logs/components/LogResults.tsx
deleted file mode 100644
index 3c852e5..0000000
--- a/frontend/src/pages/logs/components/LogResults.tsx
+++ /dev/null
@@ -1,215 +0,0 @@
-import { memo, useMemo, useRef, useState } from 'react'
-import { ChevronDown, ChevronRight } from 'lucide-react'
-import { useTranslation } from 'react-i18next'
-import { cn } from '@/lib/utils'
-import type { LogEntry } from '@/api/logs'
-import { applyLabelFilter } from '../query-utils'
-
-// 级别 → 颜色(语义 token)
-const LEVEL_COLOR: Record = {
- error: 'text-destructive',
- warn: 'text-warning',
- info: 'text-success',
- debug: 'text-muted-foreground',
-}
-
-/** 从日志行提取 k=v / JSON 字段(点击过滤用) */
-function detectFields(message: string): Array<[string, string]> {
- const out: Array<[string, string]> = []
- const trimmed = message.trim()
- if (trimmed.startsWith('{')) {
- try {
- const obj = JSON.parse(trimmed)
- for (const [k, v] of Object.entries(obj)) {
- if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
- out.push([k, String(v)])
- }
- }
- return out.slice(0, 12)
- } catch {
- /* 非 JSON 继续 */
- }
- }
- const re = /([a-zA-Z_][\w.-]*)=("[^"]*"|\S+)/g
- let m: RegExpExecArray | null
- while ((m = re.exec(message)) !== null && out.length < 12) {
- out.push([m[1], m[2].replace(/^"|"$/g, '')])
- }
- return out
-}
-
-interface LogResultsProps {
- entries: LogEntry[]
- loading: boolean
- hasMore: boolean
- onLoadMore: () => void
- /** 点击标签过滤(改写查询) */
- onFilter: (nextQuery: string) => void
- query: string
-}
-
-/**
- * 日志结果区(Grafana 风格裁剪版):级别着色、行展开(原文 + labels + 提取字段,
- * 点击即过滤/排除)、去重开关
- */
-export const LogResults = memo(function LogResults({
- entries,
- loading,
- hasMore,
- onLoadMore,
- onFilter,
- query,
-}: LogResultsProps) {
- const { t } = useTranslation('logs')
- const [expanded, setExpanded] = useState>(new Set())
- const [dedup, setDedup] = useState(false)
- const listRef = useRef(null)
-
- const display = useMemo(() => {
- if (!dedup) return entries
- const seen = new Set()
- return entries.filter((e) => {
- const k = e.message.replace(/\d+/g, '#')
- if (seen.has(k)) return false
- seen.add(k)
- return true
- })
- }, [entries, dedup])
-
- const toggle = (key: string) => {
- setExpanded((prev) => {
- const next = new Set(prev)
- if (next.has(key)) next.delete(key)
- else next.add(key)
- return next
- })
- }
-
- // 公共标签(所有结果共有)
- const commonLabels = useMemo(() => {
- if (entries.length === 0) return [] as string[]
- let keys = Object.keys(entries[0].labels)
- for (const e of entries) {
- keys = keys.filter((k) => e.labels[k] !== undefined && e.labels[k] === entries[0].labels[k])
- }
- return keys
- }, [entries])
-
- if (loading && entries.length === 0) {
- return {t('raw.searching')}
- }
-
- return (
-
-
-
- {t('raw.count', { count: display.length })}
- {dedup && t('raw.dedupSuffix', { count: entries.length })}
- {commonLabels.length > 0 && (
-
- {commonLabels.slice(0, 6).map((k) => (
-
- {k}={entries[0].labels[k]}
-
- ))}
-
- )}
-
-
-
-
-
- {display.length === 0 ? (
-
{t('raw.empty')}
- ) : (
- display.map((e, idx) => {
- const key = `${e.timestamp}-${idx}`
- const open = expanded.has(key)
- const fields = open ? detectFields(e.message) : []
- return (
-
-
- {open && (
-
-
-
{t('raw.original')}
-
{e.message}
-
-
-
{t('raw.labelsTitle')}
-
- {Object.entries(e.labels).map(([k, v]) => (
-
-
-
-
- ))}
-
-
- {fields.length > 0 && (
-
-
{t('raw.fields')}
-
- {fields.map(([k, v]) => (
-
-
-
- ))}
-
-
- )}
-
- )}
-
- )
- })
- )}
-
-
- {hasMore && (
-
- )}
-
- )
-})
diff --git a/frontend/src/pages/logs/components/QueryInput.tsx b/frontend/src/pages/logs/components/QueryInput.tsx
index 505fbba..8e1f290 100644
--- a/frontend/src/pages/logs/components/QueryInput.tsx
+++ b/frontend/src/pages/logs/components/QueryInput.tsx
@@ -23,7 +23,8 @@ function colorizedLogQL(query: string) {
interface QueryInputProps {
value: string
onChange: (value: string) => void
- onSubmit: () => void
+ /** 执行查询;传入 query 时直接以其为准(避免 setQuery 异步取到旧值) */
+ onSubmit: (query?: string) => void
sourceId: number
history: string[]
}
@@ -152,7 +153,10 @@ export const QueryInput = memo(function QueryInput({ value, onChange, onSubmit,
{t('editor.escClose')}
- {assistantTab === 'quick' ? :
+ {assistantTab === 'quick' ? { onChange(next); onSubmit(next) }} /> :
{history.length === 0 ?
{t('editor.historyEmpty')}
: history.map((item, index) =>
)}
}
diff --git a/frontend/src/pages/logs/components/QuickQueryTemplates.tsx b/frontend/src/pages/logs/components/QuickQueryTemplates.tsx
index aa335e1..056779b 100644
--- a/frontend/src/pages/logs/components/QuickQueryTemplates.tsx
+++ b/frontend/src/pages/logs/components/QuickQueryTemplates.tsx
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { logsApi } from '@/api/logs'
import { SearchableValueSelect } from './SearchableValueSelect'
-import { applyLabelFilter, extractSelector } from '../query-utils'
+import { applyLabelFilter, extractSelector, hasLabelFilter, removeLabelFilter, replaceSelector } from '../query-utils'
-type Template = 'app' | 'error' | 'warn' | 'pod' | 'ingress'
+type Template = 'error' | 'warn' | 'ingress'
/** Ingress 访问日志所在命名空间候选(按顺序取第一个实际存在的) */
const INGRESS_NAMESPACE_CANDIDATES = ['ingress-nginx', 'ingress']
@@ -16,10 +16,15 @@ function selectorOf(labels: Record) {
return `{${entries.map(([key, value]) => `${key}=${JSON.stringify(value.trim())}`).join(',')}}`
}
-export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }: {
+export function QuickQueryTemplates({ sourceId, query, onQueryChange, onInsert, onRun, compact }: {
sourceId: number
query: string
+ /** 终态动作(模板按钮):写入输入框并关闭助手面板 */
onQueryChange: (query: string) => void
+ /** 只写入输入框,不执行、不关面板(清理失效标签用) */
+ onInsert?: (query: string) => void
+ /** 写入并立即执行查询,面板保持打开(选中 App/Pod 后直接出结果) */
+ onRun?: (query: string) => void
compact?: boolean
}) {
const { t } = useTranslation('logs')
@@ -114,11 +119,38 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
// 换命名空间后旧的 app / pod 条件失效
setApp('')
setPod('')
+ // 命名空间只作级联筛选,不主动插入查询(范围太大);
+ // 但输入框里已插入的 app/pod 属于旧命名空间,需要清理,namespace 标签同步为新值
+ if (!onInsert || !extractSelector(query)) return
+ let next = removeLabelFilter(removeLabelFilter(query, appLabel), 'pod')
+ if (value.trim()) {
+ if (hasLabelFilter(next, 'namespace')) next = applyLabelFilter(next, 'namespace', value.trim())
+ } else {
+ next = removeLabelFilter(next, 'namespace')
+ }
+ if (next !== query) onInsert(next)
}
function chooseApp(value: string) {
setApp(value)
setPod('')
+ // 选中 App 即把选择器写入输入框并立即执行(命名空间范围太大故不自动执行,App 级别粒度合适)
+ if (!value.trim() || !onRun) return
+ let selector = extractSelector(query) || '{}'
+ if (namespace.trim()) selector = applyLabelFilter(selector, 'namespace', namespace.trim())
+ selector = applyLabelFilter(selector, appLabel, value.trim())
+ onRun(replaceSelector(query, selector))
+ }
+
+ function choosePod(value: string) {
+ setPod(value)
+ // 选 Pod 同样立即执行;级联保证 namespace/app 已选定,合并进选择器
+ if (!value.trim() || !onRun) return
+ let selector = extractSelector(query) || '{}'
+ if (namespace.trim()) selector = applyLabelFilter(selector, 'namespace', namespace.trim())
+ if (app.trim()) selector = applyLabelFilter(selector, appLabel, app.trim())
+ selector = applyLabelFilter(selector, 'pod', value.trim())
+ onRun(replaceSelector(query, selector))
}
function generate(template: Template) {
@@ -154,6 +186,12 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
const hasSelector = extractSelector(query).replace(/[{}\s]/g, '').length > 0
const hasScope = Boolean(app.trim() || namespace.trim() || pod.trim() || hasSelector)
+ // Ingress 访问日志按钮只在确实指向 ingress controller 时出现:
+ // 选了 ingress 命名空间,或选中的 app 就是 ingress controller(如 ingress-nginx-controller-nginx),
+ // 避免在业务命名空间下显示一个查不到东西的入口
+ const ingressReady =
+ INGRESS_NAMESPACE_CANDIDATES.includes(namespace) ||
+ Boolean(app.trim() && app.toLowerCase().includes('ingress-nginx'))
return
{!compact && {t('templates.title')}
}
@@ -183,18 +221,16 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
label={t('templates.pod')}
values={pods}
value={pod}
- onChange={setPod}
+ onChange={choosePod}
placeholder={pods.length ? t('templates.selectCount', { count: pods.length }) : app ? t('templates.noPod') : t('templates.optional')}
allowEmpty
disabled={!sourceId}
/>
-
-
-
+ {ingressReady && }
{!hasScope && {t('templates.scopeHint')}}
diff --git a/frontend/src/pages/logs/query-utils.ts b/frontend/src/pages/logs/query-utils.ts
index 222ee9a..873e486 100644
--- a/frontend/src/pages/logs/query-utils.ts
+++ b/frontend/src/pages/logs/query-utils.ts
@@ -7,6 +7,39 @@ export function extractSelector(query: string): string {
return i >= 0 && j > i ? query.slice(i, j + 1) : ''
}
+/**
+ * 替换 LogQL 的 {...} 选择器,保留其后的管道过滤(|= "x" 等);
+ * 原查询没有选择器时把新选择器前置到最前面
+ */
+export function replaceSelector(query: string, nextSelector: string): string {
+ const current = extractSelector(query)
+ if (!current) return nextSelector + query.trim().replace(/^\{[^}]*\}/, '')
+ const i = query.indexOf(current)
+ return query.slice(0, i) + nextSelector + query.slice(i + current.length)
+}
+
+/** 从选择器里删除指定标签的所有匹配条件(=、!=、=~、!~) */
+export function removeLabelFilter(query: string, key: string): string {
+ const current = extractSelector(query)
+ if (!current) return query
+ const inner = current.slice(1, -1).trim()
+ if (!inner) return query
+ const pattern = new RegExp(`^${key}(=|!=|=~|!~)`)
+ const kept = inner.split(',').map((s) => s.trim()).filter(Boolean).filter((p) => !pattern.test(p))
+ const next = kept.length ? `{${kept.join(', ')}}` : '{}'
+ return replaceSelector(query, next)
+}
+
+/** 选择器里是否已包含指定标签的匹配条件 */
+export function hasLabelFilter(query: string, key: string): boolean {
+ const current = extractSelector(query)
+ if (!current) return false
+ return current
+ .slice(1, -1)
+ .split(',')
+ .some((part) => new RegExp(`^\\s*${key}(=|!=|=~|!~)`).test(part))
+}
+
/**
* 把 key="value" 合并进 LogQL 的 {...} 选择器(同名标签替换旧值);exclude=true 用 != 排除
*/