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

@@ -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 用 != 排除
*/