Files
pepperplus-ui/src/components/Logging/Logging.tsx
Twirre Meulenbelt 6d5f1e33ef feat: add experiment logs to the monitoring page
ref: N25B-401
2026-01-19 12:57:01 +01:00

180 lines
6.5 KiB
TypeScript

import {type ComponentType, useEffect, useRef, useState} from "react";
import formatDuration from "../../utils/formatDuration.ts";
import {type LogFilterPredicate, type LogRecord, useLogs} from "./useLogs.ts";
import Filters from "./Filters.tsx";
import {type Cell, useCell} from "../../utils/cellStore.ts";
import styles from "./Logging.module.css";
import {
EXPERIMENT_FILTER_KEY,
EXPERIMENT_LOGGER_NAME,
type LoggingSettings,
type MessageComponentProps
} from "./Definitions.ts";
import {create} from "zustand";
/**
* Local Zustand store for logging UI preferences.
*/
const useLoggingSettings = create<LoggingSettings>((set) => ({
showRelativeTime: false,
setShowRelativeTime: (showRelativeTime: boolean) => set({ showRelativeTime }),
}));
/**
* Renders a single log message entry with colored level indicators and timestamp formatting.
*
* This component automatically re-renders when the underlying log record (`recordCell`)
* changes. It also triggers the `onUpdate` callback whenever the record updates (e.g., for auto-scrolling).
*
* @param recordCell - A reactive `Cell` containing a single `LogRecord`.
* @param onUpdate - Optional callback triggered when the log entry updates.
* @returns A JSX element displaying a formatted log message.
*/
function LogMessage({ recordCell, onUpdate }: MessageComponentProps) {
const { showRelativeTime, setShowRelativeTime } = useLoggingSettings();
const record = useCell(recordCell);
/**
* Normalizes the log level number to a multiple of 10,
* for which there are CSS styles. (e.g., INFO = 20, ERROR = 40).
*/
const normalizedLevelNo = (() => {
// By default, the highest level is 50 (CRITICAL). Custom levels can be higher, but we don't have more critical color.
if (record.levelno >= 50) return 50;
return Math.round(record.levelno / 10) * 10;
})();
/** Simplifies the logger name by showing only the last path segment. */
const normalizedName = record.name.split(".").pop() || record.name;
// Notify the parent component (e.g., for scroll updates) when this record changes.
useEffect(() => {
if (onUpdate) onUpdate();
}, [record, onUpdate]);
return <div className={`${styles.logContainer} round-md border-lg flex-row gap-md`}>
<div className={`${styles[`accented${normalizedLevelNo}`]} flex-col padding-sm justify-between`}>
<span className={"mono bold"}>{record.levelname}</span>
<span className={"mono clickable font-small"} onClick={() => setShowRelativeTime(!showRelativeTime)}>{
showRelativeTime
? formatDuration(record.relativeCreated)
: new Date(record.created * 1000).toLocaleTimeString()
}</span>
</div>
<div className={"flex-col flex-1 padding-sm"}>
<span className={"mono"}>{normalizedName}</span>
<span>{record.message}</span>
</div>
</div>;
}
/**
* Displays a scrollable list of log messages.
*
* Handles:
* - Auto-scrolling when new messages arrive.
* - Allowing users to scroll manually and disable auto-scroll.
* - A floating "Scroll to bottom" button when not at the bottom.
*
* @param recordCells - Array of reactive log records to display.
* @param MessageComponent - A component to use to render each log message entry.
* @returns A scrollable log list component.
*/
export function LogMessages({
recordCells,
MessageComponent,
}: {
recordCells: Cell<LogRecord>[],
MessageComponent: ComponentType<MessageComponentProps>,
}) {
const scrollableRef = useRef<HTMLDivElement>(null);
const [scrollToBottom, setScrollToBottom] = useState(true);
// Disable auto-scroll if the user manually scrolls.
useEffect(() => {
if (!scrollableRef.current) return;
const currentScrollableRef = scrollableRef.current;
const handleScroll = () => setScrollToBottom(false);
currentScrollableRef.addEventListener("wheel", handleScroll);
currentScrollableRef.addEventListener("touchmove", handleScroll);
return () => {
currentScrollableRef.removeEventListener("wheel", handleScroll);
currentScrollableRef.removeEventListener("touchmove", handleScroll);
}
}, [scrollableRef, setScrollToBottom]);
/**
* Scrolls the log messages to the bottom, making the latest messages visible.
*
* @param force - If true, forces scrolling even if `scrollToBottom` is false.
*/
function showBottom(force = false) {
if ((!scrollToBottom && !force) || !scrollableRef.current) return;
scrollableRef.current.scrollTo({top: scrollableRef.current.scrollHeight, left: 0, behavior: "smooth"});
}
return <div ref={scrollableRef} className={"min-height-0 scroll-y padding-h-lg padding-b-lg flex-1"}>
<ol className={`${styles.noNumbers} margin-0 flex-col gap-md`}>
{recordCells.map((recordCell, i) => (
<li key={`${i}_${recordCell.get().firstRelativeCreated}`}>
<MessageComponent recordCell={recordCell} onUpdate={showBottom} />
</li>
))}
</ol>
{!scrollToBottom && <button
className={styles.floatingButton}
onClick={() => {
setScrollToBottom(true);
showBottom(true);
}}
>
Scroll to bottom
</button>}
</div>;
}
/**
* Top-level logging panel component.
*
* Combines:
* - The `Filters` component for adjusting log visibility.
* - The `LogMessages` component for displaying filtered logs.
* - Zustand-managed UI settings (auto-scroll, timestamp display).
*
* This component uses the `useLogs` hook to fetch and filter logs based on
* active predicates and re-renders automatically as new logs arrive.
*
* @returns The complete logging UI as a React element.
*/
export default function Logging() {
// By default, filter experiment logs from this debug logger
const [filterPredicates, setFilterPredicates] = useState(new Map<string, LogFilterPredicate>([
[
EXPERIMENT_FILTER_KEY,
{
predicate: (r) => r.name == EXPERIMENT_LOGGER_NAME ? false : null,
priority: 999,
value: null,
} as LogFilterPredicate,
],
]));
const { filteredLogs, distinctNames } = useLogs(filterPredicates)
distinctNames.delete(EXPERIMENT_LOGGER_NAME);
return <div className={`flex-col min-height-0 relative ${styles.loggingContainer}`}>
<div className={"flex-row gap-lg justify-between align-center padding-lg"}>
<h2 className={"margin-0"}>Logs</h2>
<Filters
filterPredicates={filterPredicates}
setFilterPredicates={setFilterPredicates}
agentNames={distinctNames}
/>
</div>
<LogMessages recordCells={filteredLogs} MessageComponent={LogMessage} />
</div>;
}