feat: 品牌统一 LokiStack + 全站中英 i18n + 用户批量删除 + 主题定时切换与浅色侧栏
- 品牌: Go 模块/数据文件/二进制/构建部署资产全部 opsx→lokistack;前端 localStorage 命名空间迁移(读旧写新,含按用户 id 前缀迁移);修复 nginx upstream 与 Makefile 引用已改名服务的断裂 - i18n: react-i18next 落地,common + 11 个页面域命名空间全站中英(菜单/顶栏/登录/列表页/表单/确认弹窗/状态徽章);设置页语言即时切换;修复词典自注册导致的循环导入白屏 - 用户管理: 批量删除(POST /users/batch-delete,不删自己/最后管理员,部分失败不阻断)+ 行复选/全选/批量工具条 - 主题: 浅色侧栏(中性灰激活态)、四态模式(浅色/深色/跟随系统/定时切换,按时区判定)、顶栏太阳月亮二态、设置页紧凑分段选择器 - UI: 登录记住用户名、CardActionButton 统一五页卡片操作钮、shadcn Badge 替换手写徽章、语义色修正 - 清理: 移除主机 Web 终端(前后端)、k8s-helm/Helm 遗留代码与字段、README 重写精简 - 修复: 侧栏与日志告警 order 重复、路由标题 i18n key 未翻译、词典双逗号等
This commit is contained in:
1 parent
02d1bd87a7
commit
f3733f0bfc
201 files changed
+5438
-2388
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { logsApi } from '@/api/logs'
|
||||
import { SearchableValueSelect } from './SearchableValueSelect'
|
||||
|
||||
@@ -15,6 +16,7 @@ interface AccessRuleEditorProps {
|
||||
|
||||
/** Exact stream-label restrictions for one user's access to a Loki source. */
|
||||
export function AccessRuleEditor({ sourceId, username, labels, onChange }: AccessRuleEditorProps) {
|
||||
const { t } = useTranslation('logs')
|
||||
const [options, setOptions] = useState<Record<PolicyLabel, string[]>>({ namespace: [], app: [], pod: [] })
|
||||
const [loading, setLoading] = useState<Record<PolicyLabel, boolean>>({ namespace: false, app: false, pod: false })
|
||||
const namespace = labels.namespace ?? ''
|
||||
@@ -45,7 +47,7 @@ export function AccessRuleEditor({ sourceId, username, labels, onChange }: Acces
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
return <div className="grid gap-2 sm:grid-cols-3 flex-1" aria-label={`${username} 查询范围`}>
|
||||
{policyLabels.map(name => <SearchableValueSelect key={name} label={name === 'namespace' ? '命名空间' : name === 'app' ? 'App' : '副本 / Pod'} values={options[name]} value={labels[name] ?? ''} onChange={value => setLabel(name, value)} allowEmpty loading={loading[name]} disabled={!sourceId} />)}
|
||||
return <div className="grid gap-2 sm:grid-cols-3 flex-1" aria-label={t('access.ruleAria', { name: username })}>
|
||||
{policyLabels.map(name => <SearchableValueSelect key={name} label={t(name === 'namespace' ? 'access.nsLabel' : name === 'app' ? 'access.appLabel' : 'access.podLabel')} values={options[name]} value={labels[name] ?? ''} onChange={value => setLabel(name, value)} allowEmpty loading={loading[name]} disabled={!sourceId} />)}
|
||||
</div>
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { IngressSample, IngressRouteStats } from '../ingress-insights'
|
||||
import { summarizeIngress } from '../ingress-insights'
|
||||
|
||||
type Mode = 'traffic' | 'errors' | 'slow' | 'detail'
|
||||
type ErrorClass = 'all' | '5xx' | '4xx'
|
||||
const modes: { id: Mode; label: string }[] = [
|
||||
{ id: 'traffic', label: '接口 PV / UV' },
|
||||
{ id: 'errors', label: '异常 Top 10' },
|
||||
{ id: 'slow', label: '慢请求 Top 10' },
|
||||
{ id: 'detail', label: '请求明细' },
|
||||
]
|
||||
const modeIds: Mode[] = ['traffic', 'errors', 'slow', 'detail']
|
||||
const modeLabelKeys: Record<Mode, string> = {
|
||||
traffic: 'ingress.modeTraffic',
|
||||
errors: 'ingress.modeErrors',
|
||||
slow: 'ingress.modeSlow',
|
||||
detail: 'ingress.modeDetail',
|
||||
}
|
||||
const heading = 'px-3 py-2 text-left font-medium whitespace-nowrap'
|
||||
const cell = 'px-3 py-2 tabular-nums'
|
||||
const formatSeconds = (seconds: number | null) => seconds === null ? '—' : `${seconds.toFixed(3)} s`
|
||||
@@ -21,6 +23,7 @@ function Route({ route }: { route: IngressRouteStats }) {
|
||||
}
|
||||
|
||||
export function IngressInsightsTable({ samples, loadedCount }: { samples: IngressSample[]; loadedCount: number }) {
|
||||
const { t } = useTranslation('logs')
|
||||
const [mode, setMode] = useState<Mode>('traffic')
|
||||
const [errorClass, setErrorClass] = useState<ErrorClass>('all')
|
||||
const [slowThreshold, setSlowThreshold] = useState(1)
|
||||
@@ -34,29 +37,30 @@ export function IngressInsightsTable({ samples, loadedCount }: { samples: Ingres
|
||||
}).slice(0, 10), [summary.routes, errorClass])
|
||||
const slow = useMemo(() => [...summary.routes].filter(route => route.slow || route.timeoutSignals)
|
||||
.sort((a, b) => b.slow - a.slow || b.timeoutSignals - a.timeoutSignals || (b.maxSeconds ?? 0) - (a.maxSeconds ?? 0)).slice(0, 10), [summary.routes])
|
||||
const modes = useMemo(() => modeIds.map(id => ({ id, label: t(modeLabelKeys[id]) })), [t])
|
||||
|
||||
return <div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">仅统计当前页已加载的 {loadedCount} 条日志,其中 {samples.length} 条为 ingress-nginx 访问日志。翻页后重新统计;UV 按非空客户端 IP 去重。</p>
|
||||
<p className="text-xs text-muted-foreground">{t('ingress.summary', { loaded: loadedCount, parsed: samples.length })}</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{[
|
||||
['PV', summary.pv], ['UV(IP)', summary.uv], ['4xx / 5xx', summary.errors], [`慢请求 ≥ ${slowThreshold}s`, summary.slow],
|
||||
[t('ingress.statPv'), summary.pv], [t('ingress.statUv'), summary.uv], [t('ingress.statErrors'), summary.errors], [t('ingress.statSlow', { threshold: slowThreshold }), summary.slow],
|
||||
].map(([label, value]) => <div key={label} className="rounded-lg border bg-muted/20 px-3 py-2"><div className="text-xs text-muted-foreground">{label}</div><div className="text-lg font-semibold tabular-nums">{value}</div></div>)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div role="tablist" aria-label="Ingress 统计方式" className="flex flex-wrap gap-1 rounded-md bg-muted/60 p-1">
|
||||
<div role="tablist" aria-label={t('ingress.modesAria')} className="flex flex-wrap gap-1 rounded-md bg-muted/60 p-1">
|
||||
{modes.map(item => <button key={item.id} type="button" role="tab" aria-selected={mode === item.id} onClick={() => setMode(item.id)} className={`rounded px-2.5 py-1.5 text-xs ${mode === item.id ? 'bg-card font-medium shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}>{item.label}</button>)}
|
||||
</div>
|
||||
{mode === 'errors' && <select aria-label="异常状态码范围" value={errorClass} onChange={event => setErrorClass(event.target.value as ErrorClass)} className="h-8 rounded border bg-background px-2 text-xs"><option value="all">全部 4xx / 5xx</option><option value="5xx">只看 5xx</option><option value="4xx">只看 4xx</option></select>}
|
||||
{mode === 'slow' && <label className="flex items-center gap-1 text-xs text-muted-foreground">慢请求阈值 <select aria-label="慢请求阈值" value={slowThreshold} onChange={event => setSlowThreshold(Number(event.target.value))} className="h-8 rounded border bg-background px-2 text-foreground"><option value={0.5}>0.5 秒</option><option value={1}>1 秒</option><option value={3}>3 秒</option><option value={5}>5 秒</option><option value={10}>10 秒</option></select></label>}
|
||||
{mode === 'errors' && <select aria-label={t('ingress.errorClassAria')} value={errorClass} onChange={event => setErrorClass(event.target.value as ErrorClass)} className="h-8 rounded border bg-background px-2 text-xs"><option value="all">{t('ingress.errorAll')}</option><option value="5xx">{t('ingress.error5xx')}</option><option value="4xx">{t('ingress.error4xx')}</option></select>}
|
||||
{mode === 'slow' && <label className="flex items-center gap-1 text-xs text-muted-foreground">{t('ingress.slowThreshold')} <select aria-label={t('ingress.slowThreshold')} value={slowThreshold} onChange={event => setSlowThreshold(Number(event.target.value))} className="h-8 rounded border bg-background px-2 text-foreground"><option value={0.5}>{t('ingress.seconds', { count: 0.5 })}</option><option value={1}>{t('ingress.seconds', { count: 1 })}</option><option value={3}>{t('ingress.seconds', { count: 3 })}</option><option value={5}>{t('ingress.seconds', { count: 5 })}</option><option value={10}>{t('ingress.seconds', { count: 10 })}</option></select></label>}
|
||||
</div>
|
||||
{mode === 'slow' && <p className="text-xs text-muted-foreground">依据 access log 的 request_time 排序;408 / 499 / 504 仅作超时线索,不能单凭状态码断定后端超时。{summary.timed < summary.pv && ` ${summary.pv - summary.timed} 条缺少有效耗时。`}</p>}
|
||||
{mode === 'traffic' && <p className="text-xs text-muted-foreground">访问成功率按 2xx / 3xx 请求数 ÷ PV 计算;路径已去掉查询参数。</p>}
|
||||
{mode === 'slow' && <p className="text-xs text-muted-foreground">{t('ingress.slowHint')}{summary.timed < summary.pv && ` ${t('ingress.slowMissing', { count: summary.pv - summary.timed })}`}</p>}
|
||||
{mode === 'traffic' && <p className="text-xs text-muted-foreground">{t('ingress.trafficHint')}</p>}
|
||||
<div className="max-h-[60vh] overflow-auto rounded-lg border">
|
||||
{mode === 'traffic' && <table className="w-full min-w-[680px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{['请求 Host', '接口路径', 'PV', 'UV(IP)', '成功率', '4xx', '5xx'].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{traffic.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.pv}</td><td className={cell}>{route.uv}</td><td className={cell}>{(route.success / route.pv * 100).toFixed(1)}%</td><td className={cell}>{route.error4xx}</td><td className={cell}>{route.error5xx}</td></tr>)}</tbody></table>}
|
||||
{mode === 'errors' && <table className="w-full min-w-[700px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{['请求 Host', '接口路径', '4xx', '5xx', '异常率', '状态码'].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{errors.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.error4xx}</td><td className={cell}>{route.error5xx}</td><td className={cell}>{((route.error4xx + route.error5xx) / route.pv * 100).toFixed(1)}%</td><td className={cell}>{route.statusCodes}</td></tr>)}</tbody></table>}
|
||||
{mode === 'slow' && <table className="w-full min-w-[700px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{['请求 Host', '接口路径', '慢请求', '最长耗时', '平均耗时', '408 / 499 / 504'].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{slow.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.slow}</td><td className={cell}>{formatSeconds(route.maxSeconds)}</td><td className={cell}>{formatSeconds(route.averageSeconds)}</td><td className={cell}>{route.timeoutSignals}</td></tr>)}</tbody></table>}
|
||||
{mode === 'detail' && <table className="w-full min-w-[800px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{['时间', '请求 Host', '接口路径', '客户端 IP', '状态', '耗时'].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{samples.map(({ access, timestamp }, index) => <tr key={`${timestamp}-${index}`} className="hover:bg-muted/40"><td className={`${cell} whitespace-nowrap`}>{new Date(timestamp).toLocaleString()}</td><td className={`${cell} max-w-48 truncate`}>{access.domain || '—'}</td><td className={`${cell} max-w-96 truncate font-mono`} title={`${access.method} ${access.path}`}>{access.method} {access.path}</td><td className={cell}>{access.clientIp || '—'}</td><td className={cell}>{access.status}</td><td className={cell}>{access.requestTime ? `${access.requestTime} s` : '—'}</td></tr>)}</tbody></table>}
|
||||
{((mode === 'traffic' && !traffic.length) || (mode === 'errors' && !errors.length) || (mode === 'slow' && !slow.length)) && <p className="px-3 py-10 text-center text-sm text-muted-foreground">当前页没有符合条件的请求</p>}
|
||||
{mode === 'traffic' && <table className="w-full min-w-[680px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{[t('ingress.thHost'), t('ingress.thPath'), 'PV', t('ingress.statUv'), t('ingress.thSuccess'), '4xx', '5xx'].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{traffic.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.pv}</td><td className={cell}>{route.uv}</td><td className={cell}>{(route.success / route.pv * 100).toFixed(1)}%</td><td className={cell}>{route.error4xx}</td><td className={cell}>{route.error5xx}</td></tr>)}</tbody></table>}
|
||||
{mode === 'errors' && <table className="w-full min-w-[700px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{[t('ingress.thHost'), t('ingress.thPath'), '4xx', '5xx', t('ingress.thErrorRate'), t('ingress.thStatus')].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{errors.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.error4xx}</td><td className={cell}>{route.error5xx}</td><td className={cell}>{((route.error4xx + route.error5xx) / route.pv * 100).toFixed(1)}%</td><td className={cell}>{route.statusCodes}</td></tr>)}</tbody></table>}
|
||||
{mode === 'slow' && <table className="w-full min-w-[700px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{[t('ingress.thHost'), t('ingress.thPath'), t('ingress.thSlow'), t('ingress.thMaxLatency'), t('ingress.thAvgLatency'), t('ingress.thTimeout')].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{slow.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.slow}</td><td className={cell}>{formatSeconds(route.maxSeconds)}</td><td className={cell}>{formatSeconds(route.averageSeconds)}</td><td className={cell}>{route.timeoutSignals}</td></tr>)}</tbody></table>}
|
||||
{mode === 'detail' && <table className="w-full min-w-[800px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{[t('ingress.thTime'), t('ingress.thHost'), t('ingress.thPath'), t('ingress.thClientIp'), t('ingress.thStatusShort'), t('ingress.thLatency')].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{samples.map(({ access, timestamp }, index) => <tr key={`${timestamp}-${index}`} className="hover:bg-muted/40"><td className={`${cell} whitespace-nowrap`}>{new Date(timestamp).toLocaleString()}</td><td className={`${cell} max-w-48 truncate`}>{access.domain || '—'}</td><td className={`${cell} max-w-96 truncate font-mono`} title={`${access.method} ${access.path}`}>{access.method} {access.path}</td><td className={cell}>{access.clientIp || '—'}</td><td className={cell}>{access.status}</td><td className={cell}>{access.requestTime ? `${access.requestTime} s` : '—'}</td></tr>)}</tbody></table>}
|
||||
{((mode === 'traffic' && !traffic.length) || (mode === 'errors' && !errors.length) || (mode === 'slow' && !slow.length)) && <p className="px-3 py-10 text-center text-sm text-muted-foreground">{t('ingress.empty')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Crosshair } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import type { LogEntry } from '@/api/logs'
|
||||
import { HighlightedLogLine } from './HighlightedLogLine'
|
||||
@@ -6,10 +7,11 @@ import { HighlightedLogLine } from './HighlightedLogLine'
|
||||
export type LogContext = { row: LogEntry; before: LogEntry[]; after: LogEntry[] }
|
||||
|
||||
function ContextRow({ row, current, terms }: { row: LogEntry; current?: boolean; terms: string[] }) {
|
||||
const { t } = useTranslation('logs')
|
||||
const match = row.message.match(/^\d{4}-\d\d-\d\dT\S+\s+(stdout|stderr)\s+[FP]\s+([\s\S]*)$/)
|
||||
return <article className={`border-l-[3px] px-3 py-2 ${current ? 'border-l-warning bg-warning/15' : 'border-l-transparent bg-card'}`}>
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2 text-xs">
|
||||
{current && <span className="inline-flex items-center gap-1 rounded bg-warning px-2 py-0.5 font-semibold text-warning-foreground"><Crosshair className="size-3" />当前日志</span>}
|
||||
{current && <span className="inline-flex items-center gap-1 rounded bg-warning px-2 py-0.5 font-semibold text-warning-foreground"><Crosshair className="size-3" />{t('context.current')}</span>}
|
||||
<time className="font-mono tabular-nums text-muted-foreground">{new Date(row.timestamp).toLocaleString()}</time>
|
||||
{match && <span className="rounded bg-muted px-1.5 py-0.5 font-mono text-muted-foreground">{match[1]}</span>}
|
||||
{row.level && <span className="rounded bg-muted px-1.5 py-0.5 text-muted-foreground">{row.level}</span>}
|
||||
@@ -19,25 +21,26 @@ function ContextRow({ row, current, terms }: { row: LogEntry; current?: boolean;
|
||||
}
|
||||
|
||||
export function LogContextDialog({ context, terms, onClose }: { context: LogContext | null; terms: string[]; onClose: () => void }) {
|
||||
const { t } = useTranslation('logs')
|
||||
return <Dialog open={context !== null} onOpenChange={open => { if (!open) onClose() }}>
|
||||
<DialogContent className="flex max-h-[85vh] flex-col gap-0 !overflow-hidden p-0 sm:max-w-4xl">
|
||||
<DialogHeader className="border-b px-5 py-4 pr-12">
|
||||
<DialogTitle>同一日志流的前后文</DialogTitle>
|
||||
<DialogDescription>{context?.row.labels.filename ? '同一文件' : '同一日志流'} · 前后各最多 5 条 · 前后 5 分钟</DialogDescription>
|
||||
<DialogTitle>{t('context.title')}</DialogTitle>
|
||||
<DialogDescription>{context?.row.labels.filename ? t('context.sameFile') : t('context.sameStream')} · {t('context.meta')}</DialogDescription>
|
||||
{context && <div className="flex flex-wrap gap-1.5 pt-1 text-xs">
|
||||
{['namespace', 'app', 'pod'].filter(key => context.row.labels[key]).map(key => <span key={key} className="rounded bg-muted px-2 py-1 font-mono text-foreground">{key}={context.row.labels[key]}</span>)}
|
||||
{context.row.labels.filename && <span title={context.row.labels.filename} className="max-w-full truncate rounded bg-muted px-2 py-1 font-mono text-foreground">文件: {context.row.labels.filename.split('/').slice(-2).join('/')}</span>}
|
||||
{context.row.labels.filename && <span title={context.row.labels.filename} className="max-w-full truncate rounded bg-muted px-2 py-1 font-mono text-foreground">{t('context.file', { name: context.row.labels.filename.split('/').slice(-2).join('/') })}</span>}
|
||||
</div>}
|
||||
</DialogHeader>
|
||||
{context && <div className="space-y-3 overflow-y-auto bg-muted/20 px-5 py-4">
|
||||
<section aria-label="之前的日志">
|
||||
<h3 className="mb-1.5 text-xs font-semibold text-muted-foreground">之前 · {context.before.length} 条</h3>
|
||||
{context.before.length ? <div className="divide-y overflow-hidden rounded-lg border">{context.before.map((row, index) => <ContextRow key={`${row.timestampNs}-${index}`} row={row} terms={terms} />)}</div> : <p className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">此前 5 分钟内无日志</p>}
|
||||
<section aria-label={t('context.beforeAria')}>
|
||||
<h3 className="mb-1.5 text-xs font-semibold text-muted-foreground">{t('context.beforeTitle', { count: context.before.length })}</h3>
|
||||
{context.before.length ? <div className="divide-y overflow-hidden rounded-lg border">{context.before.map((row, index) => <ContextRow key={`${row.timestampNs}-${index}`} row={row} terms={terms} />)}</div> : <p className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">{t('context.beforeEmpty')}</p>}
|
||||
</section>
|
||||
<section aria-label="当前日志" className="overflow-hidden rounded-lg border border-warning/70 shadow-sm"><ContextRow row={context.row} current terms={terms} /></section>
|
||||
<section aria-label="之后的日志">
|
||||
<h3 className="mb-1.5 text-xs font-semibold text-muted-foreground">之后 · {context.after.length} 条</h3>
|
||||
{context.after.length ? <div className="divide-y overflow-hidden rounded-lg border">{context.after.map((row, index) => <ContextRow key={`${row.timestampNs}-${index}`} row={row} terms={terms} />)}</div> : <p className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">此后 5 分钟内无日志</p>}
|
||||
<section aria-label={t('context.currentAria')} className="overflow-hidden rounded-lg border border-warning/70 shadow-sm"><ContextRow row={context.row} current terms={terms} /></section>
|
||||
<section aria-label={t('context.afterAria')}>
|
||||
<h3 className="mb-1.5 text-xs font-semibold text-muted-foreground">{t('context.afterTitle', { count: context.after.length })}</h3>
|
||||
{context.after.length ? <div className="divide-y overflow-hidden rounded-lg border">{context.after.map((row, index) => <ContextRow key={`${row.timestampNs}-${index}`} row={row} terms={terms} />)}</div> : <p className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">{t('context.afterEmpty')}</p>}
|
||||
</section>
|
||||
</div>}
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 {
|
||||
@@ -11,6 +12,7 @@ interface LogHistogramProps {
|
||||
|
||||
/** 日志量直方图(服务端 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,
|
||||
@@ -18,10 +20,10 @@ export const LogHistogram = memo(function LogHistogram({ buckets, loading, onZoo
|
||||
}))
|
||||
|
||||
if (loading && data.length === 0) {
|
||||
return <div className="h-20 flex items-center justify-center text-xs text-muted-foreground">统计中…</div>
|
||||
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">暂无日志量数据</div>
|
||||
return <div className="h-20 flex items-center justify-center text-xs text-muted-foreground">{t('volume.histogramEmpty')}</div>
|
||||
}
|
||||
|
||||
const handleClick = (idx: number) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Area, AreaChart, CartesianGrid, Cell, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { LogEntry } from '@/api/logs'
|
||||
import { httpStatusClass, parseIngressAccessLog, pathWithoutQuery, stripLogEnvelope } from '../ingress-access-log'
|
||||
import { IngressInsightsTable } from './IngressInsightsTable'
|
||||
@@ -20,6 +21,13 @@ export function LogResultViews({ view, entries, buckets, start, end, domainFilte
|
||||
end: string
|
||||
domainFiltered?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('logs')
|
||||
// 聚合占位名(无域名/其他域名等):同时用作数据列名与图例展示,统一从词典取,保证语言切换后聚合与展示一致
|
||||
const noDomain = t('views.noDomain')
|
||||
const otherDomain = t('views.otherDomain')
|
||||
const unknownName = t('views.unknown')
|
||||
const untaggedName = t('views.untagged')
|
||||
const otherName = t('views.other')
|
||||
const [groupBy, setGroupBy] = useState<'domain' | 'status' | 'method' | 'path' | 'upstream' | 'app' | 'namespace' | 'pod' | 'level'>('domain')
|
||||
const [lineBy, setLineBy] = useState<'domain' | 'total'>('domain')
|
||||
const [streamBy, setStreamBy] = useState<'domain' | 'status'>('domain')
|
||||
@@ -32,11 +40,11 @@ export function LogResultViews({ view, entries, buckets, start, end, domainFilte
|
||||
const streamDomains = useMemo(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const row of parsedRows) if (row) {
|
||||
const domain = row.domain || '未记录域名'
|
||||
const domain = row.domain || noDomain
|
||||
counts.set(domain, (counts.get(domain) ?? 0) + 1)
|
||||
}
|
||||
return [...counts].sort((a, b) => b[1] - a[1]).slice(0, 4).map(([domain]) => domain)
|
||||
}, [parsedRows])
|
||||
}, [parsedRows, noDomain])
|
||||
const effectiveGroupBy = ingressCount ? groupBy : ['domain', 'status', 'method', 'path', 'upstream'].includes(groupBy) ? 'app' : groupBy
|
||||
const lineData = useMemo(() => {
|
||||
if (!domainFiltered && (lineBy === 'total' || !ingressCount)) return buckets.map(bucket => ({ time: new Date(bucket.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), count: bucket.count }))
|
||||
@@ -50,13 +58,13 @@ export function LogResultViews({ view, entries, buckets, start, end, domainFilte
|
||||
if (lineBy === 'domain' && ingressCount) {
|
||||
const row = parsedRows[rowIndex]
|
||||
if (!row) continue
|
||||
const domain = row.domain || '未记录域名'
|
||||
const key = streamDomains.includes(domain) ? domain : '其他域名'
|
||||
const domain = row.domain || noDomain
|
||||
const key = streamDomains.includes(domain) ? domain : otherDomain
|
||||
counts[index][key] = Number(counts[index][key] ?? 0) + 1
|
||||
} else counts[index].count = Number(counts[index].count) + 1
|
||||
}
|
||||
return counts
|
||||
}, [buckets, domainFiltered, entries, ingressCount, lineBy, parsedRows, start, end, streamDomains])
|
||||
}, [buckets, domainFiltered, entries, ingressCount, lineBy, parsedRows, start, end, streamDomains, noDomain, otherDomain])
|
||||
const streamData = useMemo(() => {
|
||||
const from = new Date(start).getTime(), to = new Date(end).getTime()
|
||||
if (!(from < to)) return []
|
||||
@@ -69,7 +77,7 @@ export function LogResultViews({ view, entries, buckets, start, end, domainFilte
|
||||
const ingress = parsedRows[rowIndex]
|
||||
if (ingressCount) {
|
||||
if (ingress) {
|
||||
const key = streamBy === 'domain' ? (streamDomains.includes(ingress.domain || '未记录域名') ? ingress.domain || '未记录域名' : '其他域名') : httpStatusClass(ingress.status)
|
||||
const key = streamBy === 'domain' ? (streamDomains.includes(ingress.domain || noDomain) ? ingress.domain || noDomain : otherDomain) : httpStatusClass(ingress.status)
|
||||
rows[index][key] = Number(rows[index][key] ?? 0) + 1
|
||||
}
|
||||
continue
|
||||
@@ -79,47 +87,47 @@ export function LogResultViews({ view, entries, buckets, start, end, domainFilte
|
||||
rows[index][key] = Number(rows[index][key] ?? 0) + 1
|
||||
}
|
||||
return rows
|
||||
}, [entries, parsedRows, ingressCount, streamBy, streamDomains, start, end])
|
||||
}, [entries, parsedRows, ingressCount, streamBy, streamDomains, start, end, noDomain, otherDomain])
|
||||
const pieData = useMemo(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const [index, entry] of entries.entries()) {
|
||||
const ingress = parsedRows[index]
|
||||
if (['domain', 'status', 'method', 'path', 'upstream'].includes(effectiveGroupBy) && !ingress) continue
|
||||
const name = effectiveGroupBy === 'domain' ? ingress?.domain || '未记录域名'
|
||||
: effectiveGroupBy === 'status' ? ingress?.status || '未知'
|
||||
: effectiveGroupBy === 'method' ? ingress?.method || '未知'
|
||||
const name = effectiveGroupBy === 'domain' ? ingress?.domain || noDomain
|
||||
: effectiveGroupBy === 'status' ? ingress?.status || unknownName
|
||||
: effectiveGroupBy === 'method' ? ingress?.method || unknownName
|
||||
: effectiveGroupBy === 'path' ? pathWithoutQuery(ingress?.path || '/')
|
||||
: effectiveGroupBy === 'upstream' ? ingress?.upstreamName || '未标记'
|
||||
: effectiveGroupBy === 'upstream' ? ingress?.upstreamName || untaggedName
|
||||
: effectiveGroupBy === 'level' ? entry.level || 'unknown'
|
||||
: entry.labels[effectiveGroupBy] || '未标记'
|
||||
: entry.labels[effectiveGroupBy] || untaggedName
|
||||
counts.set(name, (counts.get(name) ?? 0) + 1)
|
||||
}
|
||||
const sorted = [...counts].sort((a, b) => b[1] - a[1])
|
||||
const top = sorted.slice(0, 7).map(([name, count]) => ({ name, count }))
|
||||
const other = sorted.slice(7).reduce((sum, [, count]) => sum + count, 0)
|
||||
if (other) top.push({ name: '其他', count: other })
|
||||
if (other) top.push({ name: otherName, count: other })
|
||||
return top
|
||||
}, [entries, parsedRows, effectiveGroupBy])
|
||||
}, [entries, parsedRows, effectiveGroupBy, noDomain, unknownName, untaggedName, otherName])
|
||||
|
||||
if (view === 'table') return ingressCount > 0
|
||||
? <IngressInsightsTable samples={ingressSamples} loadedCount={entries.length} />
|
||||
: <div>
|
||||
<p className="mb-2 text-xs text-muted-foreground">当前页已加载 {entries.length} 条日志。</p>
|
||||
{entries.length === 0 ? <EmptyChart message="暂无可展示的日志" /> : <div className="max-h-[60vh] overflow-auto rounded-lg border"><table className="w-full text-left text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{['时间', 'namespace / pod', '级别', '日志'].map(title => <th key={title} className="border-b px-3 py-2 font-medium">{title}</th>)}</tr></thead><tbody className="divide-y">{entries.map((entry, index) => <tr key={`${entry.timestampNs}-${index}`} className="hover:bg-muted/40"><td className="whitespace-nowrap px-3 py-2 tabular-nums">{new Date(entry.timestamp).toLocaleString()}</td><td className="px-3 py-2">{entry.labels.namespace || '—'} / {entry.labels.pod || '—'}</td><td className="px-3 py-2">{entry.level}</td><td className="max-w-xl truncate px-3 py-2 font-mono" title={stripLogEnvelope(entry.message)}>{stripLogEnvelope(entry.message)}</td></tr>)}</tbody></table></div>}
|
||||
<p className="mb-2 text-xs text-muted-foreground">{t('views.tableLoaded', { count: entries.length })}</p>
|
||||
{entries.length === 0 ? <EmptyChart message={t('views.tableEmpty')} /> : <div className="max-h-[60vh] overflow-auto rounded-lg border"><table className="w-full text-left text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{[t('views.thTime'), t('views.thNsPod'), t('views.thLevel'), t('views.thLog')].map(title => <th key={title} className="border-b px-3 py-2 font-medium">{title}</th>)}</tr></thead><tbody className="divide-y">{entries.map((entry, index) => <tr key={`${entry.timestampNs}-${index}`} className="hover:bg-muted/40"><td className="whitespace-nowrap px-3 py-2 tabular-nums">{new Date(entry.timestamp).toLocaleString()}</td><td className="px-3 py-2">{entry.labels.namespace || '—'} / {entry.labels.pod || '—'}</td><td className="px-3 py-2">{entry.level}</td><td className="max-w-xl truncate px-3 py-2 font-mono" title={stripLogEnvelope(entry.message)}>{stripLogEnvelope(entry.message)}</td></tr>)}</tbody></table></div>}
|
||||
</div>
|
||||
|
||||
if (view === 'line') return <div>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{(lineBy === 'domain' && ingressCount) || domainFiltered ? '按当前已加载日志统计;更多结果请继续加载。' : '按查询时间范围统计的日志量,来自 Loki 时间聚合。'}</p>{ingressCount > 0 && <select aria-label="折线图分组字段" className="h-8 rounded border bg-background px-2 text-xs" value={lineBy} onChange={event => setLineBy(event.target.value as typeof lineBy)}><option value="domain">按域名</option><option value="total">总量</option></select>}</div>
|
||||
{lineData.length === 0 ? <EmptyChart message="当前查询没有可用的时间统计" /> : <div className="h-72 w-full"><ResponsiveContainer width="100%" height="100%"><LineChart data={lineData} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}><CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={30} /><YAxis allowDecimals={false} tick={{ fontSize: 11 }} /><Tooltip />{lineBy === 'domain' && ingressCount ? [...streamDomains, '其他域名'].map((domain, index) => <Line key={domain} type="monotone" dataKey={domain} name={domain} stroke={colors[index % colors.length]} strokeWidth={2} dot={false} />) : <Line type="monotone" dataKey="count" name="日志条数" stroke={colors[0]} strokeWidth={2} dot={false} />}</LineChart></ResponsiveContainer></div>}
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{(lineBy === 'domain' && ingressCount) || domainFiltered ? t('views.lineByLoaded') : t('views.lineByRange')}</p>{ingressCount > 0 && <select aria-label={t('views.lineGroupAria')} className="h-8 rounded border bg-background px-2 text-xs" value={lineBy} onChange={event => setLineBy(event.target.value as typeof lineBy)}><option value="domain">{t('views.lineByDomain')}</option><option value="total">{t('views.lineByTotal')}</option></select>}</div>
|
||||
{lineData.length === 0 ? <EmptyChart message={t('views.lineEmpty')} /> : <div className="h-72 w-full"><ResponsiveContainer width="100%" height="100%"><LineChart data={lineData} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}><CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={30} /><YAxis allowDecimals={false} tick={{ fontSize: 11 }} /><Tooltip />{lineBy === 'domain' && ingressCount ? [...streamDomains, otherDomain].map((domain, index) => <Line key={domain} type="monotone" dataKey={domain} name={domain} stroke={colors[index % colors.length]} strokeWidth={2} dot={false} />) : <Line type="monotone" dataKey="count" name={t('views.lineSeriesTotal')} stroke={colors[0]} strokeWidth={2} dot={false} />}</LineChart></ResponsiveContainer></div>}
|
||||
</div>
|
||||
|
||||
if (view === 'stream') return <div>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{ingressCount ? `展示 ${ingressCount} 条已解析日志` : `按级别展示当前已加载的 ${entries.length} 条日志`},基于当前已加载结果。</p>{ingressCount > 0 && <select aria-label="流图分组字段" className="h-8 rounded border bg-background px-2 text-xs" value={streamBy} onChange={event => setStreamBy(event.target.value as typeof streamBy)}><option value="domain">按域名</option><option value="status">按 HTTP 状态段</option></select>}</div>
|
||||
{entries.length === 0 ? <EmptyChart message="暂无可展示的日志流" /> : <div className="h-72 w-full"><ResponsiveContainer width="100%" height="100%"><AreaChart data={streamData} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}><CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={30} /><YAxis allowDecimals={false} tick={{ fontSize: 11 }} /><Tooltip />{(ingressCount ? streamBy === 'domain' ? [...streamDomains, '其他域名'] : ['2xx', '3xx', '4xx', '5xx', 'other'] : ['error', 'warn', 'info', 'other']).map((level, index) => <Area key={level} type="monotone" dataKey={level} name={level} stackId="logs" stroke={colors[level === '4xx' || level === 'error' ? 4 : index % colors.length]} fill={colors[level === '4xx' || level === 'error' ? 4 : index % colors.length]} fillOpacity={0.65} />)}</AreaChart></ResponsiveContainer></div>}
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{ingressCount ? t('views.streamParsed', { count: ingressCount }) : t('views.streamByLevel', { count: entries.length })}{t('views.streamSuffix')}</p>{ingressCount > 0 && <select aria-label={t('views.streamGroupAria')} className="h-8 rounded border bg-background px-2 text-xs" value={streamBy} onChange={event => setStreamBy(event.target.value as typeof streamBy)}><option value="domain">{t('views.lineByDomain')}</option><option value="status">{t('views.streamByStatus')}</option></select>}</div>
|
||||
{entries.length === 0 ? <EmptyChart message={t('views.streamEmpty')} /> : <div className="h-72 w-full"><ResponsiveContainer width="100%" height="100%"><AreaChart data={streamData} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}><CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={30} /><YAxis allowDecimals={false} tick={{ fontSize: 11 }} /><Tooltip />{(ingressCount ? streamBy === 'domain' ? [...streamDomains, otherDomain] : ['2xx', '3xx', '4xx', '5xx', 'other'] : ['error', 'warn', 'info', 'other']).map((level, index) => <Area key={level} type="monotone" dataKey={level} name={level} stackId="logs" stroke={colors[level === '4xx' || level === 'error' ? 4 : index % colors.length]} fill={colors[level === '4xx' || level === 'error' ? 4 : index % colors.length]} fillOpacity={0.65} />)}</AreaChart></ResponsiveContainer></div>}
|
||||
</div>
|
||||
|
||||
return <div>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{['domain', 'status', 'method', 'path', 'upstream'].includes(effectiveGroupBy) ? `按访问字段统计已解析的 ${ingressCount} 条日志` : `按标签统计当前已加载的 ${entries.length} 条日志`}</p><select aria-label="饼图分组字段" className="h-8 rounded border bg-background px-2 text-xs" value={effectiveGroupBy} onChange={event => setGroupBy(event.target.value as typeof groupBy)}>{ingressCount > 0 && <><option value="domain">域名(请求 Host)</option><option value="status">HTTP 状态码</option><option value="method">请求方法</option><option value="path">请求路径</option><option value="upstream">上游服务</option></>}<option value="app">app</option><option value="namespace">namespace</option><option value="pod">pod</option><option value="level">级别</option></select></div>
|
||||
{pieData.length === 0 ? <EmptyChart message="暂无可展示的标签分布" /> : <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]"><div className="h-72"><ResponsiveContainer width="100%" height="100%"><PieChart><Pie data={pieData} dataKey="count" nameKey="name" outerRadius="75%" innerRadius="42%">{pieData.map((item, index) => <Cell key={item.name} fill={colors[index % colors.length]} />)}</Pie><Tooltip /></PieChart></ResponsiveContainer></div><div className="flex flex-col justify-center gap-2 text-xs">{pieData.map((item, index) => <div key={item.name} className="flex items-center gap-2"><span className="size-2.5 shrink-0 rounded-sm" style={{ background: colors[index % colors.length] }} /><span className="min-w-0 flex-1 truncate" title={item.name}>{item.name}</span><strong className="tabular-nums">{item.count}</strong></div>)}</div></div>}
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{['domain', 'status', 'method', 'path', 'upstream'].includes(effectiveGroupBy) ? t('views.pieByAccess', { count: ingressCount }) : t('views.pieByLabel', { count: entries.length })}</p><select aria-label={t('views.pieGroupAria')} className="h-8 rounded border bg-background px-2 text-xs" value={effectiveGroupBy} onChange={event => setGroupBy(event.target.value as typeof groupBy)}>{ingressCount > 0 && <><option value="domain">{t('views.pieDomain')}</option><option value="status">{t('views.pieStatus')}</option><option value="method">{t('views.pieMethod')}</option><option value="path">{t('views.piePath')}</option><option value="upstream">{t('views.pieUpstream')}</option></>}<option value="app">app</option><option value="namespace">namespace</option><option value="pod">pod</option><option value="level">{t('views.pieLevel')}</option></select></div>
|
||||
{pieData.length === 0 ? <EmptyChart message={t('views.pieEmpty')} /> : <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]"><div className="h-72"><ResponsiveContainer width="100%" height="100%"><PieChart><Pie data={pieData} dataKey="count" nameKey="name" outerRadius="75%" innerRadius="42%">{pieData.map((item, index) => <Cell key={item.name} fill={colors[index % colors.length]} />)}</Pie><Tooltip /></PieChart></ResponsiveContainer></div><div className="flex flex-col justify-center gap-2 text-xs">{pieData.map((item, index) => <div key={item.name} className="flex items-center gap-2"><span className="size-2.5 shrink-0 rounded-sm" style={{ background: colors[index % colors.length] }} /><span className="min-w-0 flex-1 truncate" title={item.name}>{item.name}</span><strong className="tabular-nums">{item.count}</strong></div>)}</div></div>}
|
||||
</div>
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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'
|
||||
@@ -59,6 +60,7 @@ export const LogResults = memo(function LogResults({
|
||||
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)
|
||||
@@ -94,14 +96,15 @@ export const LogResults = memo(function LogResults({
|
||||
}, [entries])
|
||||
|
||||
if (loading && entries.length === 0) {
|
||||
return <div className="h-40 flex items-center justify-center text-sm text-muted-foreground">查询中…</div>
|
||||
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>
|
||||
{display.length} 条{dedup && `(去重后,原 ${entries.length} 条)`}
|
||||
{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) => (
|
||||
@@ -114,13 +117,13 @@ export const LogResults = memo(function LogResults({
|
||||
</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">没有匹配的日志</div>
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">{t('raw.empty')}</div>
|
||||
) : (
|
||||
display.map((e, idx) => {
|
||||
const key = `${e.timestamp}-${idx}`
|
||||
@@ -147,18 +150,18 @@ export const LogResults = memo(function LogResults({
|
||||
{open && (
|
||||
<div className="px-3 pb-3 pl-10 space-y-2">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground mb-1">日志原文</p>
|
||||
<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">标签(点击过滤)</p>
|
||||
<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="过滤此值"
|
||||
title={t('raw.filterValue')}
|
||||
onClick={() => onFilter(applyLabelFilter(query, k, v))}
|
||||
>
|
||||
{k}={v}
|
||||
@@ -166,7 +169,7 @@ export const LogResults = memo(function LogResults({
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 opacity-0 group-hover:opacity-100 text-destructive"
|
||||
title="排除此值"
|
||||
title={t('raw.excludeValue')}
|
||||
onClick={() => onFilter(applyLabelFilter(query, k, v, true))}
|
||||
>
|
||||
×
|
||||
@@ -177,7 +180,7 @@ export const LogResults = memo(function LogResults({
|
||||
</div>
|
||||
{fields.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground mb-1">提取字段</p>
|
||||
<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">
|
||||
@@ -204,7 +207,7 @@ export const LogResults = memo(function LogResults({
|
||||
className="w-full py-2 text-xs text-primary hover:bg-primary/5 rounded-lg border border-border"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '加载中…' : '加载更多'}
|
||||
{loading ? t('raw.loadingMore') : t('raw.loadMore')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { LogEntry } from '@/api/logs'
|
||||
@@ -10,16 +11,21 @@ import { LogResultViews, type ResultView } from './LogResultViews'
|
||||
import { QueryEmptyState } from './QueryEmptyState'
|
||||
|
||||
type Bucket = { timestamp: string; count: number }
|
||||
const views: { id: ResultView; label: string }[] = [
|
||||
{ id: 'raw', label: '原始日志' }, { id: 'table', label: '表格' },
|
||||
{ id: 'line', label: '折线图' }, { id: 'stream', label: '流图' }, { id: 'pie', label: '饼图' },
|
||||
]
|
||||
const viewIds: ResultView[] = ['raw', 'table', 'line', 'stream', 'pie']
|
||||
const viewLabelKeys: Record<ResultView, string> = {
|
||||
raw: 'results.viewRaw',
|
||||
table: 'results.viewTable',
|
||||
line: 'results.viewLine',
|
||||
stream: 'results.viewStream',
|
||||
pie: 'results.viewPie',
|
||||
}
|
||||
|
||||
export function LogResultsPanel({ entries, buckets, start, end, searched, hasSource, pageIndex, hasNextPage, loading, loadingStartedAt, query, terms, onQueryChange, onApplyDomain, onShowContext, onSearch, onNextPage, onPreviousPage }: {
|
||||
entries: LogEntry[]; buckets: Bucket[]; start: string; end: string; searched: boolean; hasSource: boolean
|
||||
pageIndex: number; hasNextPage: boolean; loading: boolean; loadingStartedAt: number | null; query: string; terms: string[]
|
||||
onQueryChange: (query: string) => void; onApplyDomain: (domain: string) => void; onShowContext: (row: LogEntry) => void; onSearch: () => void; onNextPage: () => void; onPreviousPage: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('logs')
|
||||
const [view, setView] = useState<ResultView>('raw')
|
||||
const [domainFilter, setDomainFilter] = useState('')
|
||||
const [clock, setClock] = useState(0)
|
||||
@@ -28,45 +34,49 @@ export function LogResultsPanel({ entries, buckets, start, end, searched, hasSou
|
||||
const timer = window.setInterval(() => setClock(Date.now()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [loading])
|
||||
const views = useMemo(
|
||||
() => viewIds.map(id => ({ id, label: t(viewLabelKeys[id]) })),
|
||||
[t],
|
||||
)
|
||||
const domains = useMemo(() => [...new Set(entries.map(entry => parseIngressAccessLog(entry.message)?.domain).filter((domain): domain is string => Boolean(domain)))].sort(), [entries])
|
||||
const visibleEntries = useMemo(() => {
|
||||
const needle = domainFilter.trim().toLowerCase()
|
||||
return needle ? entries.filter(entry => parseIngressAccessLog(entry.message)?.domain.includes(needle)) : entries
|
||||
}, [entries, domainFilter])
|
||||
return <section className="rounded-xl border bg-card p-4" aria-label="日志结果">
|
||||
return <section className="rounded-xl border bg-card p-4" aria-label={t('results.aria')}>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="font-semibold">日志结果 <span className="text-sm font-normal text-muted-foreground">第 {pageIndex + 1} 页 · 本页 {entries.length} 条{hasNextPage && ' · 还有更多'}</span></h2>
|
||||
<div role="tablist" aria-label="日志展示方式" className="flex flex-wrap gap-1 rounded-lg bg-muted/60 p-1">
|
||||
<h2 className="font-semibold">{t('results.title')} <span className="text-sm font-normal text-muted-foreground">{hasNextPage ? t('results.summaryMore', { page: pageIndex + 1, count: entries.length }) : t('results.summary', { page: pageIndex + 1, count: entries.length })}</span></h2>
|
||||
<div role="tablist" aria-label={t('results.viewAria')} className="flex flex-wrap gap-1 rounded-lg bg-muted/60 p-1">
|
||||
{views.map(item => <button key={item.id} role="tab" aria-selected={view === item.id} type="button" className={`rounded px-2.5 py-1.5 text-xs transition-colors ${view === item.id ? 'bg-card font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`} onClick={() => setView(item.id)}>{item.label}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
{loading && <div role="status" aria-live="polite" className="mb-3 flex items-center gap-2 rounded-md border border-info/30 bg-info/5 px-3 py-2 text-sm text-foreground">
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-info" />
|
||||
<span>{entries.length ? '正在加载日志页' : '正在查询 Loki'} · 已等待 {clock && loadingStartedAt ? Math.max(0, Math.floor((clock - loadingStartedAt) / 1000)) : 0} 秒。大范围查询可能需要较长时间。</span>
|
||||
<span>{entries.length ? t('results.loadingPage') : t('results.loadingQuery')} · {t('results.loadingWait', { seconds: clock && loadingStartedAt ? Math.max(0, Math.floor((clock - loadingStartedAt) / 1000)) : 0 })}</span>
|
||||
</div>}
|
||||
{pageIndex > 0 && <p className="mb-2 text-xs text-muted-foreground">日志按时间从新到旧翻页;图表展示当前页,顶部时间线展示整个查询范围。</p>}
|
||||
{pageIndex > 0 && <p className="mb-2 text-xs text-muted-foreground">{t('results.pagingHint')}</p>}
|
||||
{domains.length > 0 && <div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
<label htmlFor="log-domain-filter" className="shrink-0 text-muted-foreground">域名筛选</label>
|
||||
<Input id="log-domain-filter" list="log-domain-options" type="search" className="h-8 w-60" value={domainFilter} onChange={event => setDomainFilter(event.target.value)} placeholder="输入或选择域名" />
|
||||
<label htmlFor="log-domain-filter" className="shrink-0 text-muted-foreground">{t('results.domainFilter')}</label>
|
||||
<Input id="log-domain-filter" list="log-domain-options" type="search" className="h-8 w-60" value={domainFilter} onChange={event => setDomainFilter(event.target.value)} placeholder={t('results.domainPlaceholder')} />
|
||||
<datalist id="log-domain-options">{domains.map(domain => <option key={domain} value={domain} />)}</datalist>
|
||||
<Button type="button" size="sm" variant="outline" disabled={!domainFilter.trim() || loading} onClick={() => onApplyDomain(domainFilter.trim())}>在 Loki 中查询</Button>
|
||||
<span className="text-muted-foreground">仅匹配日志记录的请求 Host。当前 {visibleEntries.length} / {entries.length} 条。</span>
|
||||
<Button type="button" size="sm" variant="outline" disabled={!domainFilter.trim() || loading} onClick={() => onApplyDomain(domainFilter.trim())}>{t('results.domainQuery')}</Button>
|
||||
<span className="text-muted-foreground">{t('results.domainHint', { shown: visibleEntries.length, total: entries.length })}</span>
|
||||
</div>}
|
||||
{view === 'raw' ? entries.length === 0 ? <QueryEmptyState searched={searched} hasSource={hasSource} onRetry={onSearch} /> : visibleEntries.length === 0 ? <p className="py-10 text-center text-sm text-muted-foreground">当前域名筛选没有匹配的日志</p> : <div className="divide-y">
|
||||
{view === 'raw' ? entries.length === 0 ? <QueryEmptyState searched={searched} hasSource={hasSource} onRetry={onSearch} /> : visibleEntries.length === 0 ? <p className="py-10 text-center text-sm text-muted-foreground">{t('results.domainNoMatch')}</p> : <div className="divide-y">
|
||||
{visibleEntries.map((row, index) => <div key={`${row.timestampNs}-${index}`} className="py-2 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<time className="font-mono tabular-nums">{new Date(row.timestamp).toLocaleString()}</time>
|
||||
<span className="rounded bg-muted px-1">{row.level}</span>
|
||||
{Object.entries(row.labels).filter(([key]) => ['namespace', 'app', 'pod'].includes(key)).map(([key, value]) => <button key={key} title="筛选此标签" className="hover:text-primary" onClick={() => onQueryChange(applyLabelFilter(query, key, value))}>{key}={value}</button>)}
|
||||
<button className="text-primary hover:underline" onClick={() => onShowContext(row)}>查看前后各 5 条</button>
|
||||
{Object.entries(row.labels).filter(([key]) => ['namespace', 'app', 'pod'].includes(key)).map(([key, value]) => <button key={key} title={t('results.filterLabel')} className="hover:text-primary" onClick={() => onQueryChange(applyLabelFilter(query, key, value))}>{key}={value}</button>)}
|
||||
<button className="text-primary hover:underline" onClick={() => onShowContext(row)}>{t('results.showContext')}</button>
|
||||
</div>
|
||||
<HighlightedLogLine message={row.message} terms={terms} className="mt-1 whitespace-pre-wrap break-all font-mono" />
|
||||
</div>)}
|
||||
</div> : <LogResultViews view={view} entries={visibleEntries} buckets={buckets} start={start} end={end} domainFiltered={Boolean(domainFilter.trim())} />}
|
||||
{searched && entries.length > 0 && <div className="mt-3 flex items-center gap-2">
|
||||
<Button variant="outline" disabled={loading || pageIndex === 0} onClick={onPreviousPage}>上一页</Button>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">第 {pageIndex + 1} 页</span>
|
||||
<Button variant="outline" disabled={loading || !hasNextPage} onClick={onNextPage}>下一页</Button>
|
||||
<Button variant="outline" disabled={loading || pageIndex === 0} onClick={onPreviousPage}>{t('results.prevPage')}</Button>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{t('results.pageOf', { page: pageIndex + 1 })}</span>
|
||||
<Button variant="outline" disabled={loading || !hasNextPage} onClick={onNextPage}>{t('results.nextPage')}</Button>
|
||||
</div>}
|
||||
</section>
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type Bucket = { timestamp: string; count: number }
|
||||
|
||||
@@ -9,6 +10,7 @@ export function LogVolumeTimeline({ buckets, onSelect }: {
|
||||
buckets: Bucket[]
|
||||
onSelect: (start: number, end: number) => void
|
||||
}) {
|
||||
const { t } = useTranslation('logs')
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
const [selection, setSelection] = useState<[number, number] | null>(null)
|
||||
const startIndex = useRef<number | null>(null)
|
||||
@@ -33,10 +35,10 @@ export function LogVolumeTimeline({ buckets, onSelect }: {
|
||||
|
||||
if (buckets.length === 0) return null
|
||||
|
||||
return <section className="rounded-xl border bg-card p-4" aria-label="日志量时间线">
|
||||
return <section className="rounded-xl border bg-card p-4" aria-label={t('volume.timelineAria')}>
|
||||
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div><h2 className="text-sm font-semibold">日志量时间线</h2><p className="text-xs text-muted-foreground">悬停查看数量,按住任意高度拖选时间;选择后自动查询。</p></div>
|
||||
<span className="text-sm tabular-nums">时间桶合计 <strong>{formatCount(total)}</strong> 条</span>
|
||||
<div><h2 className="text-sm font-semibold">{t('volume.timelineTitle')}</h2><p className="text-xs text-muted-foreground">{t('volume.timelineHint')}</p></div>
|
||||
<span className="text-sm tabular-nums">{t('volume.timelineTotal', { count: formatCount(total) })}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative h-44 min-w-0 flex-1 select-none touch-none cursor-crosshair"
|
||||
@@ -53,7 +55,7 @@ export function LogVolumeTimeline({ buckets, onSelect }: {
|
||||
</div>)}
|
||||
</div>
|
||||
{hover !== null && <div className="pointer-events-none absolute z-10 top-2 rounded-md border bg-popover px-2 py-1 text-xs text-popover-foreground shadow-sm" style={{ left: `${Math.min(78, Math.max(2, (hover + 0.5) / buckets.length * 100))}%` }}>
|
||||
{new Date(buckets[hover].timestamp).toLocaleString()} · <strong>{formatCount(buckets[hover].count)} 条</strong>
|
||||
{new Date(buckets[hover].timestamp).toLocaleString()} · <strong>{t('volume.timelineBucket', { count: formatCount(buckets[hover].count) })}</strong>
|
||||
</div>}
|
||||
</div>
|
||||
<div className="flex h-44 w-14 flex-col justify-between text-right text-xs tabular-nums text-muted-foreground"><span>{formatCount(max)}</span><span>{formatCount(Math.round(max / 2))}</span><span>0</span></div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, Tags } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { logsApi } from '@/api/logs'
|
||||
import { applyLabelFilter, extractSelector } from '../query-utils'
|
||||
@@ -17,6 +18,7 @@ function streamSelector(labels: Record<string, string>) {
|
||||
}
|
||||
|
||||
export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }: { sourceId: number; query: string; onQueryChange: (query: string) => void; start: string; end: string }) {
|
||||
const { t } = useTranslation('logs')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [labels, setLabels] = useState<string[]>([])
|
||||
const [activeLabel, setActiveLabel] = useState('')
|
||||
@@ -74,22 +76,22 @@ export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }:
|
||||
}
|
||||
}
|
||||
|
||||
return <section className="overflow-hidden rounded-lg border bg-card" aria-label="Label 浏览器">
|
||||
return <section className="overflow-hidden rounded-lg border bg-card" aria-label={t('labels.browser')}>
|
||||
<button type="button" className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm font-medium hover:bg-muted/50" aria-expanded={open} onClick={() => { setOpen(!open); if (!open) setLoadingLabels(true) }}>
|
||||
{open ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
|
||||
<Tags className="size-4 text-info" />Label 浏览器
|
||||
{Object.keys(selected).length > 0 && <span className="rounded bg-info/10 px-2 py-0.5 text-xs text-info">已选 {Object.keys(selected).length}</span>}
|
||||
<Tags className="size-4 text-info" />{t('labels.browser')}
|
||||
{Object.keys(selected).length > 0 && <span className="rounded bg-info/10 px-2 py-0.5 text-xs text-info">{t('labels.selectedCount', { count: Object.keys(selected).length })}</span>}
|
||||
</button>
|
||||
{open && <div className="grid min-h-44 border-t sm:grid-cols-[minmax(150px,220px)_1fr]">
|
||||
<div className="max-h-64 overflow-auto border-b p-1 sm:border-b-0 sm:border-r">
|
||||
{loadingLabels ? <p className="px-3 py-4 text-sm text-muted-foreground">加载标签中…</p> : labels.length === 0 ? <p className="px-3 py-4 text-sm text-muted-foreground">暂无可用标签</p> : labels.map(label => <button key={label} type="button" className={`block w-full rounded px-3 py-2 text-left font-mono text-sm ${activeLabel === label ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`} onClick={() => { if (label !== activeLabel) { setActiveLabel(label); setValues([]); setLoadingValues(true) } setFilter('') }}>{label}{selected[label] && <span className="ml-1 text-xs text-success">✓</span>}</button>)}
|
||||
{loadingLabels ? <p className="px-3 py-4 text-sm text-muted-foreground">{t('labels.loadingLabels')}</p> : labels.length === 0 ? <p className="px-3 py-4 text-sm text-muted-foreground">{t('labels.noLabels')}</p> : labels.map(label => <button key={label} type="button" className={`block w-full rounded px-3 py-2 text-left font-mono text-sm ${activeLabel === label ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`} onClick={() => { if (label !== activeLabel) { setActiveLabel(label); setValues([]); setLoadingValues(true) } setFilter('') }}>{label}{selected[label] && <span className="ml-1 text-xs text-success">✓</span>}</button>)}
|
||||
</div>
|
||||
<div className="min-w-0 p-3">
|
||||
{!activeLabel ? <p className="py-12 text-center text-sm text-muted-foreground">选择左侧标签,再搜索对应的值</p> : <><div className="mb-3 flex flex-wrap items-center gap-2"><strong className="text-sm">{activeLabel} 的值</strong><Input aria-label={`搜索 ${activeLabel} 的值`} className="h-8 max-w-sm" placeholder="搜索或输入标签值" value={filter} onChange={event => setFilter(event.target.value)} /></div>
|
||||
<label className="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground"><input type="checkbox" checked={scoped} onChange={event => setScoped(event.target.checked)} />按当前查询条件缩小候选</label>
|
||||
<div className="grid max-h-48 gap-1 overflow-auto sm:grid-cols-2 xl:grid-cols-3">{loadingValues ? <p className="p-2 text-sm text-muted-foreground">加载中…</p> : shown.length === 0 ? <p className="p-2 text-sm text-muted-foreground">没有匹配值</p> : shown.map(value => <button key={value} type="button" title={value} onClick={() => choose(activeLabel, value)} className={`truncate rounded px-2 py-1.5 text-left font-mono text-sm ${selected[activeLabel] === value ? 'bg-success/10 text-success' : 'hover:bg-muted'}`}>{value}</button>)}</div>
|
||||
{filter.trim() && !values.includes(filter.trim()) && <button type="button" className="mt-2 rounded border px-2 py-1 text-xs text-primary hover:bg-muted" onClick={() => choose(activeLabel, filter.trim())}>使用“{filter.trim()}”作为值</button>}
|
||||
<p className="mt-2 text-xs text-muted-foreground">先选 namespace 可查该命名空间全部副本;再选 app / service_name 可缩小到某个服务(无 app 标签的工作负载用 namespace 即可)。候选来自当前时间范围内已写入 Loki 的日志;授权范围仍由服务端限制。</p>
|
||||
{!activeLabel ? <p className="py-12 text-center text-sm text-muted-foreground">{t('labels.pickHint')}</p> : <><div className="mb-3 flex flex-wrap items-center gap-2"><strong className="text-sm">{t('labels.valuesOf', { label: activeLabel })}</strong><Input aria-label={t('labels.searchAria', { label: activeLabel })} className="h-8 max-w-sm" placeholder={t('labels.searchPlaceholder')} value={filter} onChange={event => setFilter(event.target.value)} /></div>
|
||||
<label className="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground"><input type="checkbox" checked={scoped} onChange={event => setScoped(event.target.checked)} />{t('labels.scoped')}</label>
|
||||
<div className="grid max-h-48 gap-1 overflow-auto sm:grid-cols-2 xl:grid-cols-3">{loadingValues ? <p className="p-2 text-sm text-muted-foreground">{t('labels.loading')}</p> : shown.length === 0 ? <p className="p-2 text-sm text-muted-foreground">{t('labels.noValues')}</p> : shown.map(value => <button key={value} type="button" title={value} onClick={() => choose(activeLabel, value)} className={`truncate rounded px-2 py-1.5 text-left font-mono text-sm ${selected[activeLabel] === value ? 'bg-success/10 text-success' : 'hover:bg-muted'}`}>{value}</button>)}</div>
|
||||
{filter.trim() && !values.includes(filter.trim()) && <button type="button" className="mt-2 rounded border px-2 py-1 text-xs text-primary hover:bg-muted" onClick={() => choose(activeLabel, filter.trim())}>{t('labels.useAsValue', { value: filter.trim() })}</button>}
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t('labels.hint')}</p>
|
||||
</>}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog'
|
||||
@@ -20,6 +21,7 @@ const emptyForm = (sourceId: number): PolicyForm => ({ name: '', sourceId, label
|
||||
const ANYONE = 0
|
||||
|
||||
export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
|
||||
const { t } = useTranslation('logs')
|
||||
const [groups, setGroups] = useState<LogPolicyGroup[]>([])
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [form, setForm] = useState<PolicyForm>(() => emptyForm(sources[0]?.id ?? 0))
|
||||
@@ -42,7 +44,7 @@ export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
|
||||
setGroups(policies)
|
||||
setUsers(allUsers.filter(user => user.role !== 'admin'))
|
||||
setError('')
|
||||
} catch (err) { setError(err instanceof Error ? err.message : '加载查询策略失败') }
|
||||
} catch (err) { setError(err instanceof Error ? err.message : t('access.loadFailed')) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
useEffect(() => { void reload() }, [])
|
||||
@@ -50,7 +52,7 @@ export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
|
||||
|
||||
async function save() {
|
||||
if (!form.name.trim() || !form.sourceId || form.userIds.length === 0) {
|
||||
toast.error('请填写策略组名称,选择数据源,并选择「所有人」或至少一位用户')
|
||||
toast.error(t('access.validate'))
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
@@ -60,7 +62,7 @@ export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
|
||||
else await logsApi.createPolicyGroup(body)
|
||||
await reload()
|
||||
setForm(emptyForm(form.sourceId))
|
||||
} catch (err) { toast.error(err instanceof Error ? err.message : '保存策略组失败') }
|
||||
} catch (err) { toast.error(err instanceof Error ? err.message : t('access.saveFailed')) }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
@@ -72,32 +74,32 @@ export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
|
||||
setDeleteTarget(null)
|
||||
if (form.id === deleteTarget.id) setForm(emptyForm(form.sourceId))
|
||||
await reload()
|
||||
} catch (err) { toast.error(err instanceof Error ? err.message : '删除策略组失败') }
|
||||
} catch (err) { toast.error(err instanceof Error ? err.message : t('access.deleteFailed')) }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const visibleUsers = users.filter(user => user.username.toLowerCase().includes(filter.toLowerCase()) || (user.nickname ?? '').toLowerCase().includes(filter.toLowerCase()))
|
||||
const assignedElsewhere = new Set(groups.filter(group => group.sourceId === form.sourceId && group.id !== form.id).flatMap(group => group.userIds))
|
||||
const sourceName = (id: number) => sources.find(source => source.id === id)?.name ?? `数据源 #${id}`
|
||||
const sourceName = (id: number) => sources.find(source => source.id === id)?.name ?? t('access.sourceRef', { id })
|
||||
|
||||
return <div className="grid gap-6 xl:grid-cols-[minmax(240px,0.85fr)_minmax(0,2fr)]">
|
||||
<aside className="rounded-xl border bg-card p-5 space-y-4">
|
||||
<div className="flex items-center justify-between gap-2"><h2 className="font-semibold">查询策略组</h2><Button size="sm" onClick={() => setForm(emptyForm(sources[0]?.id ?? 0))}>新建</Button></div>
|
||||
<p className="text-sm text-muted-foreground">一套日志范围可关联多个用户,后续修改会同时生效。</p>
|
||||
{loading && <p className="text-sm text-muted-foreground">加载中…</p>}
|
||||
<div className="flex items-center justify-between gap-2"><h2 className="font-semibold">{t('access.listTitle')}</h2><Button size="sm" onClick={() => setForm(emptyForm(sources[0]?.id ?? 0))}>{t('access.create')}</Button></div>
|
||||
<p className="text-sm text-muted-foreground">{t('access.listHint')}</p>
|
||||
{loading && <p className="text-sm text-muted-foreground">{t('access.loading')}</p>}
|
||||
{error && <ErrorState message={error} onRetry={() => void reload()} />}
|
||||
{!loading && !error && groups.length === 0 && <p className="text-sm text-muted-foreground">还没有策略组</p>}
|
||||
{!loading && !error && groups.length === 0 && <p className="text-sm text-muted-foreground">{t('access.empty')}</p>}
|
||||
<div className="space-y-2">{groups.map(group => <button key={group.id} type="button" onClick={() => setForm({ ...group, labels: { ...group.labels }, userIds: [...group.userIds] })} className={`w-full rounded-lg border p-3 text-left transition-colors ${form.id === group.id ? 'border-primary bg-primary/5' : 'hover:bg-muted'}`}>
|
||||
<strong className="block text-sm">{group.name}</strong><span className="block mt-1 text-xs text-muted-foreground">{sourceName(group.sourceId)} · {group.userIds.includes(ANYONE) ? '所有人' : `${group.userIds.length} 位用户`}</span><span className="block mt-1 truncate font-mono text-xs text-muted-foreground">{JSON.stringify(group.labels)}</span>
|
||||
<strong className="block text-sm">{group.name}</strong><span className="block mt-1 text-xs text-muted-foreground">{sourceName(group.sourceId)} · {group.userIds.includes(ANYONE) ? t('access.anyone') : t('access.userCount', { count: group.userIds.length })}</span><span className="block mt-1 truncate font-mono text-xs text-muted-foreground">{JSON.stringify(group.labels)}</span>
|
||||
</button>)}</div>
|
||||
</aside>
|
||||
<section className="rounded-xl border bg-card p-5 space-y-6">
|
||||
<div className="flex items-start justify-between gap-3"><div><h2 className="font-semibold">{form.id ? '编辑策略组' : '新建策略组'}</h2><p className="mt-1 text-sm text-muted-foreground">选择数据源及允许的日志范围,再关联需要复用此策略的用户。</p></div>{form.id && <Button variant="outline" size="sm" className="text-destructive" onClick={() => setDeleteTarget(groups.find(group => group.id === form.id) ?? null)}>删除</Button>}</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2"><label className="text-sm font-medium">策略组名称<Input className="mt-1" value={form.name} onChange={event => setForm(current => ({ ...current, name: event.target.value }))} placeholder="例如:生产环境 Agent 日志" /></label><label className="text-sm font-medium">数据源<select className="mt-1 block h-9 w-full rounded-md border bg-background px-3" value={form.sourceId} onChange={event => setForm(current => ({ ...current, sourceId: Number(event.target.value), labels: {}, userIds: [] }))}>{sources.length === 0 && <option value={0}>请先添加数据源</option>}{sources.map(source => <option key={source.id} value={source.id}>{source.name}</option>)}</select></label></div>
|
||||
<div><h3 className="mb-2 text-sm font-medium">允许查询的日志范围</h3><AccessRuleEditor sourceId={form.sourceId} username={form.name || '策略组'} labels={form.labels} onChange={labels => setForm(current => ({ ...current, labels }))} /><p className="mt-2 text-xs text-muted-foreground">可按命名空间、App、副本 / Pod 精确限制。未选择的标签不作限制。</p></div>
|
||||
<div className="space-y-3"><div className="flex items-center justify-between gap-2"><h3 className="text-sm font-medium">关联用户 <span className="text-muted-foreground">已选 {form.userIds.includes(ANYONE) ? '所有人(anyone)' : `${form.userIds.length} 位`}</span></h3><div className="flex gap-2"><Button size="sm" variant={form.userIds.includes(ANYONE) ? 'default' : 'outline'} onClick={() => setForm(current => current.userIds.includes(ANYONE) ? { ...current, userIds: [] } : { ...current, userIds: [ANYONE] })}>{form.userIds.includes(ANYONE) ? '✓ 所有人(anyone)' : '所有人(anyone)'}</Button><Button size="sm" variant="outline" disabled={form.userIds.includes(ANYONE)} onClick={() => setForm(current => ({ ...current, userIds: Array.from(new Set([...current.userIds, ...visibleUsers.filter(user => !assignedElsewhere.has(user.id)).map(user => user.id)])) }))}>选择当前筛选</Button><Button size="sm" variant="ghost" onClick={() => setForm(current => ({ ...current, userIds: [] }))}>清空</Button></div></div><p className="text-xs text-muted-foreground">同一用户在同一数据源只能属于一个策略组;已在其他组的用户会置灰。</p><Input aria-label="搜索用户" value={filter} onChange={event => setFilter(event.target.value)} placeholder="搜索用户名或昵称" /><div className="max-h-60 overflow-auto rounded-md border p-2 grid gap-1 sm:grid-cols-2 lg:grid-cols-3">{visibleUsers.map(user => <label key={user.id} className={`flex items-center gap-2 rounded px-2 py-1.5 text-sm ${assignedElsewhere.has(user.id) ? 'text-muted-foreground' : 'hover:bg-muted'}`}><input type="checkbox" disabled={assignedElsewhere.has(user.id) || form.userIds.includes(ANYONE)} checked={form.userIds.includes(user.id)} onChange={event => setForm(current => ({ ...current, userIds: event.target.checked ? [...current.userIds, user.id] : current.userIds.filter(id => id !== user.id) }))} /><span className="truncate">{user.username}</span>{assignedElsewhere.has(user.id) && <span className="text-xs">已分组</span>}</label>)}{visibleUsers.length === 0 && <p className="p-2 text-sm text-muted-foreground">没有匹配用户</p>}</div></div>
|
||||
<div className="flex justify-end"><Button disabled={saving || !sources.length} onClick={() => void save()}>{saving ? '保存中…' : '保存策略组'}</Button></div>
|
||||
<div className="flex items-start justify-between gap-3"><div><h2 className="font-semibold">{form.id ? t('access.editTitle') : t('access.createTitle')}</h2><p className="mt-1 text-sm text-muted-foreground">{t('access.formHint')}</p></div>{form.id && <Button variant="outline" size="sm" className="text-destructive" onClick={() => setDeleteTarget(groups.find(group => group.id === form.id) ?? null)}>{t('access.remove')}</Button>}</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2"><label className="text-sm font-medium">{t('access.nameLabel')}<Input className="mt-1" value={form.name} onChange={event => setForm(current => ({ ...current, name: event.target.value }))} placeholder={t('access.namePlaceholder')} /></label><label className="text-sm font-medium">{t('access.sourceLabel')}<select className="mt-1 block h-9 w-full rounded-md border bg-background px-3" value={form.sourceId} onChange={event => setForm(current => ({ ...current, sourceId: Number(event.target.value), labels: {}, userIds: [] }))}>{sources.length === 0 && <option value={0}>{t('access.sourceEmpty')}</option>}{sources.map(source => <option key={source.id} value={source.id}>{source.name}</option>)}</select></label></div>
|
||||
<div><h3 className="mb-2 text-sm font-medium">{t('access.scopeTitle')}</h3><AccessRuleEditor sourceId={form.sourceId} username={form.name || t('access.ownerFallback')} labels={form.labels} onChange={labels => setForm(current => ({ ...current, labels }))} /><p className="mt-2 text-xs text-muted-foreground">{t('access.scopeHint')}</p></div>
|
||||
<div className="space-y-3"><div className="flex items-center justify-between gap-2"><h3 className="text-sm font-medium">{t('access.usersTitle')} <span className="text-muted-foreground">{form.userIds.includes(ANYONE) ? t('access.selectedAnyone') : t('access.selectedCount', { count: form.userIds.length })}</span></h3><div className="flex gap-2"><Button size="sm" variant={form.userIds.includes(ANYONE) ? 'default' : 'outline'} onClick={() => setForm(current => current.userIds.includes(ANYONE) ? { ...current, userIds: [] } : { ...current, userIds: [ANYONE] })}>{form.userIds.includes(ANYONE) ? t('access.anyoneActive') : t('access.anyoneToggle')}</Button><Button size="sm" variant="outline" disabled={form.userIds.includes(ANYONE)} onClick={() => setForm(current => ({ ...current, userIds: Array.from(new Set([...current.userIds, ...visibleUsers.filter(user => !assignedElsewhere.has(user.id)).map(user => user.id)])) }))}>{t('access.selectFiltered')}</Button><Button size="sm" variant="ghost" onClick={() => setForm(current => ({ ...current, userIds: [] }))}>{t('access.clear')}</Button></div></div><p className="text-xs text-muted-foreground">{t('access.usersHint')}</p><Input aria-label={t('access.searchAria')} value={filter} onChange={event => setFilter(event.target.value)} placeholder={t('access.searchPlaceholder')} /><div className="max-h-60 overflow-auto rounded-md border p-2 grid gap-1 sm:grid-cols-2 lg:grid-cols-3">{visibleUsers.map(user => <label key={user.id} className={`flex items-center gap-2 rounded px-2 py-1.5 text-sm ${assignedElsewhere.has(user.id) ? 'text-muted-foreground' : 'hover:bg-muted'}`}><input type="checkbox" disabled={assignedElsewhere.has(user.id) || form.userIds.includes(ANYONE)} checked={form.userIds.includes(user.id)} onChange={event => setForm(current => ({ ...current, userIds: event.target.checked ? [...current.userIds, user.id] : current.userIds.filter(id => id !== user.id) }))} /><span className="truncate">{user.username}</span>{assignedElsewhere.has(user.id) && <span className="text-xs">{t('access.grouped')}</span>}</label>)}{visibleUsers.length === 0 && <p className="p-2 text-sm text-muted-foreground">{t('access.noMatch')}</p>}</div></div>
|
||||
<div className="flex justify-end"><Button disabled={saving || !sources.length} onClick={() => void save()}>{saving ? t('access.saving') : t('access.save')}</Button></div>
|
||||
</section>
|
||||
<ConfirmDialog open={deleteTarget !== null} onOpenChange={open => { if (!open) setDeleteTarget(null) }} name={deleteTarget?.name} title="删除查询策略组" description={`删除「${deleteTarget?.name ?? ''}」后,关联用户将失去由该组授予的数据源访问权限。`} loading={saving} onConfirm={() => void remove()} />
|
||||
<ConfirmDialog open={deleteTarget !== null} onOpenChange={open => { if (!open) setDeleteTarget(null) }} name={deleteTarget?.name} title={t('access.deleteTitle')} description={t('access.deleteDesc', { name: deleteTarget?.name ?? '' })} loading={saving} onConfirm={() => void remove()} />
|
||||
</div>
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function QueryEmptyState({ searched, hasSource, onRetry }: { searched: boolean; hasSource: boolean; onRetry: () => void }) {
|
||||
const { t } = useTranslation('logs')
|
||||
return <div className="flex min-h-56 flex-col items-center justify-center gap-3 rounded-lg bg-muted/20 px-4 text-center">
|
||||
<div className="rounded-full bg-muted p-3"><Search className="size-6 text-muted-foreground" /></div>
|
||||
<p className="font-medium">{!hasSource ? '暂无可查询的数据源' : searched ? '未找到匹配的日志记录' : '输入 LogQL 并运行查询'}</p>
|
||||
<p className="text-sm text-muted-foreground">{!hasSource ? '请联系管理员添加或授权数据源' : searched ? '当前时间范围和筛选条件下没有匹配结果' : '也可以使用 Label 浏览器快速构建查询'}</p>
|
||||
{hasSource && searched && <Button size="sm" variant="outline" onClick={onRetry}>重新查询</Button>}
|
||||
<p className="font-medium">{!hasSource ? t('results.noSourceTitle') : searched ? t('results.noMatchTitle') : t('results.idleTitle')}</p>
|
||||
<p className="text-sm text-muted-foreground">{!hasSource ? t('results.noSourceHint') : searched ? t('results.noMatchHint') : t('results.idleHint')}</p>
|
||||
{hasSource && searched && <Button size="sm" variant="outline" onClick={onRetry}>{t('results.retry')}</Button>}
|
||||
</div>
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { logsApi } from '@/api/logs'
|
||||
import { QuickQueryTemplates } from './QuickQueryTemplates'
|
||||
|
||||
@@ -29,6 +30,7 @@ interface QueryInputProps {
|
||||
|
||||
/** LogQL editor with syntax colors and label/operator suggestions. */
|
||||
export const QueryInput = memo(function QueryInput({ value, onChange, onSubmit, sourceId, history }: QueryInputProps) {
|
||||
const { t } = useTranslation('logs')
|
||||
const [labels, setLabels] = useState<string[]>([])
|
||||
const [hints, setHints] = useState<string[]>([])
|
||||
const [active, setActive] = useState(0)
|
||||
@@ -125,7 +127,7 @@ export const QueryInput = memo(function QueryInput({ value, onChange, onSubmit,
|
||||
<div className="relative h-10 overflow-hidden rounded-md border border-input bg-background focus-within:ring-2 focus-within:ring-ring">
|
||||
<pre ref={overlay} aria-hidden className="pointer-events-none absolute inset-0 m-0 overflow-hidden whitespace-pre p-2.5 font-mono text-sm leading-5 text-foreground">{colorizedLogQL(value)}{'\u200b'}</pre>
|
||||
{!value && <span aria-hidden className="pointer-events-none absolute inset-0 flex items-center px-2.5 font-mono text-sm text-muted-foreground">{'{app="service"} |= "error"'}</span>}
|
||||
<textarea ref={editor} aria-label="LogQL 查询语句" placeholder={'{app="service"} |= "error"'} value={value} rows={1} wrap="off" spellCheck={false} autoComplete="off"
|
||||
<textarea ref={editor} aria-label={t('editor.aria')} placeholder={'{app="service"} |= "error"'} value={value} rows={1} wrap="off" spellCheck={false} autoComplete="off"
|
||||
onFocus={() => setAssistantOpen(true)}
|
||||
onChange={event => { onChange(event.target.value); computeHints(event.target.value, event.target.selectionStart) }}
|
||||
onClick={event => computeHints(event.currentTarget.value, event.currentTarget.selectionStart)}
|
||||
@@ -141,18 +143,18 @@ export const QueryInput = memo(function QueryInput({ value, onChange, onSubmit,
|
||||
/>
|
||||
</div>
|
||||
{hints.length > 0 && <div role="listbox" className="absolute left-0 right-0 top-full z-40 mt-1 max-h-56 overflow-auto rounded-md border bg-popover p-1 shadow-lg">
|
||||
{hintKind === 'value' && <p className="px-2 py-1 text-xs text-muted-foreground">{currentLabel} 的值</p>}
|
||||
{hintKind === 'value' && <p className="px-2 py-1 text-xs text-muted-foreground">{t('editor.valuesOf', { label: currentLabel })}</p>}
|
||||
{hints.map((hint, index) => <button role="option" aria-selected={index === active} type="button" key={`${hint}-${index}`} onMouseDown={event => { event.preventDefault(); applyHint(hint) }} className={`block w-full rounded px-2 py-1.5 text-left font-mono text-xs ${index === active ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`}>{hint}</button>)}
|
||||
</div>}
|
||||
{assistantOpen && hints.length === 0 && <div className="absolute left-0 top-full z-30 mt-1 w-[min(90vw,760px)] max-w-full rounded-lg border bg-popover p-3 shadow-xl">
|
||||
<div className="mb-2 flex gap-1 border-b pb-2">
|
||||
<button type="button" className={`rounded px-3 py-1.5 text-xs ${assistantTab === 'quick' ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted'}`} onClick={() => setAssistantTab('quick')}>快捷 LogQL</button>
|
||||
<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 && `(${history.length})`}</button>
|
||||
<span className="ml-auto self-center text-xs text-muted-foreground">Esc 关闭</span>
|
||||
<button type="button" className={`rounded px-3 py-1.5 text-xs ${assistantTab === 'quick' ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted'}`} onClick={() => setAssistantTab('quick')}>{t('editor.quickTab')}</button>
|
||||
<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} /> :
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{history.length === 0 ? <p className="px-2 py-5 text-center text-xs text-muted-foreground">运行查询后,常用 LogQL 会出现在这里</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>)}
|
||||
{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>}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { logsApi } from '@/api/logs'
|
||||
import { SearchableValueSelect } from './SearchableValueSelect'
|
||||
@@ -21,6 +22,7 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
|
||||
onQueryChange: (query: string) => void
|
||||
compact?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('logs')
|
||||
const [namespace, setNamespace] = useState('')
|
||||
const [app, setApp] = useState('')
|
||||
const [pod, setPod] = useState('')
|
||||
@@ -30,7 +32,7 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
|
||||
/**
|
||||
* 实际用来标识「应用」的标签名。
|
||||
*
|
||||
* K8s 惯例是 app 标签,但很多集群(如本环境)Deployment 模板没打 app,
|
||||
* Kubernetes 惯例是 app 标签,但很多集群(如本环境)Deployment 模板没打 app,
|
||||
* 只有 Kuboard 注入的 k8s.kuboard.cn/name,此时 Loki 里 app 只剩零星几个值。
|
||||
* Promtail 会从容器名生成 service_name / container,覆盖度更高,
|
||||
* 因此按 app → service_name → container 逐级回退。
|
||||
@@ -153,47 +155,47 @@ 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)
|
||||
|
||||
return <section className="space-y-2" aria-label="快捷查询模板">
|
||||
{!compact && <h2 className="text-sm font-medium">快捷生成 LogQL</h2>}
|
||||
{!compact && <p className="text-xs text-muted-foreground">先选 namespace,再选 app,最后选副本 pod;app 候选只来自所选命名空间。</p>}
|
||||
return <section className="space-y-2" aria-label={t('templates.aria')}>
|
||||
{!compact && <h2 className="text-sm font-medium">{t('templates.title')}</h2>}
|
||||
{!compact && <p className="text-xs text-muted-foreground">{t('templates.hint')}</p>}
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
{/* 用 SearchableValueSelect(点击即展开列表 + 可搜索),
|
||||
原生 datalist 只在输入时才提示、样式弱,容易让人以为「下拉是空的」 */}
|
||||
<SearchableValueSelect
|
||||
label="命名空间"
|
||||
label={t('templates.namespace')}
|
||||
values={namespaceOptions}
|
||||
value={namespace}
|
||||
onChange={chooseNamespace}
|
||||
placeholder={`请选择(共 ${namespaceOptions.length} 个)`}
|
||||
placeholder={t('templates.selectCount', { count: namespaceOptions.length })}
|
||||
allowEmpty
|
||||
loading={!namespaceOptions.length}
|
||||
/>
|
||||
<SearchableValueSelect
|
||||
label={appLabel === 'app' ? 'App' : `App(${appLabel})`}
|
||||
label={appLabel === 'app' ? t('templates.app') : t('templates.appWithKey', { key: appLabel })}
|
||||
values={apps}
|
||||
value={app}
|
||||
onChange={chooseApp}
|
||||
placeholder={apps.length ? `请选择(共 ${apps.length} 个)` : namespace ? '该范围内无 app' : '先选命名空间可缩小范围'}
|
||||
placeholder={apps.length ? t('templates.selectCount', { count: apps.length }) : namespace ? t('templates.noApp') : t('templates.pickNsFirst')}
|
||||
allowEmpty
|
||||
disabled={!sourceId}
|
||||
/>
|
||||
<SearchableValueSelect
|
||||
label="副本 / Pod"
|
||||
label={t('templates.pod')}
|
||||
values={pods}
|
||||
value={pod}
|
||||
onChange={setPod}
|
||||
placeholder={pods.length ? `请选择(共 ${pods.length} 个)` : app ? '该 app 下无副本' : '可选'}
|
||||
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')}>查询该范围日志</Button>
|
||||
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('error')}>错误 error</Button>
|
||||
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('warn')}>告警 warn</Button>
|
||||
<Button size="sm" variant="outline" disabled={!pod.trim()} onClick={() => generate('pod')}>指定副本日志</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => generate('ingress')}>Ingress 访问日志</Button>
|
||||
{!hasScope && <span className="self-center text-xs text-muted-foreground">先选 namespace,再选 app 或 pod,也可在 Label 浏览器选值</span>}
|
||||
<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>
|
||||
{!hasScope && <span className="self-center text-xs text-muted-foreground">{t('templates.scopeHint')}</span>}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
interface SearchableValueSelectProps {
|
||||
@@ -14,6 +15,7 @@ interface SearchableValueSelectProps {
|
||||
|
||||
/** A searchable single-value combobox for Loki label values. */
|
||||
export function SearchableValueSelect({ label, values, value, onChange, placeholder, allowEmpty = false, loading = false, disabled = false }: SearchableValueSelectProps) {
|
||||
const { t } = useTranslation('logs')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [filter, setFilter] = useState('')
|
||||
const [active, setActive] = useState(0)
|
||||
@@ -46,7 +48,7 @@ export function SearchableValueSelect({ label, values, value, onChange, placehol
|
||||
aria-controls={`${id}-options`}
|
||||
disabled={disabled}
|
||||
value={open ? filter : value}
|
||||
placeholder={placeholder ?? '搜索并选择'}
|
||||
placeholder={placeholder ?? t('select.placeholder')}
|
||||
onFocus={() => { setFilter(''); setOpen(true) }}
|
||||
onChange={event => { setFilter(event.target.value); setActive(0); setOpen(true) }}
|
||||
onKeyDown={event => {
|
||||
@@ -57,8 +59,8 @@ export function SearchableValueSelect({ label, values, value, onChange, placehol
|
||||
}}
|
||||
/>
|
||||
{open && <div id={`${id}-options`} role="listbox" className="absolute z-30 mt-1 max-h-56 w-full overflow-auto rounded-md border bg-popover p-1 shadow-lg">
|
||||
{allowEmpty && <button type="button" role="option" aria-selected={!value} className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-muted" onMouseDown={event => event.preventDefault()} onClick={() => choose('')}>不限</button>}
|
||||
{loading ? <p className="px-2 py-1.5 text-sm text-muted-foreground">加载中…</p> : choices.length === 0 ? <p className="px-2 py-1.5 text-sm text-muted-foreground">无匹配值</p> : choices.slice(0, 200).map((item, index) =>
|
||||
{allowEmpty && <button type="button" role="option" aria-selected={!value} className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-muted" onMouseDown={event => event.preventDefault()} onClick={() => choose('')}>{t('select.any')}</button>}
|
||||
{loading ? <p className="px-2 py-1.5 text-sm text-muted-foreground">{t('select.loading')}</p> : choices.length === 0 ? <p className="px-2 py-1.5 text-sm text-muted-foreground">{t('select.noMatch')}</p> : choices.slice(0, 200).map((item, index) =>
|
||||
<button key={item} type="button" role="option" aria-selected={item === value} className={`block w-full rounded px-2 py-1.5 text-left text-sm ${index === active ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`} onMouseDown={event => event.preventDefault()} onClick={() => choose(item)}>{item}</button>
|
||||
)}
|
||||
</div>}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CalendarRange, ChevronDown } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { getUser } from '@/lib/auth-storage'
|
||||
|
||||
// 预设时间范围:unit 决定文案键(minutes/hours),count 用于插值与复数
|
||||
const presets = [
|
||||
{ label: '最近 5 分钟', ms: 300000 },
|
||||
{ label: '最近 15 分钟', ms: 900000 },
|
||||
{ label: '最近 1 小时', ms: 3600000 },
|
||||
{ label: '最近 6 小时', ms: 21600000 },
|
||||
{ label: '最近 24 小时', ms: 86400000 },
|
||||
]
|
||||
{ unit: 'minutes', count: 5, ms: 300000 },
|
||||
{ unit: 'minutes', count: 15, ms: 900000 },
|
||||
{ unit: 'hours', count: 1, ms: 3600000 },
|
||||
{ unit: 'hours', count: 6, ms: 21600000 },
|
||||
{ unit: 'hours', count: 24, ms: 86400000 },
|
||||
] as const
|
||||
const localTime = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
|
||||
const historyKey = () => `opsxloki.logs.range-history.${getUser()?.id ?? 'anonymous'}`
|
||||
const historyKey = () => `lokistack.logs.range-history.${getUser()?.id ?? 'anonymous'}`
|
||||
type SavedRange = { start: string; end: string }
|
||||
|
||||
function readHistory(): SavedRange[] {
|
||||
@@ -23,6 +25,7 @@ function readHistory(): SavedRange[] {
|
||||
}
|
||||
|
||||
export function TimeRangePicker({ start, end, onChange }: { start: string; end: string; onChange: (start: string, end: string) => void }) {
|
||||
const { t } = useTranslation('logs')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [draftStart, setDraftStart] = useState(start)
|
||||
const [draftEnd, setDraftEnd] = useState(end)
|
||||
@@ -46,8 +49,11 @@ export function TimeRangePicker({ start, end, onChange }: { start: string; end:
|
||||
try { localStorage.setItem(historyKey(), JSON.stringify(next)) } catch { /* Storage may be unavailable. */ }
|
||||
}
|
||||
|
||||
const presetLabel = (item: (typeof presets)[number]) => item.unit === 'minutes'
|
||||
? t('range.lastMinutes', { count: item.count })
|
||||
: t('range.lastHours', { count: item.count })
|
||||
const activePreset = selection.start === start && selection.end === end ? presets.find(item => item.ms === selection.ms) : undefined
|
||||
const label = activePreset?.label ?? '自定义时间'
|
||||
const label = activePreset ? presetLabel(activePreset) : t('range.custom')
|
||||
const rangeText = `${new Date(start).toLocaleString()} ~ ${new Date(end).toLocaleString()}`
|
||||
|
||||
return <div ref={root} className="relative min-w-0">
|
||||
@@ -55,14 +61,14 @@ export function TimeRangePicker({ start, end, onChange }: { start: string; end:
|
||||
<CalendarRange className="size-4 shrink-0" /><span className="shrink-0 font-medium">{label}</span><span className="hidden truncate font-normal text-muted-foreground xl:inline">{rangeText}</span><ChevronDown className="size-4 shrink-0" />
|
||||
</Button>
|
||||
{open && <div className="absolute left-0 top-full z-40 mt-1 w-[min(92vw,520px)] rounded-lg border bg-popover p-4 shadow-xl">
|
||||
<h2 className="text-sm font-medium">选择时间范围</h2>
|
||||
<div className="mt-3 grid grid-cols-2 gap-1 sm:grid-cols-3">{presets.map(item => <Button key={item.ms} type="button" size="sm" variant={activePreset?.ms === item.ms ? 'secondary' : 'ghost'} className="justify-start" onClick={() => { const now = Date.now(); choose(localTime(new Date(now - item.ms)), localTime(new Date(now)), item.ms) }}>{item.label}</Button>)}</div>
|
||||
<h2 className="text-sm font-medium">{t('range.title')}</h2>
|
||||
<div className="mt-3 grid grid-cols-2 gap-1 sm:grid-cols-3">{presets.map(item => <Button key={item.ms} type="button" size="sm" variant={activePreset?.ms === item.ms ? 'secondary' : 'ghost'} className="justify-start" onClick={() => { const now = Date.now(); choose(localTime(new Date(now - item.ms)), localTime(new Date(now)), item.ms) }}>{presetLabel(item)}</Button>)}</div>
|
||||
<div className="mt-3 grid gap-2 border-t pt-3 sm:grid-cols-2">
|
||||
<label className="text-xs text-muted-foreground">开始时间<Input className="mt-1 h-8" type="datetime-local" step={1} value={draftStart} onChange={event => setDraftStart(event.target.value)} /></label>
|
||||
<label className="text-xs text-muted-foreground">结束时间<Input className="mt-1 h-8" type="datetime-local" step={1} value={draftEnd} onChange={event => setDraftEnd(event.target.value)} /></label>
|
||||
<label className="text-xs text-muted-foreground">{t('range.start')}<Input className="mt-1 h-8" type="datetime-local" step={1} value={draftStart} onChange={event => setDraftStart(event.target.value)} /></label>
|
||||
<label className="text-xs text-muted-foreground">{t('range.end')}<Input className="mt-1 h-8" type="datetime-local" step={1} value={draftEnd} onChange={event => setDraftEnd(event.target.value)} /></label>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-end"><Button type="button" size="sm" disabled={!(new Date(draftStart).getTime() < new Date(draftEnd).getTime())} onClick={() => choose(draftStart, draftEnd)}>应用时间</Button></div>
|
||||
{history.length > 0 && <div className="mt-3 border-t pt-3"><h3 className="mb-1 text-xs font-medium text-muted-foreground">最近使用</h3><div className="max-h-32 space-y-0.5 overflow-y-auto">{history.map(item => <button type="button" key={`${item.start}-${item.end}`} className="block w-full truncate rounded px-2 py-1.5 text-left text-xs hover:bg-muted" onClick={() => choose(item.start, item.end)}>{new Date(item.start).toLocaleString()} ~ {new Date(item.end).toLocaleString()}</button>)}</div></div>}
|
||||
<div className="mt-2 flex justify-end"><Button type="button" size="sm" disabled={!(new Date(draftStart).getTime() < new Date(draftEnd).getTime())} onClick={() => choose(draftStart, draftEnd)}>{t('range.apply')}</Button></div>
|
||||
{history.length > 0 && <div className="mt-3 border-t pt-3"><h3 className="mb-1 text-xs font-medium text-muted-foreground">{t('range.recent')}</h3><div className="max-h-32 space-y-0.5 overflow-y-auto">{history.map(item => <button type="button" key={`${item.start}-${item.end}`} className="block w-full truncate rounded px-2 py-1.5 text-left text-xs hover:bg-muted" onClick={() => choose(item.start, item.end)}>{new Date(item.start).toLocaleString()} ~ {new Date(item.end).toLocaleString()}</button>)}</div></div>}
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { PageContainer } from '@/components/PageContainer'
|
||||
import { ErrorState } from '@/components/ErrorState'
|
||||
import { getUser } from '@/lib/auth-storage'
|
||||
import i18n from '@/i18n'
|
||||
import zhLogs from '@/i18n/locales/zh/logs'
|
||||
import enLogs from '@/i18n/locales/en/logs'
|
||||
import { logsApi, type LogEntry, type LogQuery, type LogSource } from '@/api/logs'
|
||||
import { clusterApi } from '@/api'
|
||||
import type { Cluster } from '@/types'
|
||||
@@ -19,12 +23,17 @@ import { LogResultsPanel } from './components/LogResultsPanel'
|
||||
import { literalFilters } from './query-utils'
|
||||
import { readQueryHistory, rememberQuery } from './query-history'
|
||||
|
||||
// 注册 logs 命名空间(域词典集中在页面入口挂载,域内组件统一 useTranslation('logs'))
|
||||
i18n.addResourceBundle('zh', 'logs', zhLogs)
|
||||
i18n.addResourceBundle('en', 'logs', enLogs)
|
||||
|
||||
const toLocal = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
|
||||
const ns = (ms: number) => Math.round(ms).toString() + '000000'
|
||||
|
||||
type Bucket = { timestamp: string; count: number }
|
||||
|
||||
export default function LogsPage() {
|
||||
const { t } = useTranslation('logs')
|
||||
const admin = getUser()?.role === 'admin'
|
||||
const [sources, setSources] = useState<LogSource[]>([])
|
||||
const [sourceId, setSourceId] = useState(0)
|
||||
@@ -55,20 +64,20 @@ export default function LogsPage() {
|
||||
const [clusters, setClusters] = useState<Cluster[]>([])
|
||||
useEffect(() => { if (admin) clusterApi.list({ page: 1, pageSize: 100 }).then(r => setClusters(r.list)).catch(() => setClusters([])) }, [admin])
|
||||
|
||||
async function reloadSources() {
|
||||
const reloadSources = useCallback(async () => {
|
||||
try {
|
||||
const result = await logsApi.sources()
|
||||
setSources(result.list)
|
||||
setSourceId(current => result.list.some(s => s.id === current) ? current : (result.list.find(s => s.isDefault)?.id ?? result.list[0]?.id ?? 0))
|
||||
} catch (e) { setError(e instanceof Error ? e.message : '加载数据源失败') }
|
||||
}
|
||||
useEffect(() => { void reloadSources() }, [])
|
||||
} catch (e) { setError(e instanceof Error ? e.message : t('source.loadFailed')) }
|
||||
}, [t])
|
||||
useEffect(() => { void reloadSources() }, [reloadSources])
|
||||
useEffect(() => { requestVersion.current++; setEntries([]); setBuckets([]); setHistogramLoading(false); setHistogramError(false); setCursor(''); setPageIndex(0); setPageCursors(['']); setSearched(false); setLoading(false) }, [sourceId])
|
||||
|
||||
|
||||
function range(): { start: number; end: number } | null {
|
||||
const a = new Date(start).getTime(), b = new Date(end).getTime()
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b) || a >= b) { setError('请选择有效的起止时间'); return null }
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b) || a >= b) { setError(t('toolbar.rangeInvalid')); return null }
|
||||
return { start: Number(ns(a)), end: Number(ns(b)) }
|
||||
}
|
||||
async function search(nextCursor = '', quiet = false, selectedRange?: { start: number; end: number }, overrideQuery?: string, targetPage?: number) {
|
||||
@@ -102,7 +111,7 @@ export default function LogsPage() {
|
||||
}).catch(() => { if (version === requestVersion.current) setHistogramError(true) })
|
||||
.finally(() => { if (version === requestVersion.current) setHistogramLoading(false) })
|
||||
}
|
||||
} catch (e) { if (!quiet && version === requestVersion.current) setError(e instanceof Error ? e.message : '查询失败') }
|
||||
} catch (e) { if (!quiet && version === requestVersion.current) setError(e instanceof Error ? e.message : t('toolbar.queryFailed')) }
|
||||
finally { if (version === requestVersion.current) { setLoading(false); setLoadingStartedAt(null) } }
|
||||
}
|
||||
useEffect(() => {
|
||||
@@ -130,12 +139,12 @@ export default function LogsPage() {
|
||||
try {
|
||||
const result = await logsApi.context({ sourceId, timestampNs: row.timestampNs, labels: row.labels })
|
||||
setContext({ row, before: result.before.slice().reverse(), after: result.after })
|
||||
} catch (e) { toast.error(e instanceof Error ? e.message : '读取上下文失败') }
|
||||
} catch (e) { toast.error(e instanceof Error ? e.message : t('context.loadFailed')) }
|
||||
}
|
||||
function saveSource() {
|
||||
setSaving(true)
|
||||
const action = sourceForm.id ? logsApi.updateSource(sourceForm.id, sourceForm) : logsApi.createSource(sourceForm)
|
||||
action.then(() => { setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] }); void reloadSources() }).catch(e => toast.error(e instanceof Error ? e.message : '保存失败')).finally(() => setSaving(false))
|
||||
action.then(() => { setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] }); void reloadSources() }).catch(e => toast.error(e instanceof Error ? e.message : t('source.saveFailed'))).finally(() => setSaving(false))
|
||||
}
|
||||
const highlightTerms = useMemo(() => literalFilters(executedQuery), [executedQuery])
|
||||
const applyDomainFilter = (domain: string) => {
|
||||
@@ -148,15 +157,15 @@ export default function LogsPage() {
|
||||
void search('', false, { start: Number(ns(from)), end: Number(ns(to)) })
|
||||
}
|
||||
|
||||
const pageTitle = tab === 'query' ? 'Loki 查询' : tab === 'access' ? '查询授权' : '数据源管理'
|
||||
const description = tab === 'query' ? '按日志流和时间范围检索;拖动时间线缩小范围,点日志查看前后文。' : tab === 'access' ? '用策略组复用日志范围,并一次关联多个用户。' : '配置 Loki 连接与认证方式。'
|
||||
const pageTitle = tab === 'query' ? t('tabs.query') : tab === 'access' ? t('tabs.access') : t('tabs.sources')
|
||||
const description = tab === 'query' ? t('tabs.descQuery') : tab === 'access' ? t('tabs.descAccess') : t('tabs.descSources')
|
||||
|
||||
return (
|
||||
<div className="-m-4 md:-m-6">
|
||||
<nav aria-label="日志中心导航" className="flex flex-wrap gap-1 border-b bg-card px-4 py-3 md:px-6">
|
||||
<Button variant={tab === 'query' ? 'default' : 'ghost'} onClick={() => setTab('query')}>Loki 查询</Button>
|
||||
{admin && <Button variant={tab === 'access' ? 'default' : 'ghost'} onClick={() => setTab('access')}>查询授权</Button>}
|
||||
{admin && <Button variant={tab === 'sources' ? 'default' : 'ghost'} onClick={() => setTab('sources')}>数据源管理</Button>}
|
||||
<nav aria-label={t('nav.aria')} className="flex flex-wrap gap-1 border-b bg-card px-4 py-3 md:px-6">
|
||||
<Button variant={tab === 'query' ? 'default' : 'ghost'} onClick={() => setTab('query')}>{t('tabs.query')}</Button>
|
||||
{admin && <Button variant={tab === 'access' ? 'default' : 'ghost'} onClick={() => setTab('access')}>{t('tabs.access')}</Button>}
|
||||
{admin && <Button variant={tab === 'sources' ? 'default' : 'ghost'} onClick={() => setTab('sources')}>{t('tabs.sources')}</Button>}
|
||||
</nav>
|
||||
<div className="p-4 md:p-6">
|
||||
<PageContainer title={tab === 'query' ? undefined : pageTitle} description={description}>
|
||||
@@ -165,14 +174,11 @@ export default function LogsPage() {
|
||||
{tab === 'query' && <section className="space-y-4">
|
||||
{sources.length === 0 && (
|
||||
<div className="rounded-xl border border-warning/40 bg-warning/10 p-4 text-sm">
|
||||
<p className="font-medium text-warning">还没有可用的 Loki 数据源</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
请先到「数据源管理」关联 Loki:集群内可直接选「集群服务」(命名空间 / Service / 端口,无需 Ingress);
|
||||
跨集群或公网查询,请先给 Loki 配置 Ingress 域名,再用「URL 直连」填写该域名。未关联数据源无法查询。
|
||||
</p>
|
||||
<p className="font-medium text-warning">{t('source.missingTitle')}</p>
|
||||
<p className="mt-1 text-muted-foreground">{t('source.missingBody')}</p>
|
||||
{admin && (
|
||||
<Button size="sm" className="mt-2" variant="outline" onClick={() => setTab('sources')}>
|
||||
去配置数据源
|
||||
{t('source.goConfigure')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -180,24 +186,21 @@ export default function LogsPage() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="flex h-10 items-center gap-2 rounded-md border bg-muted/40 px-3 text-sm font-semibold text-info"><span className="size-2 rounded-full bg-info" />LogQL</span>
|
||||
<div className="min-w-[260px] flex-1"><QueryInput key={sourceId} value={query} onChange={setQuery} onSubmit={() => void search()} sourceId={sourceId} history={queryHistory} /></div>
|
||||
<Button className="h-10 bg-success text-success-foreground hover:bg-success/90" disabled={!sourceId || !query.trim() || loading} onClick={() => void search()}>{loading ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Play className="mr-1 size-4" />}{loading ? '查询中…' : '查询'}</Button>
|
||||
<Button className="h-10" variant={tail ? 'destructive' : 'outline'} onClick={() => setTail(!tail)} disabled={!sourceId || (!tail && !query.trim())}><Radio className="mr-1 size-4" />{tail ? '停止 Tail' : 'Tail'}</Button>
|
||||
<Button className="h-10 bg-success text-success-foreground hover:bg-success/90" disabled={!sourceId || !query.trim() || loading} onClick={() => void search()}>{loading ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Play className="mr-1 size-4" />}{loading ? t('toolbar.running') : t('toolbar.run')}</Button>
|
||||
<Button className="h-10" variant={tail ? 'destructive' : 'outline'} onClick={() => setTail(!tail)} disabled={!sourceId || (!tail && !query.trim())}><Radio className="mr-1 size-4" />{tail ? t('toolbar.tailStop') : t('toolbar.tail')}</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<TimeRangePicker start={start} end={end} onChange={(nextStart, nextEnd) => { setStart(nextStart); setEnd(nextEnd); setTail(false) }} />
|
||||
<select aria-label="数据源" className="h-9 min-w-40 rounded-md border bg-background px-3 text-sm text-foreground" value={sourceId} onChange={event => setSourceId(Number(event.target.value))}>{sources.length === 0 && <option value={0}>暂无可用数据源</option>}{sources.map(source => <option key={source.id} value={source.id}>{source.name}</option>)}</select>
|
||||
<label className="flex items-center gap-1 text-xs text-muted-foreground">每页条数<Input type="number" min={1} max={1000} value={limit} onChange={event => setLimit(Math.min(1000, Math.max(1, Number(event.target.value))))} className="h-9 w-24" /></label>
|
||||
<span className="text-xs text-muted-foreground">每页最多 1000 条;翻页只保留当前页。大范围先看时间线,再拖选高峰时段。</span>
|
||||
<select aria-label={t('toolbar.sourceAria')} className="h-9 min-w-40 rounded-md border bg-background px-3 text-sm text-foreground" value={sourceId} onChange={event => setSourceId(Number(event.target.value))}>{sources.length === 0 && <option value={0}>{t('toolbar.sourceEmptyOption')}</option>}{sources.map(source => <option key={source.id} value={source.id}>{source.name}</option>)}</select>
|
||||
<label className="flex items-center gap-1 text-xs text-muted-foreground">{t('toolbar.limitLabel')}<Input type="number" min={1} max={1000} value={limit} onChange={event => setLimit(Math.min(1000, Math.max(1, Number(event.target.value))))} className="h-9 w-24" /></label>
|
||||
<span className="text-xs text-muted-foreground">{t('toolbar.limitHint')}</span>
|
||||
</div>
|
||||
<LokiLabelBrowser key={sourceId} sourceId={sourceId} query={query} onQueryChange={setQuery} start={start} end={end} />
|
||||
</section>}
|
||||
{admin && tab === 'access' && <PolicyGroupsPage sources={sources} />}
|
||||
{admin && tab === 'sources' && <section className="rounded-xl border bg-card p-4 space-y-4" aria-label="数据源管理">
|
||||
<h2 className="font-semibold">Loki 数据源</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
提示:Loki 部署在集群内时,可直接添加「集群服务」数据源(无需 Ingress);跨集群 / 公网查询需先为 Loki 配置
|
||||
Ingress 域名,再用「URL 直连」关联。未关联数据源时,查询页无法使用。
|
||||
</p>
|
||||
{admin && tab === 'sources' && <section className="rounded-xl border bg-card p-4 space-y-4" aria-label={t('source.sectionAria')}>
|
||||
<h2 className="font-semibold">{t('source.heading')}</h2>
|
||||
<p className="text-xs text-muted-foreground">{t('source.hint')}</p>
|
||||
|
||||
{/* 列表:平台部署自动注册的固定展示(🔒 不可编辑);手动添加的可编辑 */}
|
||||
<div className="space-y-2">
|
||||
@@ -209,9 +212,9 @@ export default function LogsPage() {
|
||||
<Lock className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="font-medium">{source.name}</span>
|
||||
<span className="text-muted-foreground truncate">
|
||||
{cluster ? `集群 ${cluster.name}` : ''}{source.namespace ? ` · ${source.namespace}` : ''} · {source.service || 'loki'}:{source.port || 3100}
|
||||
{cluster ? t('source.managedCluster', { name: cluster.name }) : ''}{source.namespace ? ` · ${source.namespace}` : ''} · {source.service || 'loki'}:{source.port || 3100}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground flex-shrink-0">平台部署 · 随部署管理</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground flex-shrink-0">{t('source.managedBadge')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<Button key={source.id} size="sm" variant="outline" onClick={() => setSourceForm({ ...source, headers: Object.fromEntries((source.headerNames ?? []).map(name => [name, ''])) })}>
|
||||
@@ -219,38 +222,38 @@ export default function LogsPage() {
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
<Button size="sm" variant="outline" onClick={() => setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] })}>+ 手动添加</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] })}>{t('source.add')}</Button>
|
||||
</div>
|
||||
|
||||
{/* 表单:URL 直连 或 集群服务(集群名下拉,服务默认 loki) */}
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<label>名称<Input value={sourceForm.name ?? ''} onChange={e => setSourceForm({ ...sourceForm, name: e.target.value })} /></label>
|
||||
<label>{t('source.nameLabel')}<Input value={sourceForm.name ?? ''} onChange={e => setSourceForm({ ...sourceForm, name: e.target.value })} /></label>
|
||||
<div className="flex gap-2 items-end">
|
||||
<button type="button" className={`px-3 py-1.5 rounded-md text-sm border ${sourceForm.targetType === 'url' ? 'border-primary bg-primary/10 text-primary' : 'border-border'}`} onClick={() => setSourceForm({ ...sourceForm, targetType: 'url' })}>URL 直连</button>
|
||||
<button type="button" className={`px-3 py-1.5 rounded-md text-sm border ${sourceForm.targetType === 'cluster-svc' ? 'border-primary bg-primary/10 text-primary' : 'border-border'}`} onClick={() => setSourceForm({ ...sourceForm, targetType: 'cluster-svc', service: sourceForm.service || 'loki', port: sourceForm.port || 3100 })}>集群服务</button>
|
||||
<button type="button" className={`px-3 py-1.5 rounded-md text-sm border ${sourceForm.targetType === 'url' ? 'border-primary bg-primary/10 text-primary' : 'border-border'}`} onClick={() => setSourceForm({ ...sourceForm, targetType: 'url' })}>{t('source.targetUrl')}</button>
|
||||
<button type="button" className={`px-3 py-1.5 rounded-md text-sm border ${sourceForm.targetType === 'cluster-svc' ? 'border-primary bg-primary/10 text-primary' : 'border-border'}`} onClick={() => setSourceForm({ ...sourceForm, targetType: 'cluster-svc', service: sourceForm.service || 'loki', port: sourceForm.port || 3100 })}>{t('source.targetClusterSvc')}</button>
|
||||
</div>
|
||||
{sourceForm.targetType === 'url'
|
||||
? <label className="sm:col-span-2">Loki 地址<Input value={sourceForm.url ?? ''} onChange={e => setSourceForm({ ...sourceForm, url: e.target.value })} placeholder="http://10.0.0.8:3100" /></label>
|
||||
? <label className="sm:col-span-2">{t('source.addressLabel')}<Input value={sourceForm.url ?? ''} onChange={e => setSourceForm({ ...sourceForm, url: e.target.value })} placeholder="http://10.0.0.8:3100" /></label>
|
||||
: <>
|
||||
<label>集群<select className="block h-9 w-full rounded-md border bg-background px-3" value={sourceForm.clusterID ?? ''} onChange={e => setSourceForm({ ...sourceForm, clusterID: Number(e.target.value) })}>
|
||||
<option value="">选择集群…</option>
|
||||
<label>{t('source.clusterLabel')}<select className="block h-9 w-full rounded-md border bg-background px-3" value={sourceForm.clusterID ?? ''} onChange={e => setSourceForm({ ...sourceForm, clusterID: Number(e.target.value) })}>
|
||||
<option value="">{t('source.clusterPlaceholder')}</option>
|
||||
{clusters.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select></label>
|
||||
<label>命名空间<Input value={sourceForm.namespace ?? ''} onChange={e => setSourceForm({ ...sourceForm, namespace: e.target.value })} placeholder="log" /></label>
|
||||
<label>Service<Input value={sourceForm.service ?? ''} onChange={e => setSourceForm({ ...sourceForm, service: e.target.value })} placeholder="loki" /></label>
|
||||
<label>端口<Input type="number" value={sourceForm.port ?? 3100} onChange={e => setSourceForm({ ...sourceForm, port: Number(e.target.value) })} /></label>
|
||||
<label>{t('source.namespaceLabel')}<Input value={sourceForm.namespace ?? ''} onChange={e => setSourceForm({ ...sourceForm, namespace: e.target.value })} placeholder="log" /></label>
|
||||
<label>{t('source.serviceLabel')}<Input value={sourceForm.service ?? ''} onChange={e => setSourceForm({ ...sourceForm, service: e.target.value })} placeholder="loki" /></label>
|
||||
<label>{t('source.portLabel')}<Input type="number" value={sourceForm.port ?? 3100} onChange={e => setSourceForm({ ...sourceForm, port: Number(e.target.value) })} /></label>
|
||||
</>}
|
||||
<label>认证方式<select className="block h-9 w-full rounded-md border bg-background px-3" value={sourceForm.authType ?? 'none'} onChange={e => setSourceForm({ ...sourceForm, authType: e.target.value })}><option value="none">无认证</option><option value="basic">Basic Auth</option><option value="bearer">Bearer Token</option><option value="headers">自定义 Headers</option></select></label>
|
||||
{sourceForm.authType === 'basic' && <><label>用户名<Input value={sourceForm.basicUser ?? ''} onChange={e => setSourceForm({ ...sourceForm, basicUser: e.target.value })} /></label><label>密码<Input type="password" value={sourceForm.basicPass ?? ''} onChange={e => setSourceForm({ ...sourceForm, basicPass: e.target.value })} /></label></>}
|
||||
{sourceForm.authType === 'bearer' && <label>令牌<Input type="password" value={sourceForm.bearerToken ?? ''} onChange={e => setSourceForm({ ...sourceForm, bearerToken: e.target.value })} /></label>}
|
||||
{sourceForm.authType === 'headers' && <label className="sm:col-span-2">自定义 Headers(JSON 对象)<textarea key={sourceForm.id ?? 'new'} className="block w-full rounded-md border bg-background p-2 font-mono text-sm" rows={3} defaultValue={JSON.stringify(sourceForm.headers ?? {}, null, 2)} onBlur={e => { try { const headers = JSON.parse(e.target.value) as Record<string, string>; if (!headers || Array.isArray(headers) || typeof headers !== 'object' || Object.values(headers).some(value => typeof value !== 'string')) throw Error(); setSourceForm(current => ({ ...current, headers })) } catch { toast.error('Headers 必须是字符串键值 JSON 对象') } }} placeholder='{"X-Scope-OrgID":"tenant"}' /></label>}
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={!!sourceForm.skipTLSVerify} onChange={e => setSourceForm({ ...sourceForm, skipTLSVerify: e.target.checked })} />跳过 TLS 校验</label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={!!sourceForm.isDefault} onChange={e => setSourceForm({ ...sourceForm, isDefault: e.target.checked })} />设为默认</label>
|
||||
<label>{t('source.authLabel')}<select className="block h-9 w-full rounded-md border bg-background px-3" value={sourceForm.authType ?? 'none'} onChange={e => setSourceForm({ ...sourceForm, authType: e.target.value })}><option value="none">{t('source.authNone')}</option><option value="basic">{t('source.authBasic')}</option><option value="bearer">{t('source.authBearer')}</option><option value="headers">{t('source.authHeaders')}</option></select></label>
|
||||
{sourceForm.authType === 'basic' && <><label>{t('source.usernameLabel')}<Input value={sourceForm.basicUser ?? ''} onChange={e => setSourceForm({ ...sourceForm, basicUser: e.target.value })} /></label><label>{t('source.passwordLabel')}<Input type="password" value={sourceForm.basicPass ?? ''} onChange={e => setSourceForm({ ...sourceForm, basicPass: e.target.value })} /></label></>}
|
||||
{sourceForm.authType === 'bearer' && <label>{t('source.tokenLabel')}<Input type="password" value={sourceForm.bearerToken ?? ''} onChange={e => setSourceForm({ ...sourceForm, bearerToken: e.target.value })} /></label>}
|
||||
{sourceForm.authType === 'headers' && <label className="sm:col-span-2">{t('source.headersLabel')}<textarea key={sourceForm.id ?? 'new'} className="block w-full rounded-md border bg-background p-2 font-mono text-sm" rows={3} defaultValue={JSON.stringify(sourceForm.headers ?? {}, null, 2)} onBlur={e => { try { const headers = JSON.parse(e.target.value) as Record<string, string>; if (!headers || Array.isArray(headers) || typeof headers !== 'object' || Object.values(headers).some(value => typeof value !== 'string')) throw Error(); setSourceForm(current => ({ ...current, headers })) } catch { toast.error(t('source.headersInvalid')) } }} placeholder='{"X-Scope-OrgID":"tenant"}' /></label>}
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={!!sourceForm.skipTLSVerify} onChange={e => setSourceForm({ ...sourceForm, skipTLSVerify: e.target.checked })} />{t('source.skipTLS')}</label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={!!sourceForm.isDefault} onChange={e => setSourceForm({ ...sourceForm, isDefault: e.target.checked })} />{t('source.setDefault')}</label>
|
||||
</div>
|
||||
<div className="flex gap-2"><Button variant="outline" onClick={() => logsApi.testSource(sourceForm).then(result => result.success ? toast.info(result.message) : toast.error(result.message)).catch(e => toast.error(e.message))}>测试连接</Button><Button disabled={saving || !sourceForm.name} onClick={saveSource}>保存数据源</Button>{sourceForm.id && <Button variant="outline" onClick={() => logsApi.deleteSource(sourceForm.id!).then(() => { void reloadSources(); setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] }) }).catch(e => toast.error(e.message))}>删除</Button>}</div>
|
||||
<div className="flex gap-2"><Button variant="outline" onClick={() => logsApi.testSource(sourceForm).then(result => result.success ? toast.info(result.message) : toast.error(result.message)).catch(e => toast.error(e.message))}>{t('source.test')}</Button><Button disabled={saving || !sourceForm.name} onClick={saveSource}>{t('source.save')}</Button>{sourceForm.id && <Button variant="outline" onClick={() => logsApi.deleteSource(sourceForm.id!).then(() => { void reloadSources(); setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] }) }).catch(e => toast.error(e.message))}>{t('source.remove')}</Button>}</div>
|
||||
</section>}
|
||||
{tab === 'query' && histogramLoading && <div role="status" className="flex items-center gap-2 rounded-xl border bg-card p-4 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin text-info" />正在统计整个时间范围的日志量,日志页可继续浏览。</div>}
|
||||
{tab === 'query' && histogramError && <p className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">日志量统计超时或失败;日志页仍可使用。可缩小时间范围后重新查询。</p>}
|
||||
{tab === 'query' && histogramLoading && <div role="status" className="flex items-center gap-2 rounded-xl border bg-card p-4 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin text-info" />{t('volume.statsLoading')}</div>}
|
||||
{tab === 'query' && histogramError && <p className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">{t('volume.statsError')}</p>}
|
||||
{tab === 'query' && buckets.length > 0 && <LogVolumeTimeline key={`${buckets[0].timestamp}-${buckets.length}`} buckets={buckets} onSelect={selectTimeRange} />}
|
||||
{tab === 'query' && <LogResultsPanel key={`${sourceId}:${executedQuery}:${executedRange?.start}:${executedRange?.end}`} entries={entries} buckets={buckets} start={executedRange ? toLocal(new Date(executedRange.start / 1e6)) : start} end={executedRange ? toLocal(new Date(executedRange.end / 1e6)) : end} searched={searched} hasSource={sources.length > 0} pageIndex={pageIndex} hasNextPage={Boolean(cursor)} loading={loading} loadingStartedAt={loadingStartedAt} query={query} terms={highlightTerms} onQueryChange={setQuery} onApplyDomain={applyDomainFilter} onShowContext={row => void showContext(row)} onSearch={() => void search()} onNextPage={() => void search(cursor, false, undefined, undefined, pageIndex + 1)} onPreviousPage={() => void search(pageCursors[pageIndex - 1] ?? '', false, undefined, undefined, pageIndex - 1)} />}
|
||||
<LogContextDialog context={context} terms={highlightTerms} onClose={() => setContext(null)} />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import i18n from '@/i18n'
|
||||
import type { IngressAccessLog } from './ingress-access-log'
|
||||
import { pathWithoutQuery } from './ingress-access-log'
|
||||
|
||||
@@ -36,7 +37,7 @@ export function summarizeIngress(samples: IngressSample[], slowThreshold: number
|
||||
const allIps = new Set<string>()
|
||||
let errors = 0, slow = 0, timed = 0
|
||||
for (const { access } of samples) {
|
||||
const domain = access.domain || '未记录 Host'
|
||||
const domain = access.domain || i18n.t('ingress.noDomain', { ns: 'logs' })
|
||||
const path = pathWithoutQuery(access.path)
|
||||
const key = `${domain}\u0000${path}`
|
||||
let route = routes.get(key)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getUser } from '@/lib/auth-storage'
|
||||
|
||||
const key = () => `opsxloki.logs.query-history.${getUser()?.id ?? 'anonymous'}`
|
||||
const key = () => `lokistack.logs.query-history.${getUser()?.id ?? 'anonymous'}`
|
||||
|
||||
export function readQueryHistory(): string[] {
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user