- 品牌: 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 未翻译、词典双逗号等
49 lines
4.1 KiB
TypeScript
49 lines
4.1 KiB
TypeScript
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'
|
|
|
|
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" />{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>}
|
|
</div>
|
|
<HighlightedLogLine message={match?.[2] ?? row.message} terms={terms} className="m-0 whitespace-pre-wrap break-all font-mono text-xs leading-5 text-foreground" />
|
|
</article>
|
|
}
|
|
|
|
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>{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">{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={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={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>
|
|
</Dialog>
|
|
}
|