- 工具配置:新增卸载 API/UI、Go 工具链、流水线构建自动安装依赖 - 修复 ListTools VerifyTool 递归栈溢出导致获取工具配置失败 - Node.js Alpine 安装改用 unofficial-builds musl 预编译包与 index.json 解析 LTS - 站点拨测:详情页路由、告警自动恢复、并发拨测与默认可观测频率 - 日志中心:RBAC、查询构建器、ES/Loki 增强与审计 request/response 展示 - 发布配置:构建节点、流水线远程执行、Deploy RBAC 导航拆分
454 lines
20 KiB
TypeScript
454 lines
20 KiB
TypeScript
// 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';
|
|
|
|
interface Props {
|
|
entries: LogEntry[];
|
|
loading?: boolean;
|
|
viewMode: ViewMode;
|
|
wrapLines: boolean;
|
|
truncate: boolean;
|
|
density?: ViewDensity;
|
|
fieldSearch?: string;
|
|
// Grafana 风格新增 // Logan.Li
|
|
showTime?: boolean;
|
|
onShowTimeChange?: (v: boolean) => void;
|
|
uniqueLabels?: boolean;
|
|
onUniqueLabelsChange?: (v: boolean) => void;
|
|
prettifyJSON?: boolean;
|
|
onPrettifyJSONChange?: (v: boolean) => void;
|
|
dedupMode?: DedupMode;
|
|
onDedupModeChange?: (m: DedupMode) => void;
|
|
sortOrder?: 'newest' | 'oldest';
|
|
onSortOrderChange?: (o: 'newest' | 'oldest') => void;
|
|
// Common labels
|
|
commonLabels?: Record<string, string>;
|
|
// 标签操作回调
|
|
onLabelFilter?: (key: string, value: string, op: 'include' | 'exclude') => void;
|
|
}
|
|
|
|
type DedupMode = 'none' | 'exact' | 'numbers' | 'signature';
|
|
|
|
function levelBadge(level?: string): string {
|
|
const l = (level || '').toLowerCase();
|
|
if (l === 'error' || l === 'fatal' || l === 'err') return 'bg-red-500/15 dg-text-red border-red-500/30';
|
|
if (l === 'warn' || l === 'warning') return 'bg-amber-500/15 dg-text-yellow border-amber-500/30';
|
|
if (l === 'info') return 'bg-emerald-500/15 dg-text-green border-emerald-500/30';
|
|
if (l === 'debug') return 'bg-[var(--dg-bg-active)]/15 dg-text-secondary border-[var(--dg-border)]';
|
|
return 'bg-[var(--dg-bg-active)]/15 dg-text-secondary border-[var(--dg-border)]';
|
|
}
|
|
|
|
function levelColorText(level?: string): string {
|
|
const l = (level || '').toLowerCase();
|
|
if (l === 'error' || l === 'fatal' || l === 'err') return '#f87171';
|
|
if (l === 'warn' || l === 'warning') return '#fbbf24';
|
|
if (l === 'info') return '#34d399';
|
|
if (l === 'debug') return '#94a3b8';
|
|
return '#94a3b8';
|
|
}
|
|
|
|
const DENSITY_PADDING: Record<ViewDensity, string> = {
|
|
compact: 'py-0.5',
|
|
comfortable: 'py-1.5',
|
|
loose: 'py-2.5',
|
|
};
|
|
|
|
// ===== Dedup 处理 ===== //
|
|
function dedupEntries(entries: LogEntry[], mode: DedupMode): LogEntry[] {
|
|
if (mode === 'none') return entries;
|
|
const seen = new Set<string>();
|
|
return entries.filter(e => {
|
|
let key: string;
|
|
if (mode === 'exact') key = e.message;
|
|
else if (mode === 'numbers') key = e.message.replace(/\d+/g, '');
|
|
else key = `${e.level || ''}:${e.labels?.service || ''}:${e.message.slice(0, 80)}`;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
// ===== 提取 Common Labels ===== //
|
|
export function extractCommonLabels(entries: LogEntry[]): Record<string, string> | null {
|
|
if (entries.length === 0) return null;
|
|
const first = entries[0].labels || {};
|
|
const common: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(first)) {
|
|
if (entries.every(e => e.labels?.[k] === v)) common[k] = v;
|
|
}
|
|
return Object.keys(common).length > 0 ? common : null;
|
|
}
|
|
|
|
// 日志行 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 风格)=====
|
|
function RawLogView({
|
|
entry, wrap, trunc, showTime, onLabelFilter,
|
|
}: {
|
|
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 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 = 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 (
|
|
<div className="group" style={{ borderBottom: '1px solid var(--dg-border)' }}>
|
|
{/* 日志行 */}
|
|
<div
|
|
className="flex items-start gap-2 cursor-pointer hover:opacity-90 transition-colors"
|
|
style={{ background: 'var(--dg-bg-secondary)', padding: '4px 12px' }}
|
|
onClick={() => setExpanded(!expanded)}
|
|
>
|
|
{/* 展开图标 */}
|
|
<span className="flex-shrink-0 mt-0.5" style={{ color: 'var(--dg-text-muted)' }}>
|
|
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
|
</span>
|
|
|
|
{/* 时间戳 */}
|
|
{showTime && (
|
|
<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>
|
|
)}
|
|
|
|
{/* 日志级别 */}
|
|
{entry.level && (
|
|
<span className="text-[10px] font-medium flex-shrink-0 mt-0.5" style={{ color: levelColorText(entry.level), minWidth: '40px' }}>
|
|
{entry.level.toUpperCase()}
|
|
</span>
|
|
)}
|
|
|
|
{/* 日志内容(折叠预览) */}
|
|
<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 显示,复制完整原文) */}
|
|
<button
|
|
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>
|
|
|
|
{/* 展开详情:完整日志 + 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">
|
|
<p className="text-[10px] font-medium mb-1.5" style={{ color: 'var(--dg-text-muted)' }}>Log labels</p>
|
|
<div className="space-y-1">
|
|
{Object.entries(labels).map(([k, v]) => (
|
|
<div key={k} className="flex items-center gap-2 group/label">
|
|
<BarChart3 className="h-3 w-3 flex-shrink-0 opacity-0 group-hover/label:opacity-100 transition-opacity" style={{ color: 'var(--dg-text-muted)' }} />
|
|
<button
|
|
className="opacity-0 group-hover/label:opacity-100 transition-opacity flex-shrink-0"
|
|
style={{ color: '#22c55e' }}
|
|
onClick={e => { e.stopPropagation(); onLabelFilter?.(k, v, 'include'); }}
|
|
title={`Filter for ${k}=${v}`}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
</button>
|
|
<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>
|
|
</div>
|
|
)}
|
|
|
|
{/* Detected fields */}
|
|
{detected.length > 0 && (
|
|
<div>
|
|
<p className="text-[10px] font-medium mb-1.5" style={{ color: 'var(--dg-text-muted)' }}>Detected fields</p>
|
|
<div className="space-y-1">
|
|
{detected.map(({ name, value }) => (
|
|
<div key={name} className="flex items-center gap-2 group/field">
|
|
<BarChart3 className="h-3 w-3 flex-shrink-0 opacity-0 group-hover/field:opacity-100 transition-opacity" style={{ color: 'var(--dg-text-muted)' }} />
|
|
<button
|
|
className="opacity-0 group-hover/field:opacity-100 transition-opacity flex-shrink-0"
|
|
style={{ color: '#22c55e' }}
|
|
onClick={e => { e.stopPropagation(); onLabelFilter?.(name, value, 'include'); }}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
</button>
|
|
<button
|
|
className="opacity-0 group-hover/field:opacity-100 transition-opacity flex-shrink-0"
|
|
style={{ color: '#ef4444' }}
|
|
onClick={e => { e.stopPropagation(); onLabelFilter?.(name, value, 'exclude'); }}
|
|
>
|
|
<Search className="h-3 w-3" />
|
|
</button>
|
|
<span className="text-[11px] font-mono w-[160px] truncate flex-shrink-0" style={{ color: 'var(--dg-text-primary)' }}>{name}</span>
|
|
<span className="text-[11px] font-mono flex-1 break-all" style={{ color: 'var(--dg-text-secondary)' }}>{value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 字段视图 ===== //
|
|
function FieldView({ entries, wrap, trunc, search }: { entries: LogEntry[]; wrap: boolean; trunc: boolean; search?: string }) {
|
|
const columns = useMemo(() => {
|
|
const colSet = new Set<string>();
|
|
entries.forEach(e => { if (e.labels) Object.keys(e.labels).forEach(k => colSet.add(k)); });
|
|
colSet.add('timestamp'); colSet.add('message');
|
|
const arr = Array.from(colSet);
|
|
return search ? arr.filter(c => c.toLowerCase().includes(search.toLowerCase())) : arr;
|
|
}, [entries, search]);
|
|
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-xs" style={{ borderCollapse: 'collapse' }}>
|
|
<thead>
|
|
<tr style={{ background: 'var(--dg-bg-tertiary)', borderBottom: '1px solid var(--dg-border)' }}>
|
|
{columns.map(col => (
|
|
<th key={col} className="text-left px-2 py-1.5 font-medium whitespace-nowrap" style={{ color: 'var(--dg-text-muted)', fontSize: '10px', textTransform: 'uppercase' }}>{col}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{entries.map((e, i) => (
|
|
<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 = 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-[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>
|
|
);
|
|
})}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 表格视图 ===== //
|
|
function TableView({ entries, wrap, trunc }: { entries: LogEntry[]; wrap: boolean; trunc: boolean }) {
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-xs" style={{ borderCollapse: 'collapse' }}>
|
|
<thead>
|
|
<tr style={{ background: 'var(--dg-bg-tertiary)', borderBottom: '1px solid var(--dg-border)' }}>
|
|
<th className="text-left px-3 py-2 font-medium w-[160px]" style={{ color: 'var(--dg-text-muted)', fontSize: '10px' }}>Time</th>
|
|
<th className="text-left px-3 py-2 font-medium w-[72px]" style={{ color: 'var(--dg-text-muted)', fontSize: '10px' }}>Level</th>
|
|
<th className="text-left px-3 py-2 font-medium" style={{ color: 'var(--dg-text-muted)', fontSize: '10px' }}>Message</th>
|
|
</tr>
|
|
</thead>
|
|
<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)' }}>{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 ${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>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 主组件 ===== //
|
|
export function EnhancedLogResults({
|
|
entries, loading, viewMode, wrapLines, truncate, density = 'comfortable',
|
|
showTime = true, onShowTimeChange,
|
|
uniqueLabels = false, onUniqueLabelsChange,
|
|
prettifyJSON = false, onPrettifyJSONChange,
|
|
dedupMode = 'none', onDedupModeChange,
|
|
sortOrder = 'newest', onSortOrderChange,
|
|
commonLabels,
|
|
onLabelFilter,
|
|
}: Props) {
|
|
// 去重处理
|
|
const dedupedEntries = useMemo(() => dedupEntries(entries, dedupMode), [entries, dedupMode]);
|
|
|
|
// 排序处理
|
|
const sortedEntries = useMemo(() => {
|
|
if (sortOrder === 'oldest') return [...dedupedEntries].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
return dedupedEntries;
|
|
}, [dedupedEntries, sortOrder]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="border rounded-lg overflow-hidden animate-pulse" style={{ borderColor: 'var(--dg-border)' }}>
|
|
{[1, 2, 3, 4, 5].map(i => (
|
|
<div key={i} className={`${DENSITY_PADDING[density]} px-3`} style={{ borderBottom: '1px solid var(--dg-border)' }}>
|
|
<div className="h-4 rounded" style={{ background: 'var(--dg-bg-tertiary)', width: `${60 + Math.random() * 40}%` }} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (entries.length === 0) {
|
|
return (
|
|
<div className="border rounded-lg overflow-hidden flex items-center justify-center" style={{ borderColor: 'var(--dg-border)', minHeight: '200px' }}>
|
|
<span className="text-sm" style={{ color: 'var(--dg-text-muted)' }}>暂无日志</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{/* Common labels */}
|
|
{commonLabels && Object.keys(commonLabels).length > 0 && (
|
|
<div className="flex flex-wrap items-center gap-1.5 mb-2 px-1">
|
|
<span className="text-[11px]" style={{ color: 'var(--dg-text-muted)' }}>Common labels:</span>
|
|
{Object.entries(commonLabels).map(([k, v]) => (
|
|
<span key={k} className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-secondary)', border: '1px solid var(--dg-border)' }}>
|
|
{k}={v}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 工具栏 */}
|
|
<div className="flex flex-wrap items-center gap-3 mb-2 px-1">
|
|
{/* Time 开关 */}
|
|
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }}>
|
|
<input type="checkbox" checked={showTime} onChange={e => onShowTimeChange?.(e.target.checked)} className="h-3 w-3 rounded" />
|
|
Time
|
|
</label>
|
|
|
|
{/* Unique labels */}
|
|
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }}>
|
|
<input type="checkbox" checked={uniqueLabels} onChange={e => onUniqueLabelsChange?.(e.target.checked)} className="h-3 w-3 rounded" />
|
|
Unique labels
|
|
</label>
|
|
|
|
{/* Wrap lines */}
|
|
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }}>
|
|
<input type="checkbox" checked={wrapLines} onChange={() => {}} className="h-3 w-3 rounded" disabled />
|
|
Wrap lines
|
|
</label>
|
|
|
|
{/* Prettify JSON */}
|
|
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }}>
|
|
<input type="checkbox" checked={prettifyJSON} onChange={e => onPrettifyJSONChange?.(e.target.checked)} className="h-3 w-3 rounded" />
|
|
Prettify JSON
|
|
</label>
|
|
|
|
{/* Dedup */}
|
|
<div className="flex items-center gap-1 text-[11px]">
|
|
<span style={{ color: 'var(--dg-text-muted)' }}>Dedup</span>
|
|
{(['none', 'exact', 'numbers', 'signature'] as DedupMode[]).map(m => (
|
|
<button
|
|
key={m}
|
|
onClick={() => onDedupModeChange?.(m)}
|
|
className="px-1.5 py-0.5 rounded text-[10px] transition-colors"
|
|
style={{
|
|
background: dedupMode === m ? 'var(--dg-bg-tertiary)' : 'transparent',
|
|
color: dedupMode === m ? 'var(--dg-text-primary)' : 'var(--dg-text-muted)',
|
|
border: dedupMode === m ? '1px solid var(--dg-border)' : '1px solid transparent',
|
|
}}
|
|
>
|
|
{m.charAt(0).toUpperCase() + m.slice(1)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Sort order */}
|
|
<div className="flex items-center gap-1 ml-auto">
|
|
<span className="text-[11px]" style={{ color: 'var(--dg-text-muted)' }}>Display results</span>
|
|
{(['newest', 'oldest'] as const).map(o => (
|
|
<button
|
|
key={o}
|
|
onClick={() => onSortOrderChange?.(o)}
|
|
className="px-2 py-0.5 rounded text-[10px] transition-colors"
|
|
style={{
|
|
background: sortOrder === o ? 'var(--dg-bg-tertiary)' : 'transparent',
|
|
color: sortOrder === o ? 'var(--dg-text-primary)' : 'var(--dg-text-muted)',
|
|
border: sortOrder === o ? '1px solid var(--dg-border)' : '1px solid transparent',
|
|
}}
|
|
>
|
|
{o === 'newest' ? 'Newest first' : 'Oldest first'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 结果内容 */}
|
|
<div className="border rounded-lg overflow-hidden" style={{ borderColor: 'var(--dg-border)' }}>
|
|
{viewMode === 'raw' && sortedEntries.map((e, i) => (
|
|
<RawLogView key={i} entry={e} wrap={wrapLines} trunc={truncate} showTime={showTime} onLabelFilter={onLabelFilter} />
|
|
))}
|
|
{viewMode === 'field' && <FieldView entries={sortedEntries} wrap={wrapLines} trunc={truncate} />}
|
|
{viewMode === 'table' && <TableView entries={sortedEntries} wrap={wrapLines} trunc={truncate} />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|