Files
pepperplus-ui/src/components/Logging/useLogs.ts
2025-12-16 11:26:35 +00:00

213 lines
7.6 KiB
TypeScript

import {useCallback, useEffect, useRef, useState} from "react";
import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../utils/priorityFiltering.ts";
import {cell, type Cell} from "../../utils/cellStore.ts";
/**
* Represents a single log record emitted by the backend logging system.
*
* @property name - The name of the logger or source (e.g., `"agent.core"`).
* @property message - The message content of the log record.
* @property levelname - The human-readable severity level (e.g., `"INFO"`, `"ERROR"`).
* @property levelno - The numeric severity value corresponding to `levelname`.
* @property created - The UNIX timestamp (in seconds) when this record was created.
* @property relativeCreated - The time (in milliseconds) since the logging system started.
* @property reference - (Optional) A reference identifier linking related log messages.
* @property firstCreated - Timestamp of the first log in this reference group.
* @property firstRelativeCreated - Relative timestamp of the first log in this reference group.
*/
export type LogRecord = {
name: string;
message: string;
levelname: 'LLM' | 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | string;
levelno: number;
created: number;
relativeCreated: number;
reference?: string;
firstCreated: number;
firstRelativeCreated: number;
};
/**
* A log filter predicate with priority support, used to determine whether
* a log record should be displayed.
*
* This extends a general `PriorityFilterPredicate` and includes an optional
* `value` field for UI metadata (e.g., selected log level or agent).
*
* @template T - The type of record being filtered (here, `LogRecord`).
*/
export type LogFilterPredicate = PriorityFilterPredicate<LogRecord> & {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any };
/**
* React hook that manages the lifecycle of log records, including:
* - Receiving live log messages via Server-Sent Events (SSE),
* - Applying priority-based filtering rules,
* - Managing distinct logger names and reference-linked messages.
*
* Returns both the filtered logs (as reactive `Cell<LogRecord>` objects)
* and a set of distinct logger names for use in UI components (e.g., Filters).
*
* @param filterPredicates - A `Map` of log filter predicates, keyed by ID or type.
* @returns An object containing:
* - `filteredLogs`: The currently visible (filtered) log messages.
* - `distinctNames`: A set of all distinct logger names encountered.
*
* @example
* ```ts
* const { filteredLogs, distinctNames } = useLogs(activeFilters);
* ```
*/
export function useLogs(filterPredicates: Map<string, LogFilterPredicate>) {
/** Distinct logger names encountered across all logs. */
const [distinctNames, setDistinctNames] = useState<Set<string>>(new Set());
/** Filtered logs that pass all active predicates, stored as reactive cells. */
const [filtered, setFiltered] = useState<Cell<LogRecord>[]>([]);
/** Persistent reference to the active EventSource connection. */
const sseRef = useRef<EventSource | null>(null);
/** Keeps a stable reference to the current filter map (avoids re-renders). */
const filtersRef = useRef(filterPredicates);
/** Stores all received logs (the unfiltered full history). */
const logsRef = useRef<LogRecord[]>([]);
/** Map to store the first message for each reference, instance can be updated to change contents. */
const firstByRefRef = useRef<Map<string, Cell<LogRecord>>>(new Map());
/**
* Apply all active filter predicates to a log record.
* @param log The log record to apply the filters to.
* @returns `true` if the record passes all filters; otherwise `false`.
*/
const applyFilters = useCallback((log: LogRecord) =>
applyPriorityPredicates(log, [...filtersRef.current.values()]), []);
/**
* Fully recomputes the filtered log list based on the current
* filter predicates and historical logs.
*
* Should be invoked whenever the filter map changes.
*/
const recomputeFiltered = useCallback(() => {
const newFiltered: Cell<LogRecord>[] = [];
firstByRefRef.current = new Map();
for (const message of logsRef.current) {
const messageCell = cell<LogRecord>({
...message,
firstCreated: message.created,
firstRelativeCreated: message.relativeCreated,
});
// Handle reference grouping: update the first message in the group.
if (message.reference) {
const first = firstByRefRef.current.get(message.reference);
if (first) {
// Update the first's contents
first.set((prev) => ({
...message,
firstCreated: prev.firstCreated ?? prev.created,
firstRelativeCreated: prev.firstRelativeCreated ?? prev.relativeCreated,
}));
continue; // Don't add it to the list again (it's a duplicate).
} else {
// Add the first message with this reference to the registry
firstByRefRef.current.set(message.reference, messageCell);
}
}
// Include only if it passes current filters.
if (applyFilters(message)) {
newFiltered.push(messageCell);
}
}
setFiltered(newFiltered);
}, [applyFilters, setFiltered]);
// Re-filter all logs whenever filter predicates change.
useEffect(() => {
filtersRef.current = filterPredicates;
recomputeFiltered();
}, [filterPredicates, recomputeFiltered]);
/**
* Handles a newly received log record.
* Updates the full log history, distinct names set, and filtered log list.
*
* @param message - The new log record to process.
*/
const handleNewMessage = useCallback((message: LogRecord) => {
// Store in complete history for future refiltering.
logsRef.current.push(message);
// Track distinct logger names.
setDistinctNames((prev) => {
if (prev.has(message.name)) return prev;
const newSet = new Set(prev);
newSet.add(message.name);
return newSet;
});
// Wrap in a reactive cell for UI binding.
const messageCell = cell<LogRecord>({
...message,
firstCreated: message.created,
firstRelativeCreated: message.relativeCreated,
});
// Handle reference-linked updates.
if (message.reference) {
const first = firstByRefRef.current.get(message.reference);
if (first) {
// Update the first's contents
first.set((prev) => ({
...message,
firstCreated: prev.firstCreated ?? prev.created,
firstRelativeCreated: prev.firstRelativeCreated ?? prev.relativeCreated,
}));
return; // Do not duplicate reference group entries.
} else {
firstByRefRef.current.set(message.reference, messageCell);
}
}
// Only append if message passes filters.
if (applyFilters(message)) {
setFiltered((curr) => [...curr, messageCell]);
}
}, [applyFilters, setFiltered]);
/**
* Initializes the SSE (Server-Sent Events) stream for real-time logs.
*
* Subscribes to messages from the backend logging endpoint and
* dispatches each message to `handleNewMessage`.
*
* Cleans up the EventSource connection when the component unmounts.
*/
useEffect(() => {
// Only create one SSE connection for the lifetime of the hook.
if (sseRef.current) return;
const es = new EventSource("http://localhost:8000/logs/stream");
sseRef.current = es;
es.onmessage = (event) => {
const data: LogRecord = JSON.parse(event.data);
handleNewMessage(data);
};
return () => {
es.close();
sseRef.current = null;
};
}, [handleNewMessage]);
return {filteredLogs: filtered, distinctNames};
}