fix(frontend): logs/analysis P0 — 职责拆分与布局收口

砍 analysis 明细可视化改「去日志查询」;无数据源 EmptyState;日图标固定近7天。
logs 去掉负 margin、页头常显、筛选/查询分层;level 色阶;Label 保持默认收起。
This commit is contained in:
Codex committed 2026-10-28 21:54:40 +08:00
1 parent 4f75ef8578
commit f993d5b420
5 files changed
+431 -110

No files matched your search

@@ -4,29 +4,30 @@ import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { LogEntry } from '@/api/logs'
import { parseIngressAccessLog } from '../ingress-access-log'
import { parseIngressAccessLog } from '@/lib/ingress-access-log'
import { applyLabelFilter } from '../query-utils'
import { HighlightedLogLine } from './HighlightedLogLine'
import { LogResultViews, type ResultView } from './LogResultViews'
import { QueryEmptyState } from './QueryEmptyState'
type Bucket = { timestamp: string; count: number }
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',
function levelBadgeClass(level: string) {
const value = (level || '').toLowerCase()
if (value.includes('error') || value === 'err' || value === 'fatal' || value === 'critical') {
return 'rounded bg-destructive/15 px-1 text-destructive'
}
if (value.includes('warn')) return 'rounded bg-warning/15 px-1 text-warning'
if (value.includes('info')) return 'rounded bg-info/15 px-1 text-info'
if (value.includes('debug') || value.includes('trace')) return 'rounded bg-muted px-1 text-muted-foreground'
return 'rounded bg-muted px-1 text-muted-foreground'
}
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
// 只保留原始日志视图;表格/折线/流图/饼图等可视化已迁至「日志分析」页(LogDetailSection)
export function LogResultsPanel({ entries, searched, hasSource, pageIndex, hasNextPage, loading, loadingStartedAt, query, terms, onQueryChange, onApplyDomain, onShowContext, onSearch, onNextPage, onPreviousPage }: {
entries: LogEntry[]; 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)
useEffect(() => {
@@ -34,10 +35,6 @@ 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()
@@ -46,9 +43,6 @@ export function LogResultsPanel({ entries, buckets, start, end, searched, hasSou
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">{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" />
@@ -62,17 +56,17 @@ export function LogResultsPanel({ entries, buckets, start, end, searched, hasSou
<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">{t('results.domainNoMatch')}</p> : <div className="divide-y">
{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>
<span className={levelBadgeClass(row.level)}>{row.level}</span>
{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())} />}
</div>}
{searched && entries.length > 0 && <div className="mt-3 flex items-center gap-2">
<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>