feat(logs): metrics 指标代理端点(admin) + 快捷查询选中 App/Pod 即插入并执行

- POST /logs/metrics:LogQL 指标查询代理,[$step] 占位符按跨度自适应步长,返回多序列(httptest 假 Loki 单测)
- 快捷查询:选中 App/Pod 即写入输入框并立即查询,面板保持打开支持 Pod 级联;命名空间仅级联筛选不插入,切换时清理失效标签
- 删除冗余的「查询该范围日志」「指定副本日志」按钮;Ingress 按钮仅在选择 ingress 命名空间/app 时显示
- /logs 支持 ?q= 预填自动执行(表格「查看日志」跳转入口)
- 清理无引用死组件 LogResults/LogHistogram
This commit is contained in:
opsx committed 2026-10-28 22:43:57 +08:00
1 parent 5ce90fe2e5
commit 16306a9532
10 files changed
+264 -295

No files matched your search

+24
View File
@@ -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)
}
+9
View File
@@ -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"`
+1
View File
@@ -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)
}
+82
View File
@@ -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)
}
}
+66 -1
View File
@@ -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)
@@ -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 <div className="h-20 flex items-center justify-center text-xs text-muted-foreground">{t('volume.histogramLoading')}</div>
}
if (data.length === 0) {
return <div className="h-20 flex items-center justify-center text-xs text-muted-foreground">{t('volume.histogramEmpty')}</div>
}
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 (
<div className="h-20">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={data}
margin={{ top: 4, right: 8, bottom: 0, left: 8 }}
onClick={(state: { activeTooltipIndex?: number }) => {
if (state?.activeTooltipIndex !== undefined) handleClick(state.activeTooltipIndex)
}}
>
<defs>
<linearGradient id="logVol" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity={0.5} />
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity={0.05} />
</linearGradient>
</defs>
<XAxis dataKey="ts" tick={{ fontSize: 10 }} stroke="hsl(var(--muted-foreground))" interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10 }} stroke="hsl(var(--muted-foreground))" width={28} allowDecimals={false} />
<Tooltip
contentStyle={{ fontSize: 11, borderRadius: 8, background: 'hsl(var(--popover))', border: '1px solid hsl(var(--border))' }}
labelStyle={{ color: 'hsl(var(--popover-foreground))' }}
/>
<Area
type="monotone"
dataKey="count"
stroke="hsl(var(--primary))"
fill="url(#logVol)"
strokeWidth={1.5}
cursor="pointer"
/>
</AreaChart>
</ResponsiveContainer>
</div>
)
})
@@ -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<string, string> = {
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<Set<string>>(new Set())
const [dedup, setDedup] = useState(false)
const listRef = useRef<HTMLDivElement>(null)
const display = useMemo(() => {
if (!dedup) return entries
const seen = new Set<string>()
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 <div className="h-40 flex items-center justify-center text-sm text-muted-foreground">{t('raw.searching')}</div>
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{t('raw.count', { count: display.length })}
{dedup && t('raw.dedupSuffix', { count: entries.length })}
{commonLabels.length > 0 && (
<span className="ml-2 inline-flex flex-wrap gap-1">
{commonLabels.slice(0, 6).map((k) => (
<span key={k} className="px-1.5 py-0.5 rounded bg-muted">
{k}={entries[0].labels[k]}
</span>
))}
</span>
)}
</span>
<label className="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" checked={dedup} onChange={(e) => setDedup(e.target.checked)} className="accent-primary" />
{t('raw.dedup')}
</label>
</div>
<div ref={listRef} className="rounded-lg border border-border divide-y divide-border max-h-[52vh] overflow-y-auto bg-card">
{display.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">{t('raw.empty')}</div>
) : (
display.map((e, idx) => {
const key = `${e.timestamp}-${idx}`
const open = expanded.has(key)
const fields = open ? detectFields(e.message) : []
return (
<div key={key} className="text-xs">
<button
type="button"
onClick={() => toggle(key)}
className="w-full text-left flex items-start gap-2 px-3 py-1.5 hover:bg-muted/50"
>
{open ? (
<ChevronDown className="w-3 h-3 mt-0.5 flex-shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 mt-0.5 flex-shrink-0 text-muted-foreground" />
)}
<span className="text-muted-foreground flex-shrink-0 tabular-nums">{e.timestamp.slice(11, 23)}</span>
<span className={cn('font-semibold flex-shrink-0 uppercase w-10', LEVEL_COLOR[e.level] || 'text-muted-foreground')}>
{e.level}
</span>
<span className="font-mono break-all min-w-0">{e.message.slice(0, 500)}</span>
</button>
{open && (
<div className="px-3 pb-3 pl-10 space-y-2">
<div>
<p className="text-[11px] font-semibold text-muted-foreground mb-1">{t('raw.original')}</p>
<pre className="p-2 rounded bg-muted/60 font-mono whitespace-pre-wrap break-all">{e.message}</pre>
</div>
<div>
<p className="text-[11px] font-semibold text-muted-foreground mb-1">{t('raw.labelsTitle')}</p>
<div className="flex flex-wrap gap-1">
{Object.entries(e.labels).map(([k, v]) => (
<span key={k} className="group inline-flex items-center rounded bg-muted px-1.5 py-0.5">
<button
type="button"
className="hover:text-primary"
title={t('raw.filterValue')}
onClick={() => onFilter(applyLabelFilter(query, k, v))}
>
{k}={v}
</button>
<button
type="button"
className="ml-1 opacity-0 group-hover:opacity-100 text-destructive"
title={t('raw.excludeValue')}
onClick={() => onFilter(applyLabelFilter(query, k, v, true))}
>
×
</button>
</span>
))}
</div>
</div>
{fields.length > 0 && (
<div>
<p className="text-[11px] font-semibold text-muted-foreground mb-1">{t('raw.fields')}</p>
<div className="flex flex-wrap gap-1">
{fields.map(([k, v]) => (
<span key={k} className="inline-flex items-center rounded bg-info/10 text-info px-1.5 py-0.5">
<button type="button" className="hover:underline" onClick={() => onFilter(applyLabelFilter(query, k, v))}>
{k}={v}
</button>
</span>
))}
</div>
</div>
)}
</div>
)}
</div>
)
})
)}
</div>
{hasMore && (
<button
type="button"
onClick={onLoadMore}
className="w-full py-2 text-xs text-primary hover:bg-primary/5 rounded-lg border border-border"
disabled={loading}
>
{loading ? t('raw.loadingMore') : t('raw.loadMore')}
</button>
)}
</div>
)
})
@@ -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,
<button type="button" className={`rounded px-3 py-1.5 text-xs ${assistantTab === 'history' ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted'}`} onClick={() => setAssistantTab('history')}>{history.length > 0 ? t('editor.historyTabCount', { count: history.length }) : t('editor.historyTab')}</button>
<span className="ml-auto self-center text-xs text-muted-foreground">{t('editor.escClose')}</span>
</div>
{assistantTab === 'quick' ? <QuickQueryTemplates compact sourceId={sourceId} query={value} onQueryChange={chooseQuery} /> :
{assistantTab === 'quick' ? <QuickQueryTemplates compact sourceId={sourceId} query={value}
onQueryChange={chooseQuery}
onInsert={onChange}
onRun={(next) => { onChange(next); onSubmit(next) }} /> :
<div className="max-h-64 overflow-y-auto">
{history.length === 0 ? <p className="px-2 py-5 text-center text-xs text-muted-foreground">{t('editor.historyEmpty')}</p> : history.map((item, index) => <button key={`${item}-${index}`} type="button" className="block w-full truncate rounded px-2 py-2 text-left font-mono text-xs hover:bg-muted" title={item} onClick={() => chooseQuery(item)}>{item}</button>)}
</div>}
@@ -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<string, string>) {
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 <section className="space-y-2" aria-label={t('templates.aria')}>
{!compact && <h2 className="text-sm font-medium">{t('templates.title')}</h2>}
@@ -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}
/>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('app')}>{t('templates.scopeLogs')}</Button>
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('error')}>{t('templates.errorLogs')}</Button>
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('warn')}>{t('templates.warnLogs')}</Button>
<Button size="sm" variant="outline" disabled={!pod.trim()} onClick={() => generate('pod')}>{t('templates.podLogs')}</Button>
<Button size="sm" variant="outline" onClick={() => generate('ingress')}>{t('templates.ingressLogs')}</Button>
{ingressReady && <Button size="sm" variant="outline" onClick={() => generate('ingress')}>{t('templates.ingressLogs')}</Button>}
{!hasScope && <span className="self-center text-xs text-muted-foreground">{t('templates.scopeHint')}</span>}
</div>
</section>
+33
View File
@@ -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 用 != 排除
*/