File size: 7,172 Bytes
7c5ed63 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 | import { useVirtualizer } from '@tanstack/react-virtual';
import { useMemo, useRef, useState } from 'react';
import { useLogs } from '../../hooks/useTasks';
import type { LogLine } from '../../types';
import { formatWallClock } from '../../util/time';
import { Pill, type PillTone } from '../shared/Pill';
interface LogsTabProps {
sessionId: string;
}
function levelTone(level: string | null): PillTone {
switch (level) {
case 'ERROR':
case 'FATAL':
return 'error';
case 'WARN':
case 'WARNING':
return 'warning';
case 'INFO':
return 'info';
case 'DEBUG':
case 'TRACE':
return 'meta';
default:
return 'neutral';
}
}
const LEVELS = ['ALL', 'ERROR', 'WARN', 'INFO', 'DEBUG'] as const;
type LevelFilter = (typeof LEVELS)[number];
function matchesLevel(line: LogLine, filter: LevelFilter): boolean {
if (filter === 'ALL') return true;
if (line.level === null) return false;
if (filter === 'WARN') return line.level === 'WARN' || line.level === 'WARNING';
if (filter === 'ERROR') return line.level === 'ERROR' || line.level === 'FATAL';
return line.level === filter;
}
/** Logs tab — structured view of a session's diagnostic log. Works for both
* local sessions (whose dir holds `logs/kimi-code.log`) and imported bundles
* (which additionally may carry the global log). */
export function LogsTab({ sessionId }: LogsTabProps) {
const [which, setWhich] = useState<'session' | 'global'>('session');
const [level, setLevel] = useState<LevelFilter>('ALL');
const [search, setSearch] = useState('');
const { data, isLoading, error } = useLogs(sessionId, which);
const parentRef = useRef<HTMLDivElement>(null);
const lines = data?.lines ?? [];
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return lines.filter((l) => {
if (!matchesLevel(l, level)) return false;
if (!q) return true;
return l.raw.toLowerCase().includes(q);
});
}, [lines, level, search]);
const virt = useVirtualizer({
count: filtered.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 24,
overscan: 20,
getItemKey: (i) => filtered[i]?.lineNo ?? i,
});
const available = data?.available ?? { session: false, global: false };
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border bg-surface-1 px-3 py-2">
<div className="flex items-center gap-1 font-mono text-[11px]">
<SegBtn active={which === 'session'} onClick={() => { setWhich('session'); }} disabled={!available.session && !isLoading}>
session
</SegBtn>
<SegBtn active={which === 'global'} onClick={() => { setWhich('global'); }} disabled={!available.global}>
global
</SegBtn>
</div>
<label className="flex items-center gap-1.5 font-mono text-[11px] text-fg-2">
<span className="text-fg-3">level</span>
<select
value={level}
onChange={(e) => { setLevel(e.target.value as LevelFilter); }}
className="border border-border bg-surface-0 px-1 py-0.5 text-fg-1 focus:border-border-strong focus:outline-none"
>
{LEVELS.map((l) => (
<option key={l} value={l}>{l.toLowerCase()}</option>
))}
</select>
</label>
<input
type="text"
placeholder="search log (substring)"
value={search}
onChange={(e) => { setSearch(e.target.value); }}
className="w-64 border border-border bg-surface-0 px-2 py-1 font-mono text-[12px] text-fg-0 placeholder:text-fg-3 focus:border-border-strong focus:outline-none"
/>
<span className="ml-auto font-mono text-[11px] text-fg-3 tabular">
{filtered.length} / {lines.length}
{data?.truncated ? ' · tail' : ''}
</span>
</div>
{isLoading ? (
<div className="p-6 font-mono text-[12px] text-fg-3">loading log…</div>
) : error ? (
<div className="p-6 font-mono text-[12px] text-[var(--color-sev-error)]">{error.message}</div>
) : lines.length === 0 ? (
<div className="p-6 font-mono text-[12px] text-fg-3">
{which === 'global' && !available.global
? 'no global log in this bundle (export without --include-global-log)'
: 'no log available for this session'}
</div>
) : (
<div ref={parentRef} className="min-h-0 flex-1 overflow-y-auto">
{data?.truncated ? (
<div className="border-b border-[var(--color-sev-warning)] bg-[color-mix(in_oklab,var(--color-sev-warning)_8%,transparent)] px-3 py-1 font-mono text-[10px] text-[var(--color-sev-warning)]">
log is large — showing the most recent {lines.length} lines
</div>
) : null}
<div style={{ height: virt.getTotalSize(), position: 'relative' }}>
{virt.getVirtualItems().map((vi) => {
const line = filtered[vi.index];
if (!line) return null;
return (
<div
key={vi.key}
data-index={vi.index}
ref={virt.measureElement}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
>
<LogRow line={line} />
</div>
);
})}
</div>
</div>
)}
</div>
);
}
function SegBtn({ active, onClick, disabled, children }: { active: boolean; onClick: () => void; disabled?: boolean; children: import('react').ReactNode }) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={[
'border px-2 py-0.5',
active ? 'border-[var(--color-cat-conversation)] text-fg-0' : 'border-border text-fg-2 hover:text-fg-0',
disabled ? 'opacity-40' : '',
].join(' ')}
>
{children}
</button>
);
}
function LogRow({ line }: { line: LogLine }) {
const fieldKeys = Object.keys(line.fields);
return (
<div className="flex items-start gap-2 border-b border-border/40 px-3 py-[3px] font-mono text-[11px] hover:bg-surface-1">
<span className="w-[68px] shrink-0 text-fg-3 tabular" title={line.time ?? ''}>
{line.time ? formatWallClock(Date.parse(line.time)) : '—'}
</span>
<span className="w-[52px] shrink-0">
{line.level ? (
<Pill tone={levelTone(line.level)} variant="outline">{line.level}</Pill>
) : null}
</span>
<span className="min-w-0 flex-1 break-words text-fg-1">
{line.message}
{fieldKeys.length > 0 ? (
<span className="ml-2 text-fg-3">
{fieldKeys.map((k) => (
<span key={k} className="mr-2">
<span className="text-fg-2">{k}</span>=<span className="text-[var(--color-sev-info)]">{line.fields[k]}</span>
</span>
))}
</span>
) : null}
</span>
</div>
);
}
|