Add logging with filters
This commit is contained in:
committed by
Gerla, J. (Justin)
parent
b7eb0cb5ec
commit
231d7a5ba1
34
src/components/Logging/Filters.module.css
Normal file
34
src/components/Logging/Filters.module.css
Normal file
@@ -0,0 +1,34 @@
|
||||
.filter-root {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .25rem;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
background: canvas;
|
||||
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5);
|
||||
width: 300px;
|
||||
|
||||
*:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
*:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
button.deletable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
200
src/components/Logging/Filters.tsx
Normal file
200
src/components/Logging/Filters.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
|
||||
import type {LogFilterPredicate} from "./useLogs.ts";
|
||||
|
||||
import styles from "./Filters.module.css";
|
||||
|
||||
type Setter<T> = (value: T | ((prev: T) => T)) => void;
|
||||
|
||||
const optionMapping = new Map([
|
||||
["ALL", 0],
|
||||
["DEBUG", 10],
|
||||
["INFO", 20],
|
||||
["WARNING", 30],
|
||||
["ERROR", 40],
|
||||
["CRITICAL", 50],
|
||||
["NONE", 999_999_999_999], // It is technically possible to have a higher level, but this is fine
|
||||
]);
|
||||
|
||||
function LevelPredicateElement({
|
||||
name,
|
||||
level,
|
||||
setLevel,
|
||||
onDelete,
|
||||
}: {
|
||||
name: string;
|
||||
level: string;
|
||||
setLevel: (level: string) => void;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
const normalizedName = name.split(".").pop() || name;
|
||||
|
||||
return <div className={"flex-row gap-sm align-center"}>
|
||||
<label
|
||||
htmlFor={`log_level_${name}`}
|
||||
className={"font-small"}
|
||||
>
|
||||
{onDelete
|
||||
? <button
|
||||
className={`no-button ${styles.deletable}`}
|
||||
onClick={onDelete}
|
||||
>{normalizedName}:</button>
|
||||
: normalizedName + ':'
|
||||
}
|
||||
</label>
|
||||
<select
|
||||
id={`log_level_${name}`}
|
||||
value={level}
|
||||
onChange={(e) => setLevel(e.target.value)}
|
||||
>
|
||||
{Array.from(optionMapping.keys()).map((key) => (
|
||||
<option key={key} value={key}>{key}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
|
||||
const GLOBAL_LOG_LEVEL_PREDICATE_KEY = "global_log_level";
|
||||
|
||||
function GlobalLevelFilter({
|
||||
filterPredicates,
|
||||
setFilterPredicates,
|
||||
}: {
|
||||
filterPredicates: Map<string, LogFilterPredicate>;
|
||||
setFilterPredicates: Setter<Map<string, LogFilterPredicate>>;
|
||||
}) {
|
||||
const selected = filterPredicates.get(GLOBAL_LOG_LEVEL_PREDICATE_KEY)?.value || "ALL";
|
||||
const setSelected = (selected: string | null) => {
|
||||
if (!selected || !optionMapping.has(selected)) return;
|
||||
|
||||
setFilterPredicates((curr) => {
|
||||
const next = new Map(curr);
|
||||
next.set(GLOBAL_LOG_LEVEL_PREDICATE_KEY, {
|
||||
predicate: (record) => record.levelno >= optionMapping.get(selected)!,
|
||||
priority: 0,
|
||||
value: selected,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (filterPredicates.has(GLOBAL_LOG_LEVEL_PREDICATE_KEY)) return;
|
||||
setSelected("INFO");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Run only once when the component mounts, not when anything changes
|
||||
|
||||
return <LevelPredicateElement
|
||||
name={"Global"}
|
||||
level={selected}
|
||||
setLevel={setSelected}
|
||||
/>;
|
||||
}
|
||||
|
||||
const AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX = "agent_log_level_";
|
||||
|
||||
function AgentLevelFilters({
|
||||
filterPredicates,
|
||||
setFilterPredicates,
|
||||
agentNames,
|
||||
}: {
|
||||
filterPredicates: Map<string, LogFilterPredicate>;
|
||||
setFilterPredicates: Setter<Map<string, LogFilterPredicate>>;
|
||||
agentNames: Set<string>;
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Click outside to close
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
setOpen(false);
|
||||
e.preventDefault(); // Don't exit fullscreen mode
|
||||
};
|
||||
document.addEventListener("mousedown", onDocClick);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDocClick);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const agentPredicates = [...filterPredicates.keys()].filter((key) =>
|
||||
key.startsWith(AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX));
|
||||
|
||||
/**
|
||||
* Create or change the predicate for an agent. If the level is not given, the global level is used.
|
||||
* @param agentName The name of the agent.
|
||||
* @param level The level to filter by. If not given, the global level is used.
|
||||
*/
|
||||
const setAgentPredicate = (agentName: string, level?: string ) => {
|
||||
level = level ?? filterPredicates.get(GLOBAL_LOG_LEVEL_PREDICATE_KEY)?.value ?? "ALL";
|
||||
setFilterPredicates((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX + agentName, {
|
||||
predicate: (record) => record.name === agentName
|
||||
? record.levelno >= optionMapping.get(level!)!
|
||||
: null,
|
||||
priority: 1,
|
||||
value: {agentName, level},
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const deleteAgentPredicate = (agentName: string) => {
|
||||
setFilterPredicates((curr) => {
|
||||
const fullName = AGENT_LOG_LEVEL_PREDICATE_KEY_PREFIX + agentName;
|
||||
if (!curr.has(fullName)) return curr; // Return unchanged, no re-render
|
||||
const next = new Map(curr);
|
||||
next.delete(fullName);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return <>
|
||||
{agentPredicates.map((key) => {
|
||||
const {agentName, level} = filterPredicates.get(key)!.value;
|
||||
|
||||
return <LevelPredicateElement
|
||||
key={key}
|
||||
name={agentName}
|
||||
level={level}
|
||||
setLevel={(level) => setAgentPredicate(agentName, level)}
|
||||
onDelete={() => deleteAgentPredicate(agentName)}
|
||||
/>;
|
||||
})}
|
||||
<div className={"flex-row gap-sm align-center"}>
|
||||
<label htmlFor={"add_agent"} className={"font-small"}>Add:</label>
|
||||
<select
|
||||
id={"add_agent"}
|
||||
value={""}
|
||||
onChange={(e) => !!e.target.value && setAgentPredicate(e.target.value)}
|
||||
>
|
||||
{["", ...agentNames.keys()].map((key) => (
|
||||
<option key={key} value={key}>{key.split(".").pop()}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
export default function Filters({
|
||||
filterPredicates,
|
||||
setFilterPredicates,
|
||||
agentNames,
|
||||
}: {
|
||||
filterPredicates: Map<string, LogFilterPredicate>;
|
||||
setFilterPredicates: Setter<Map<string, LogFilterPredicate>>;
|
||||
agentNames: Set<string>;
|
||||
}) {
|
||||
return <div className={"flex-1 flex-row flex-wrap gap-md align-center"}>
|
||||
<GlobalLevelFilter filterPredicates={filterPredicates} setFilterPredicates={setFilterPredicates} />
|
||||
<AgentLevelFilters filterPredicates={filterPredicates} setFilterPredicates={setFilterPredicates} agentNames={agentNames} />
|
||||
</div>;
|
||||
}
|
||||
39
src/components/Logging/Logging.module.css
Normal file
39
src/components/Logging/Logging.module.css
Normal file
@@ -0,0 +1,39 @@
|
||||
.logging-container {
|
||||
box-sizing: border-box;
|
||||
|
||||
width: max(30dvw, 500px);
|
||||
flex-shrink: 0;
|
||||
|
||||
box-shadow: 0 0 1rem black;
|
||||
padding: 1rem 1rem 0 1rem;
|
||||
}
|
||||
|
||||
.no-numbers {
|
||||
list-style-type: none;
|
||||
counter-reset: none;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
margin-bottom: .5rem;
|
||||
|
||||
.accented-0, .accented-10 {
|
||||
background-color: color-mix(in oklab, canvas, rgb(159, 159, 159) 35%)
|
||||
}
|
||||
.accented-20 {
|
||||
background-color: color-mix(in oklab, canvas, green 35%)
|
||||
}
|
||||
.accented-30 {
|
||||
background-color: color-mix(in oklab, canvas, yellow 35%)
|
||||
}
|
||||
.accented-40, .accented-50 {
|
||||
background-color: color-mix(in oklab, canvas, red 35%)
|
||||
}
|
||||
}
|
||||
|
||||
.floating-button {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
129
src/components/Logging/Logging.tsx
Normal file
129
src/components/Logging/Logging.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import {create} from "zustand";
|
||||
|
||||
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";
|
||||
|
||||
type LoggingSettings = {
|
||||
showRelativeTime: boolean;
|
||||
setShowRelativeTime: (showRelativeTime: boolean) => void;
|
||||
scrollToBottom: boolean;
|
||||
setScrollToBottom: (scrollToBottom: boolean) => void;
|
||||
};
|
||||
|
||||
const useLoggingSettings = create<LoggingSettings>((set) => ({
|
||||
showRelativeTime: false,
|
||||
setShowRelativeTime: (showRelativeTime: boolean) => set({ showRelativeTime }),
|
||||
scrollToBottom: true,
|
||||
setScrollToBottom: (scrollToBottom: boolean) => set({ scrollToBottom }),
|
||||
}));
|
||||
|
||||
function LogMessage({
|
||||
recordCell,
|
||||
onUpdate,
|
||||
}: {
|
||||
recordCell: Cell<LogRecord>,
|
||||
onUpdate?: () => void,
|
||||
}) {
|
||||
const { showRelativeTime, setShowRelativeTime } = useLoggingSettings();
|
||||
const record = useCell(recordCell);
|
||||
|
||||
/**
|
||||
* Normalizes the log level number to a multiple of 10, for which there are CSS styles.
|
||||
*/
|
||||
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;
|
||||
})();
|
||||
|
||||
const normalizedName = record.name.split(".").pop() || record.name;
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
function LogMessages({ recordCells }: { recordCells: Cell<LogRecord>[] }) {
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
const lastElementRef = useRef<HTMLLIElement>(null)
|
||||
const { scrollToBottom, setScrollToBottom } = useLoggingSettings();
|
||||
|
||||
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]);
|
||||
|
||||
function scrollLastElementIntoView(force = false) {
|
||||
if ((!scrollToBottom && !force) || !lastElementRef.current) return;
|
||||
lastElementRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
|
||||
return <div ref={scrollableRef} className={"min-height-0 scroll-y padding-b-md"}>
|
||||
<ol className={`${styles.noNumbers} margin-0 flex-col gap-md`}>
|
||||
{recordCells.map((recordCell, i) => (
|
||||
<li key={`${i}_${recordCell.get().firstRelativeCreated}`}>
|
||||
<LogMessage recordCell={recordCell} onUpdate={scrollLastElementIntoView} />
|
||||
</li>
|
||||
))}
|
||||
<li ref={lastElementRef}></li>
|
||||
</ol>
|
||||
{!scrollToBottom && <button
|
||||
className={styles.floatingButton}
|
||||
onClick={() => {
|
||||
setScrollToBottom(true);
|
||||
scrollLastElementIntoView(true);
|
||||
}}
|
||||
>
|
||||
Scroll to bottom
|
||||
</button>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function Logging() {
|
||||
const [filterPredicates, setFilterPredicates] = useState(new Map<string, LogFilterPredicate>());
|
||||
const { filteredLogs, distinctNames } = useLogs(filterPredicates)
|
||||
|
||||
return <div className={`flex-col gap-lg min-height-0 ${styles.loggingContainer}`}>
|
||||
<div className={"flex-row gap-lg justify-between align-center"}>
|
||||
<h2 className={"margin-0"}>Logs</h2>
|
||||
<Filters
|
||||
filterPredicates={filterPredicates}
|
||||
setFilterPredicates={setFilterPredicates}
|
||||
agentNames={distinctNames}
|
||||
/>
|
||||
</div>
|
||||
<LogMessages recordCells={filteredLogs} />
|
||||
</div>;
|
||||
}
|
||||
146
src/components/Logging/useLogs.ts
Normal file
146
src/components/Logging/useLogs.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import {useCallback, useEffect, useRef, useState} from "react";
|
||||
|
||||
import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../utils/priorityFiltering.ts";
|
||||
import {cell, type Cell} from "../../utils/cellStore.ts";
|
||||
|
||||
export type LogRecord = {
|
||||
name: string;
|
||||
message: string;
|
||||
levelname: 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | string;
|
||||
levelno: number;
|
||||
created: number;
|
||||
relativeCreated: number;
|
||||
reference?: string;
|
||||
firstCreated: number;
|
||||
firstRelativeCreated: number;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type LogFilterPredicate = PriorityFilterPredicate<LogRecord> & { value: any };
|
||||
|
||||
export function useLogs(filterPredicates: Map<string, LogFilterPredicate>) {
|
||||
const [distinctNames, setDistinctNames] = useState<Set<string>>(new Set());
|
||||
const [filtered, setFiltered] = useState<Cell<LogRecord>[]>([]);
|
||||
|
||||
const sseRef = useRef<EventSource | null>(null);
|
||||
const filtersRef = useRef(filterPredicates);
|
||||
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 the filter predicates to a log record.
|
||||
* @param log The log record to apply the filters to.
|
||||
* @returns `true` if the record passes.
|
||||
*/
|
||||
const applyFilters = useCallback((log: LogRecord) =>
|
||||
applyPriorityPredicates(log, [...filtersRef.current.values()]), []);
|
||||
|
||||
/** Recomputes the entire filtered list. Use when filter predicates change. */
|
||||
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,
|
||||
});
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
// Don't add it to the list again
|
||||
continue;
|
||||
} else {
|
||||
// Add the first message with this reference to the registry
|
||||
firstByRefRef.current.set(message.reference, messageCell);
|
||||
}
|
||||
}
|
||||
|
||||
if (applyFilters(message)) {
|
||||
newFiltered.push(messageCell);
|
||||
}
|
||||
}
|
||||
|
||||
setFiltered(newFiltered);
|
||||
}, [applyFilters, setFiltered]);
|
||||
|
||||
// Reapply filters to all logs, only when filters change
|
||||
useEffect(() => {
|
||||
filtersRef.current = filterPredicates;
|
||||
recomputeFiltered();
|
||||
}, [filterPredicates, recomputeFiltered]);
|
||||
|
||||
/**
|
||||
* Handle a new log message. Updates the filtered list and to the full history.
|
||||
* @param message The new log message.
|
||||
*/
|
||||
const handleNewMessage = useCallback((message: LogRecord) => {
|
||||
// Add to the full history for re-filtering on filter changes
|
||||
logsRef.current.push(message);
|
||||
|
||||
setDistinctNames((prev) => {
|
||||
if (prev.has(message.name)) return prev;
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(message.name);
|
||||
return newSet;
|
||||
});
|
||||
|
||||
const messageCell = cell<LogRecord>({
|
||||
...message,
|
||||
firstCreated: message.created,
|
||||
firstRelativeCreated: message.relativeCreated,
|
||||
});
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
// Don't add it to the list again
|
||||
return;
|
||||
} else {
|
||||
// Add the first message with this reference to the registry
|
||||
firstByRefRef.current.set(message.reference, messageCell);
|
||||
}
|
||||
}
|
||||
|
||||
if (applyFilters(message)) {
|
||||
setFiltered((curr) => [...curr, messageCell]);
|
||||
}
|
||||
}, [applyFilters, setFiltered]);
|
||||
|
||||
useEffect(() => {
|
||||
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};
|
||||
}
|
||||
14
src/components/ScrollIntoView.tsx
Normal file
14
src/components/ScrollIntoView.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import {useEffect, useRef} from "react";
|
||||
|
||||
/**
|
||||
* An element that always scrolls into view when it is rendered. When added to a list, the entire list will scroll to show this element.
|
||||
*/
|
||||
export default function ScrollIntoView() {
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (elementRef.current) elementRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
});
|
||||
|
||||
return <div ref={elementRef} />;
|
||||
}
|
||||
Reference in New Issue
Block a user