Files
RedditBot/apps/dashboard/src/features/operations/live-console-model.ts
T

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);
});
}