feat: 工具配置增强、站点拨测与日志中心多项改进

- 工具配置:新增卸载 API/UI、Go 工具链、流水线构建自动安装依赖
- 修复 ListTools VerifyTool 递归栈溢出导致获取工具配置失败
- Node.js Alpine 安装改用 unofficial-builds musl 预编译包与 index.json 解析 LTS
- 站点拨测:详情页路由、告警自动恢复、并发拨测与默认可观测频率
- 日志中心:RBAC、查询构建器、ES/Loki 增强与审计 request/response 展示
- 发布配置:构建节点、流水线远程执行、Deploy RBAC 导航拆分
This commit is contained in:
Logan committed 2026-07-02 07:47:33 +08:00
1 parent cde7bbe540
commit 7e1c8f583c
84 files changed
+7729 -1507

No files matched your search

@@ -1,6 +1,9 @@
// EnhancedLogResults.tsx — Grafana 风格日志结果展示 // Logan.Li
import { useState, useMemo } from 'react';
import { ChevronDown, ChevronRight, BarChart3, Plus, Search, Copy, Check } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { copyToClipboard } from '@/utils/clipboard';
import { buildLogCopyText, detectLogFields, formatLogPreview, formatLogTimestamp } from './log-format';
import type { LogEntry, ViewMode } from './types-v3';
type ViewDensity = 'compact' | 'comfortable' | 'loose';
@@ -81,25 +84,11 @@ export function extractCommonLabels(entries: LogEntry[]): Record<string, string>
return Object.keys(common).length > 0 ? common : null;
}
// ===== 检测结构化字段(从消息中解析)=====
function detectFields(raw: string): { name: string; value: string }[] {
const fields: { name: string; value: string }[] = [];
try {
const parsed = JSON.parse(raw);
if (typeof parsed === 'object' && parsed !== null) {
for (const [k, v] of Object.entries(parsed)) {
fields.push({ name: k, value: String(v) });
}
}
} catch {
// 非 JSON,尝试 key=value 模式
const regex = /(\w+)=([^,\s]+)/g;
let m;
while ((m = regex.exec(raw)) !== null) {
fields.push({ name: m[1], value: m[2] });
}
}
return fields;
// 日志行 CSS:截断关闭时用横向滚动而非省略号,避免看不到后半段 // Logan.Li
function messageLineClass(wrap: boolean, trunc: boolean): string {
if (wrap) return 'whitespace-pre-wrap break-all';
if (trunc) return 'truncate';
return 'whitespace-nowrap overflow-x-auto';
}
// ===== 原始日志视图(Grafana 风格)=====
@@ -109,15 +98,25 @@ function RawLogView({
entry: LogEntry; wrap: boolean; trunc: boolean;
showTime: boolean; onLabelFilter?: (k: string, v: string, op: 'include' | 'exclude') => void;
}) {
const { toast } = useToast();
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const labels = entry.labels || {};
const detected = useMemo(() => detectFields(entry.raw || entry.message), [entry]);
const fullText = entry.raw || entry.message || '';
const detected = useMemo(() => detectLogFields(fullText), [fullText]);
const displayTs = formatLogTimestamp(entry.timestamp);
const preview = expanded ? fullText : formatLogPreview(fullText, trunc);
const copyRaw = () => {
navigator.clipboard.writeText(entry.raw || entry.message).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 1500);
});
const copyRaw = async () => {
const text = buildLogCopyText(entry);
const ok = await copyToClipboard(text);
if (ok) {
setCopied(true);
toast({ title: '已复制完整日志' });
setTimeout(() => setCopied(false), 1500);
} else {
toast({ title: '复制失败', description: '请展开后手动选中复制', variant: 'destructive' });
}
};
return (
@@ -135,8 +134,8 @@ function RawLogView({
{/* 时间戳 */}
{showTime && (
<span className="text-[11px] font-mono flex-shrink-0 w-[140px] mt-0.5" style={{ color: 'var(--dg-text-muted)' }}>
{entry.timestamp}
<span className="text-[11px] font-mono flex-shrink-0 w-[180px] mt-0.5" style={{ color: 'var(--dg-text-muted)' }} title={entry.timestamp}>
{displayTs}
</span>
)}
@@ -147,24 +146,50 @@ function RawLogView({
</span>
)}
{/* 日志内容 */}
<span className={`text-xs flex-1 min-w-0 ${wrap ? 'whitespace-pre-wrap break-all' : 'truncate'}`} style={{ color: 'var(--dg-text-secondary)' }}>
{trunc && entry.message.length > 300 && !expanded ? entry.message.slice(0, 300) + '...' : entry.message}
{/* 日志内容(折叠预览) */}
<span
className={`text-xs font-mono flex-1 min-w-0 ${messageLineClass(wrap, trunc && !expanded)}`}
style={{ color: 'var(--dg-text-secondary)' }}
title={!expanded && fullText.length > 120 ? fullText : undefined}
>
{preview}
</span>
{/* 复制按钮(hover 显示)*/}
{/* 复制按钮(hover 显示,复制完整原文) */}
<button
onClick={e => { e.stopPropagation(); copyRaw(); }}
onClick={e => { e.stopPropagation(); void copyRaw(); }}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity mt-0.5"
style={{ color: 'var(--dg-text-muted)' }}
title="复制完整日志"
>
{copied ? <Check className="h-3 w-3 dg-text-green" /> : <Copy className="h-3 w-3" />}
</button>
</div>
{/* 展开详情:Log labels + Detected fields */}
{/* 展开详情:完整日志 + labels + fields */}
{expanded && (
<div className="animate-fade-in" style={{ background: 'var(--dg-bg-secondary)', padding: '8px 12px 12px 36px' }}>
{/* 完整日志正文 */}
<div className="mb-3">
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] font-medium" style={{ color: 'var(--dg-text-muted)' }}>完整日志</p>
<button
type="button"
onClick={e => { e.stopPropagation(); void copyRaw(); }}
className="text-[10px] px-2 py-0.5 rounded border"
style={{ borderColor: 'var(--dg-border)', color: 'var(--dg-text-secondary)' }}
>
{copied ? '已复制' : '复制全文'}
</button>
</div>
<pre
className="text-[11px] font-mono whitespace-pre-wrap break-all p-2 rounded max-h-[320px] overflow-y-auto"
style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-primary)', border: '1px solid var(--dg-border)' }}
>
{fullText}
</pre>
</div>
{/* Log labels */}
{Object.keys(labels).length > 0 && (
<div className="mb-3">
@@ -181,8 +206,8 @@ function RawLogView({
>
<Plus className="h-3 w-3" />
</button>
<span className="text-[11px] font-mono w-[160px] truncate flex-shrink-0" style={{ color: 'var(--dg-text-primary)' }}>{k}</span>
<span className="text-[11px] font-mono flex-1 truncate" style={{ color: 'var(--dg-text-secondary)' }}>{v}</span>
<span className="text-[11px] font-mono w-[160px] flex-shrink-0" style={{ color: 'var(--dg-text-primary)' }}>{k}</span>
<span className="text-[11px] font-mono flex-1 break-all" style={{ color: 'var(--dg-text-secondary)' }}>{v}</span>
</div>
))}
</div>
@@ -249,12 +274,12 @@ function FieldView({ entries, wrap, trunc, search }: { entries: LogEntry[]; wrap
<tr key={i} className="hover:opacity-80 transition-colors" style={{ borderBottom: '1px solid var(--dg-border)' }}>
{columns.map(col => {
let val: string;
if (col === 'timestamp') val = e.timestamp;
else if (col === 'message') val = e.message;
if (col === 'timestamp') val = formatLogTimestamp(e.timestamp);
else if (col === 'message') val = e.raw || e.message;
else val = e.labels?.[col] || '—';
return (
<td key={col} className={`px-2 py-1.5 max-w-[300px] ${!wrap ? 'truncate' : 'break-all'}`} style={{ color: 'var(--dg-text-secondary)' }}>
{trunc && val.length > 100 ? val.slice(0, 100) + '...' : val}
<td key={col} className={`px-2 py-1.5 max-w-[400px] ${wrap ? 'break-all whitespace-pre-wrap' : trunc ? 'truncate' : 'break-all'}`} style={{ color: 'var(--dg-text-secondary)' }} title={val}>
{trunc ? formatLogPreview(val, true, 200) : val}
</td>
);
})}
@@ -281,12 +306,12 @@ function TableView({ entries, wrap, trunc }: { entries: LogEntry[]; wrap: boolea
<tbody>
{entries.map((e, i) => (
<tr key={i} className="hover:opacity-80 transition-colors" style={{ borderBottom: '1px solid var(--dg-border)' }}>
<td className="px-3 py-2 font-mono text-[11px] whitespace-nowrap" style={{ color: 'var(--dg-text-muted)' }}>{e.timestamp}</td>
<td className="px-3 py-2 font-mono text-[11px] whitespace-nowrap" style={{ color: 'var(--dg-text-muted)' }}>{formatLogTimestamp(e.timestamp)}</td>
<td className="px-3 py-2">
{e.level ? <span className={`text-[10px] px-1.5 py-0.5 rounded border ${levelBadge(e.level)}`}>{e.level}</span> : '—'}
</td>
<td className={`px-3 py-2 ${wrap ? 'whitespace-pre-wrap break-all' : 'truncate'} max-w-[600px]`} style={{ color: 'var(--dg-text-secondary)' }}>
{trunc && e.message.length > 200 ? e.message.slice(0, 200) + '...' : e.message}
<td className={`px-3 py-2 ${messageLineClass(wrap, trunc)} max-w-[800px]`} style={{ color: 'var(--dg-text-secondary)' }} title={e.raw || e.message}>
{trunc ? formatLogPreview(e.raw || e.message, true, 300) : (e.raw || e.message)}
</td>
</tr>
))}