119 lines
3.5 KiB
TypeScript
119 lines
3.5 KiB
TypeScript
export type ConsoleLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
export type ConsoleLevelFilter = 'all' | ConsoleLevel;
|
|
|
|
export interface ConsoleEntry {
|
|
id: string;
|
|
occurredAt: string;
|
|
level: ConsoleLevel;
|
|
message: string;
|
|
data?: unknown;
|
|
}
|
|
|
|
export interface LiveConsoleState {
|
|
capacity: number;
|
|
entries: readonly ConsoleEntry[];
|
|
pendingEntries: readonly ConsoleEntry[];
|
|
isPaused: boolean;
|
|
isAutoScrollEnabled: boolean;
|
|
level: ConsoleLevelFilter;
|
|
search: string;
|
|
}
|
|
|
|
export type LiveConsoleAction =
|
|
| { type: 'append'; entry: ConsoleEntry }
|
|
| { type: 'set-paused'; paused: boolean }
|
|
| { type: 'set-auto-scroll'; enabled: boolean }
|
|
| { type: 'set-level'; level: ConsoleLevelFilter }
|
|
| { type: 'set-search'; search: string }
|
|
| { type: 'clear' };
|
|
|
|
function boundedAppend(
|
|
entries: readonly ConsoleEntry[],
|
|
entry: ConsoleEntry,
|
|
capacity: number,
|
|
): readonly ConsoleEntry[] {
|
|
if (entries.some((candidate) => isSameEntry(candidate, entry))) return entries;
|
|
return [...entries, entry].slice(-capacity);
|
|
}
|
|
|
|
function isSameEntry(left: ConsoleEntry, right: ConsoleEntry): boolean {
|
|
return left.id === right.id || (
|
|
left.occurredAt === right.occurredAt &&
|
|
left.level === right.level &&
|
|
left.message === right.message &&
|
|
searchableData(left.data) === searchableData(right.data)
|
|
);
|
|
}
|
|
|
|
export function createLiveConsoleState(capacity = 500): LiveConsoleState {
|
|
if (!Number.isInteger(capacity) || capacity <= 0) {
|
|
throw new RangeError('capacity must be a positive integer');
|
|
}
|
|
return {
|
|
capacity,
|
|
entries: [],
|
|
pendingEntries: [],
|
|
isPaused: false,
|
|
isAutoScrollEnabled: true,
|
|
level: 'all',
|
|
search: '',
|
|
};
|
|
}
|
|
|
|
export function reduceLiveConsoleState(
|
|
state: LiveConsoleState,
|
|
action: LiveConsoleAction,
|
|
): LiveConsoleState {
|
|
switch (action.type) {
|
|
case 'append': {
|
|
if (
|
|
state.entries.some((entry) => isSameEntry(entry, action.entry)) ||
|
|
state.pendingEntries.some((entry) => isSameEntry(entry, action.entry))
|
|
) return state;
|
|
return state.isPaused
|
|
? { ...state, pendingEntries: boundedAppend(state.pendingEntries, action.entry, state.capacity) }
|
|
: { ...state, entries: boundedAppend(state.entries, action.entry, state.capacity) };
|
|
}
|
|
case 'set-paused':
|
|
if (action.paused === state.isPaused) return state;
|
|
return action.paused
|
|
? { ...state, isPaused: true }
|
|
: {
|
|
...state,
|
|
isPaused: false,
|
|
entries: [...state.entries, ...state.pendingEntries].slice(-state.capacity),
|
|
pendingEntries: [],
|
|
};
|
|
case 'set-auto-scroll':
|
|
return { ...state, isAutoScrollEnabled: action.enabled };
|
|
case 'set-level':
|
|
return { ...state, level: action.level };
|
|
case 'set-search':
|
|
return { ...state, search: action.search };
|
|
case 'clear':
|
|
return { ...state, entries: [], pendingEntries: [] };
|
|
}
|
|
}
|
|
|
|
function searchableData(data: unknown): string {
|
|
if (data === undefined) return '';
|
|
try {
|
|
return JSON.stringify(data);
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
export function selectVisibleConsoleEntries(
|
|
state: LiveConsoleState,
|
|
): readonly ConsoleEntry[] {
|
|
const search = state.search.trim().toLocaleLowerCase();
|
|
return state.entries.filter((entry) => {
|
|
if (state.level !== 'all' && entry.level !== state.level) return false;
|
|
if (search.length === 0) return true;
|
|
return `${entry.message} ${searchableData(entry.data)}`
|
|
.toLocaleLowerCase()
|
|
.includes(search);
|
|
});
|
|
}
|