fix: harden application log viewer
Add an Application Logs window backed by a bounded persistent main log file. The viewer loads history from lanspread.log, subscribes to live INFO/WARN/ERROR log events, supports filtering/copy/pause controls, and keeps the menu/window routing separate from the unpack log viewer. The backend sink now owns serialized access to the log file. History reads and append-time trimming use the same sink lock, so opening the logs window cannot race with a concurrent write and rewrite away a freshly appended line. The sink also keeps a persistent file handle instead of reopening the file for each captured event. Live log events carry sink-local sequence ids. The frontend uses the history watermark plus returned history line counts to suppress live events that were already included in the history response, while preserving buffered rows that were trimmed out of the history file. Auto-scroll now follows the last visible row identity, so it continues following after the in-memory cap keeps the row count stable. No timestamp code change was needed. On the Linux dev host, a temporary probe showed time::OffsetDateTime::now_local() returning +02:00 while UTC was +00:00, matching the host CEST offset. Test Plan: - just fmt - just frontend-test - just test - just clippy - just build - git diff --cached --check - temporary Linux probe of OffsetDateTime::now_local() showed local +02:00 Refs: none
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
export const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'] as const;
|
||||
export type LogLevel = typeof LOG_LEVELS[number];
|
||||
export type LevelFilter = 'all' | 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
export interface MainLogLinePayload {
|
||||
line: string;
|
||||
level: string;
|
||||
sequence?: number | null;
|
||||
}
|
||||
|
||||
export interface MainLogHistoryPayload {
|
||||
contents: string;
|
||||
lastSequence: number;
|
||||
}
|
||||
|
||||
export interface MainLogRow {
|
||||
id: string;
|
||||
line: string;
|
||||
level: LogLevel;
|
||||
sequence?: number;
|
||||
}
|
||||
|
||||
export const LEVEL_ORDER: Record<LogLevel, number> = {
|
||||
TRACE: 0,
|
||||
DEBUG: 1,
|
||||
INFO: 2,
|
||||
WARN: 3,
|
||||
ERROR: 4,
|
||||
};
|
||||
|
||||
export const LEVEL_FILTER_MIN: Record<LevelFilter, number> = {
|
||||
all: LEVEL_ORDER.TRACE,
|
||||
debug: LEVEL_ORDER.DEBUG,
|
||||
info: LEVEL_ORDER.INFO,
|
||||
warn: LEVEL_ORDER.WARN,
|
||||
error: LEVEL_ORDER.ERROR,
|
||||
};
|
||||
|
||||
export const LEVEL_FILTER_OPTIONS: ReadonlyArray<{ value: LevelFilter; label: string }> = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'debug', label: 'Debug+' },
|
||||
{ value: 'info', label: 'Info+' },
|
||||
{ value: 'warn', label: 'Warn+' },
|
||||
{ value: 'error', label: 'Error only' },
|
||||
];
|
||||
|
||||
const MAX_IN_MEMORY_LOG_ROWS = 12_000;
|
||||
const MAX_IN_MEMORY_LOG_CHARS = 2 * 1024 * 1024;
|
||||
|
||||
let nextSyntheticLogRowId = 0;
|
||||
|
||||
const syntheticLogRowId = (): string => {
|
||||
nextSyntheticLogRowId += 1;
|
||||
return `live-synthetic-${nextSyntheticLogRowId}`;
|
||||
};
|
||||
|
||||
const isLogLevel = (value: string | undefined): value is LogLevel =>
|
||||
typeof value === 'string' && (LOG_LEVELS as readonly string[]).includes(value);
|
||||
|
||||
export const normalizeLogLevel = (value: string | undefined): LogLevel => {
|
||||
const upper = value?.toUpperCase();
|
||||
return isLogLevel(upper) ? upper : 'INFO';
|
||||
};
|
||||
|
||||
export const parseLogLevelFromLine = (line: string): LogLevel => {
|
||||
const match = line.match(/\[(TRACE|DEBUG|INFO|WARN|ERROR)\](?:\s|$)/);
|
||||
return normalizeLogLevel(match?.[1]);
|
||||
};
|
||||
|
||||
export const rowFromPayload = (payload: MainLogLinePayload): MainLogRow => {
|
||||
const sequence = typeof payload.sequence === 'number' ? payload.sequence : undefined;
|
||||
|
||||
return {
|
||||
id: sequence === undefined ? syntheticLogRowId() : `live-${sequence}`,
|
||||
line: payload.line,
|
||||
level: normalizeLogLevel(payload.level),
|
||||
sequence,
|
||||
};
|
||||
};
|
||||
|
||||
export const rowsFromHistory = (text: string): MainLogRow[] =>
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.filter(line => line.length > 0)
|
||||
.map((line, index) => ({
|
||||
id: `history-${index}`,
|
||||
line,
|
||||
level: parseLogLevelFromLine(line),
|
||||
}));
|
||||
|
||||
export const capLogRows = (rows: MainLogRow[]): MainLogRow[] => {
|
||||
let charCount = 0;
|
||||
const capped: MainLogRow[] = [];
|
||||
|
||||
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
||||
const row = rows[index];
|
||||
const rowChars = row.line.length + 1;
|
||||
const wouldExceedRows = capped.length >= MAX_IN_MEMORY_LOG_ROWS;
|
||||
const wouldExceedChars = charCount + rowChars > MAX_IN_MEMORY_LOG_CHARS;
|
||||
if (wouldExceedRows || (wouldExceedChars && capped.length > 0)) {
|
||||
break;
|
||||
}
|
||||
|
||||
capped.push(row);
|
||||
charCount += rowChars;
|
||||
}
|
||||
|
||||
return capped.reverse();
|
||||
};
|
||||
|
||||
export const rowWasLoadedInHistory = (row: MainLogRow, lastHistorySequence: number): boolean =>
|
||||
typeof row.sequence === 'number' && row.sequence <= lastHistorySequence;
|
||||
|
||||
export const lineCountsFromRows = (rows: MainLogRow[]): Map<string, number> => {
|
||||
const lineCounts = new Map<string, number>();
|
||||
rows.forEach(row => {
|
||||
lineCounts.set(row.line, (lineCounts.get(row.line) ?? 0) + 1);
|
||||
});
|
||||
return lineCounts;
|
||||
};
|
||||
|
||||
export const consumeLoadedHistoryRow = (
|
||||
historyLineCounts: Map<string, number>,
|
||||
row: MainLogRow,
|
||||
lastHistorySequence: number,
|
||||
): boolean => {
|
||||
if (!rowWasLoadedInHistory(row, lastHistorySequence)) return false;
|
||||
|
||||
const count = historyLineCounts.get(row.line) ?? 0;
|
||||
if (count <= 0) return false;
|
||||
|
||||
if (count === 1) {
|
||||
historyLineCounts.delete(row.line);
|
||||
} else {
|
||||
historyLineCounts.set(row.line, count - 1);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const dedupeBufferedRows = (
|
||||
historyLineCounts: Map<string, number>,
|
||||
bufferedRows: MainLogRow[],
|
||||
lastHistorySequence: number,
|
||||
): MainLogRow[] =>
|
||||
bufferedRows.filter(row => !consumeLoadedHistoryRow(historyLineCounts, row, lastHistorySequence));
|
||||
|
||||
export const formatCount = (count: number, noun: string): string =>
|
||||
`${count.toLocaleString()} ${noun}${count === 1 ? '' : 's'}`;
|
||||
Reference in New Issue
Block a user