Add logging with filters

This commit is contained in:
Twirre
2025-11-12 14:35:38 +00:00
committed by Gerla, J. (Justin)
parent b7eb0cb5ec
commit 231d7a5ba1
22 changed files with 1899 additions and 68 deletions

View File

@@ -1,23 +1,38 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
import js from "@eslint/js"
import globals from "globals"
import reactHooks from "eslint-plugin-react-hooks"
import reactRefresh from "eslint-plugin-react-refresh"
import tseslint from "typescript-eslint"
import { defineConfig, globalIgnores } from "eslint/config"
export default defineConfig([
globalIgnores(['dist']),
globalIgnores(["dist"]),
{
files: ['**/*.{ts,tsx}'],
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactHooks.configs["recommended-latest"],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
],
},
},
{
files: ["test/**/*.{ts,tsx}"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
},
},
])

View File

@@ -82,6 +82,10 @@ button.movePage:hover{
}
#root {
display: flex;
flex-direction: column;
}
header {
position: sticky;
@@ -96,6 +100,7 @@ header {
align-items: center;
justify-content: center;
background-color: var(--accent-color);
backdrop-filter: blur(10px);
z-index: 1; /* Otherwise any translated elements render above the blur?? */
}
@@ -121,6 +126,14 @@ main {
flex-wrap: wrap;
}
.min-height-0 {
min-height: 0;
}
.scroll-y {
overflow-y: scroll;
}
.align-center {
align-items: center;
}
@@ -141,6 +154,10 @@ main {
gap: 1rem;
}
.margin-0 {
margin: 0;
}
.padding-sm {
padding: .25rem;
}
@@ -150,7 +167,19 @@ main {
.padding-lg {
padding: 1rem;
}
.padding-b-sm {
padding-bottom: .25rem;
}
.padding-b-md {
padding-bottom: .5rem;
}
.padding-b-lg {
padding-bottom: 1rem;
}
.round-sm, .round-md, .round-lg {
overflow: hidden;
}
.round-sm {
border-radius: .25rem;
}
@@ -160,3 +189,58 @@ main {
.round-lg {
border-radius: 1rem;
}
.border-sm {
border: 1px solid canvastext;
}
.border-md {
border: 2px solid canvastext;
}
.border-lg {
border: 3px solid canvastext;
}
.font-small {
font-size: .75rem;
}
.font-medium {
font-size: 1rem;
}
.font-large {
font-size: 1.25rem;
}
.mono {
font-family: ui-monospace, monospace;
}
.bold {
font-weight: bold;
}
.clickable {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.user-select-all {
-webkit-user-select: all;
user-select: all;
}
.user-select-none {
-webkit-user-select: none;
user-select: none;
}
button.no-button {
background: none;
border: none;
padding: 0;
cursor: pointer;
color: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}

View File

@@ -4,23 +4,31 @@ import TemplatePage from './pages/TemplatePage/Template.tsx'
import Home from './pages/Home/Home.tsx'
import Robot from './pages/Robot/Robot.tsx';
import VisProg from "./pages/VisProgPage/VisProg.tsx";
import {useState} from "react";
import Logging from "./components/Logging/Logging.tsx";
function App(){
const [showLogs, setShowLogs] = useState(false);
return (
<div>
<>
<header>
<Link to={"/"}>Home</Link>
<button onClick={() => setShowLogs(!showLogs)}>Toggle Logging</button>
</header>
<main className={"flex-col align-center"}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/template" element={<TemplatePage />} />
<Route path="/editor" element={<VisProg />} />
<Route path="/robot" element={<Robot />} />
</Routes>
</main>
</div>
)
<div className={"flex-row justify-center flex-1 min-height-0"}>
<main className={"flex-col align-center flex-1 scroll-y"}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/template" element={<TemplatePage />} />
<Route path="/editor" element={<VisProg />} />
<Route path="/robot" element={<Robot />} />
</Routes>
</main>
{showLogs && <Logging />}
</div>
</>
);
}
export default App

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

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

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

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

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

View 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} />;
}

View File

@@ -7,13 +7,15 @@
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
--accent-color: #008080;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html, body {
html, body, #root {
margin: 0;
padding: 0;
@@ -25,11 +27,7 @@ html, body {
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
color: canvastext;
}
h1 {
@@ -49,7 +47,7 @@ button {
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
border-color: var(--accent-color);
}
button:focus,
button:focus-visible {
@@ -60,9 +58,8 @@ button:focus-visible {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
--accent-color: #00AAAA;
}
button {
background-color: #f9f9f9;

View File

@@ -1,19 +1,9 @@
/* editor UI */
.outer-editor-container {
margin-inline: auto;
display: flex;
justify-self: center;
padding: 10px;
align-items: center;
width: 80vw;
height: 80vh;
}
.inner-editor-container {
outline-style: solid;
border-radius: 10pt;
width: 90%;
box-sizing: border-box;
margin: 1rem;
width: calc(100% - 2rem);
height: 100%;
}

View File

@@ -80,30 +80,28 @@ const VisProgUI = () => {
} = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore
return (
<div className={styles.outerEditorContainer}>
<div className={styles.innerEditorContainer}>
<ReactFlow
nodes={nodes}
edges={edges}
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
nodeTypes={NODE_TYPES}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onReconnect={onReconnect}
onReconnectStart={onReconnectStart}
onReconnectEnd={onReconnectEnd}
onConnect={onConnect}
snapToGrid
fitView
proOptions={{hideAttribution: true}}
>
<Panel position="top-center" className={styles.dndPanel}>
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
</Panel>
<Controls/>
<Background/>
</ReactFlow>
</div>
<div className={`${styles.innerEditorContainer} round-lg border-lg`}>
<ReactFlow
nodes={nodes}
edges={edges}
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
nodeTypes={NODE_TYPES}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onReconnect={onReconnect}
onReconnectStart={onReconnectStart}
onReconnectEnd={onReconnectEnd}
onConnect={onConnect}
snapToGrid
fitView
proOptions={{hideAttribution: true}}
>
<Panel position="top-center" className={styles.dndPanel}>
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
</Panel>
<Controls/>
<Background/>
</ReactFlow>
</div>
);
};

29
src/utils/cellStore.ts Normal file
View File

@@ -0,0 +1,29 @@
import {useSyncExternalStore} from "react";
type Unsub = () => void;
export type Cell<T> = {
get: () => T;
set: (next: T | ((prev: T) => T)) => void;
subscribe: (callback: () => void) => Unsub;
};
export function cell<T>(initial: T): Cell<T> {
let value = initial;
const listeners = new Set<() => void>();
return {
get: () => value,
set: (next) => {
value = typeof next === "function" ? (next as (v: T) => T)(value) : next;
for (const l of listeners) l();
},
subscribe: (callback) => {
listeners.add(callback);
return () => listeners.delete(callback);
},
};
}
export function useCell<T>(c: Cell<T>) {
return useSyncExternalStore(c.subscribe, c.get, c.get);
}

View File

@@ -0,0 +1,21 @@
/**
* Format a time duration like `HH:MM:SS.mmm`.
*
* @param durationMs time duration in milliseconds.
* @return formatted time string.
*/
export default function formatDuration(durationMs: number): string {
const isNegative = durationMs < 0;
if (isNegative) durationMs = -durationMs;
const hours = Math.floor(durationMs / 3600000);
const minutes = Math.floor((durationMs % 3600000) / 60000);
const seconds = Math.floor((durationMs % 60000) / 1000);
const milliseconds = Math.floor(durationMs % 1000);
return (isNegative ? '-' : '') +
`${hours.toString().padStart(2, '0')}:` +
`${minutes.toString().padStart(2, '0')}:` +
`${seconds.toString().padStart(2, '0')}.` +
`${milliseconds.toString().padStart(3, '0')}`;
}

View File

@@ -0,0 +1,24 @@
export type PriorityFilterPredicate<T> = {
priority: number;
predicate: (element: T) => boolean | null; // The predicate and its priority are ignored if it returns null.
}
/**
* Applies a list of priority predicates to an element. For all predicates that don't return null, if the ones with the highest level return true, then this function returns true.
* @param element The element to apply the predicates to.
* @param predicates The list of predicates to apply.
*/
export function applyPriorityPredicates<T>(element: T, predicates: PriorityFilterPredicate<T>[]): boolean {
let highestPriority = -1;
let highestKeep = true;
for (const predicate of predicates) {
if (predicate.priority >= highestPriority) {
const predicateKeep = predicate.predicate(element);
if (predicateKeep === null) continue; // This predicate doesn't care about the element, so skip it
if (predicate.priority > highestPriority) highestKeep = true;
highestPriority = predicate.priority;
highestKeep = highestKeep && predicateKeep;
}
}
return highestKeep;
}

View File

@@ -0,0 +1,328 @@
import {render, screen, waitFor, fireEvent} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import * as React from "react";
type ControlledUseState = typeof React.useState & {
__forceNextReturn?: (value: any) => jest.Mock;
__resetMockState?: () => void;
};
jest.mock("react", () => {
const actual = jest.requireActual("react");
const queue: Array<{value: any; setter: jest.Mock}> = [];
const mockUseState = ((initial: any) => {
if (queue.length) {
const {value, setter} = queue.shift()!;
return [value, setter];
}
return actual.useState(initial);
}) as ControlledUseState;
mockUseState.__forceNextReturn = (value: any) => {
const setter = jest.fn();
queue.push({value, setter});
return setter;
};
mockUseState.__resetMockState = () => {
queue.length = 0;
};
return {
__esModule: true,
...actual,
useState: mockUseState,
};
});
import Filters from "../../../src/components/Logging/Filters.tsx";
import type {LogFilterPredicate, LogRecord} from "../../../src/components/Logging/useLogs.ts";
const GLOBAL = "global_log_level";
const AGENT_PREFIX = "agent_log_level_";
const optionMapping = new Map([
["ALL", 0],
["DEBUG", 10],
["INFO", 20],
["WARNING", 30],
["ERROR", 40],
["CRITICAL", 50],
["NONE", 999_999_999_999],
]);
const controlledUseState = React.useState as ControlledUseState;
afterEach(() => {
controlledUseState.__resetMockState?.();
});
function getCallArg<T>(mock: jest.Mock, index = 0): T {
return mock.mock.calls[index][0] as T;
}
function sampleRecord(levelno: number, name = "any.logger"): LogRecord {
return {
levelname: "UNKNOWN",
levelno,
name,
message: "Whatever",
created: 0,
relativeCreated: 0,
firstCreated: 0,
firstRelativeCreated: 0,
};
}
// --------------------------------------------------------------------------
describe("Filters", () => {
describe("Global level filter", () => {
it("initializes to INFO when missing", async () => {
const setFilterPredicates = jest.fn();
const filterPredicates = new Map<string, LogFilterPredicate>();
const view = render(
<Filters
filterPredicates={filterPredicates}
setFilterPredicates={setFilterPredicates}
agentNames={new Set<string>()}
/>
);
// Effect sets default to INFO
await waitFor(() => {
expect(setFilterPredicates).toHaveBeenCalled();
});
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(setFilterPredicates);
const newMap = updater(filterPredicates);
const global = newMap.get(GLOBAL)!;
expect(global.value).toBe("INFO");
expect(global.priority).toBe(0);
// Predicate gate at INFO (>= 20)
expect(global.predicate(sampleRecord(10))).toBe(false);
expect(global.predicate(sampleRecord(20))).toBe(true);
// UI shows INFO selected after parent state updates
view.rerender(
<Filters
filterPredicates={newMap}
setFilterPredicates={setFilterPredicates}
agentNames={new Set<string>()}
/>
);
const globalSelect = screen.getByLabelText("Global:");
expect((globalSelect as HTMLSelectElement).value).toBe("INFO");
});
it("updates predicate when selecting a higher level", async () => {
// Start with INFO already present
const existing = new Map<string, LogFilterPredicate>([
[
GLOBAL,
{
value: "INFO",
priority: 0,
predicate: (r: any) => r.levelno >= optionMapping.get("INFO")!
}
]
]);
const setFilterPredicates = jest.fn();
const user = userEvent.setup();
render(
<Filters
filterPredicates={existing}
setFilterPredicates={setFilterPredicates}
agentNames={new Set<string>()}
/>
);
const select = screen.getByLabelText("Global:");
await user.selectOptions(select, "ERROR");
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(setFilterPredicates);
const updated = updater(existing);
const global = updated.get(GLOBAL)!;
expect(global.value).toBe("ERROR");
expect(global.priority).toBe(0);
expect(global.predicate(sampleRecord(30))).toBe(false);
expect(global.predicate(sampleRecord(40))).toBe(true);
});
});
describe("Agent level filters", () => {
it("adds an agent using the current global level when none specified", async () => {
// Global set to WARNING
const existing = new Map<string, LogFilterPredicate>([
[
GLOBAL,
{
value: "WARNING",
priority: 0,
predicate: (r: any) => r.levelno >= optionMapping.get("WARNING")!
}
]
]);
const setFilterPredicates = jest.fn();
const user = userEvent.setup();
render(
<Filters
filterPredicates={existing}
setFilterPredicates={setFilterPredicates}
agentNames={new Set<string>(["pepper.speech", "vision.agent"])}
/>
);
const addSelect = screen.getByLabelText("Add:");
await user.selectOptions(addSelect, "pepper.speech");
// Agent setter is functional: prev => next
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(setFilterPredicates);
const next = updater(existing);
const key = AGENT_PREFIX + "pepper.speech";
const agentPred = next.get(key)!;
expect(agentPred.priority).toBe(1);
expect(agentPred.value).toEqual({agentName: "pepper.speech", level: "WARNING"});
// When agentName matches, enforce WARNING (>= 30)
expect(agentPred.predicate(sampleRecord(20, "pepper.speech"))).toBe(false);
expect(agentPred.predicate(sampleRecord(30, "pepper.speech"))).toBe(true);
// Other agents -> null
expect(agentPred.predicate(sampleRecord(999, "other"))).toBeNull();
});
it("changes an agent's level when its select is updated", async () => {
// Prepopulate agent predicate at WARNING
const key = AGENT_PREFIX + "pepper.speech";
const existing = new Map<string, LogFilterPredicate>([
[
GLOBAL,
{
value: "INFO",
priority: 0,
predicate: (r: any) => r.levelno >= optionMapping.get("INFO")!
}
],
[
key,
{
value: {agentName: "pepper.speech", level: "WARNING"},
priority: 1,
predicate: (r: any) => (r.name === "pepper.speech" ? r.levelno >= optionMapping.get("WARNING")! : null)
}
]
]);
const setFilterPredicates = jest.fn();
const user = userEvent.setup();
const element = render(
<Filters
filterPredicates={existing}
setFilterPredicates={setFilterPredicates}
agentNames={new Set(["pepper.speech"])}
/>
);
const agentSelect = element.container.querySelector("select#log_level_pepper\\.speech")!;
await user.selectOptions(agentSelect, "ERROR");
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(setFilterPredicates);
const next = updater(existing);
const updated = next.get(key)!;
expect(updated.value).toEqual({agentName: "pepper.speech", level: "ERROR"});
// Threshold moved to ERROR (>= 40)
expect(updated.predicate(sampleRecord(30, "pepper.speech"))).toBe(false);
expect(updated.predicate(sampleRecord(40, "pepper.speech"))).toBe(true);
});
it("deletes an agent predicate when clicking its name button", async () => {
const key = AGENT_PREFIX + "pepper.speech";
const existing = new Map<string, LogFilterPredicate>([
[
GLOBAL,
{
value: "INFO",
priority: 0,
predicate: (r: any) => r.levelno >= optionMapping.get("INFO")!
}
],
[
key,
{
value: {agentName: "pepper.speech", level: "INFO"},
priority: 1,
predicate: (r: any) => (r.name === "pepper.speech" ? r.levelno >= optionMapping.get("INFO")! : null)
}
]
]);
const setFilterPredicates = jest.fn();
const user = userEvent.setup();
render(
<Filters
filterPredicates={existing}
setFilterPredicates={setFilterPredicates}
agentNames={new Set<string>(["pepper.speech"])}
/>
);
const deleteBtn = screen.getByRole("button", {name: "speech:"});
await user.click(deleteBtn);
const updater = getCallArg<(prev: Map<string, LogFilterPredicate>) => Map<string, LogFilterPredicate>>(setFilterPredicates);
const next = updater(existing);
expect(next.has(key)).toBe(false);
});
});
describe("Filter popup behavior", () => {
function renderWithPopupOpen() {
const existing = new Map<string, LogFilterPredicate>([
[
GLOBAL,
{
value: "INFO",
priority: 0,
predicate: (r: any) => r.levelno >= optionMapping.get("INFO")!
}
]
]);
const setFilterPredicates = jest.fn();
const forceNext = controlledUseState.__forceNextReturn;
if (!forceNext) throw new Error("useState mock missing helper");
const setOpen = forceNext(true);
render(
<Filters
filterPredicates={existing}
setFilterPredicates={setFilterPredicates}
agentNames={new Set(["pepper.vision"])}
/>
);
return { setOpen };
}
it("closes the popup when clicking outside", () => {
const { setOpen } = renderWithPopupOpen();
fireEvent.mouseDown(document.body);
expect(setOpen).toHaveBeenCalledWith(false);
});
it("closes the popup when pressing Escape", () => {
const { setOpen } = renderWithPopupOpen();
fireEvent.keyDown(document, { key: "Escape" });
expect(setOpen).toHaveBeenCalledWith(false);
});
});
});

View File

@@ -0,0 +1,239 @@
import {render, screen, fireEvent, act, waitFor} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import type {Cell} from "../../../src/utils/cellStore.ts";
import {cell} from "../../../src/utils/cellStore.ts";
import type {LogFilterPredicate, LogRecord} from "../../../src/components/Logging/useLogs.ts";
const mockFiltersRender = jest.fn();
const loggingStoreRef: { current: null | { setState: (state: Partial<LoggingSettingsState>) => void } } = { current: null };
type LoggingSettingsState = {
showRelativeTime: boolean;
setShowRelativeTime: (show: boolean) => void;
scrollToBottom: boolean;
setScrollToBottom: (scroll: boolean) => void;
};
jest.mock("zustand", () => {
const actual = jest.requireActual("zustand");
const actualCreate = actual.create;
return {
__esModule: true,
...actual,
create: (...args: any[]) => {
const store = actualCreate(...args);
const state = store.getState();
if ("setShowRelativeTime" in state && "setScrollToBottom" in state) {
loggingStoreRef.current = store;
}
return store;
},
};
});
jest.mock("../../../src/components/Logging/Filters.tsx", () => {
const React = jest.requireActual("react");
return {
__esModule: true,
default: (props: any) => {
mockFiltersRender(props);
return React.createElement("div", {"data-testid": "filters-mock"}, "filters");
},
};
});
jest.mock("../../../src/components/Logging/useLogs.ts", () => {
const actual = jest.requireActual("../../../src/components/Logging/useLogs.ts");
return {
__esModule: true,
...actual,
useLogs: jest.fn(),
};
});
import {useLogs} from "../../../src/components/Logging/useLogs.ts";
const mockUseLogs = useLogs as jest.MockedFunction<typeof useLogs>;
type LoggingComponent = typeof import("../../../src/components/Logging/Logging.tsx").default;
let Logging: LoggingComponent;
beforeAll(async () => {
if (!Element.prototype.scrollIntoView) {
Object.defineProperty(Element.prototype, "scrollIntoView", {
configurable: true,
writable: true,
value: function () {},
});
}
({default: Logging} = await import("../../../src/components/Logging/Logging.tsx"));
});
beforeEach(() => {
mockUseLogs.mockReset();
mockFiltersRender.mockReset();
mockUseLogs.mockReturnValue({filteredLogs: [], distinctNames: new Set()});
resetLoggingStore();
});
afterEach(() => {
jest.restoreAllMocks();
});
function resetLoggingStore() {
loggingStoreRef.current?.setState({
showRelativeTime: false,
scrollToBottom: true,
});
}
function makeRecord(overrides: Partial<LogRecord> = {}): LogRecord {
return {
name: "pepper.logger",
message: "default",
levelname: "INFO",
levelno: 20,
created: 1,
relativeCreated: 1,
firstCreated: 1,
firstRelativeCreated: 1,
...overrides,
};
}
function makeCell(overrides: Partial<LogRecord> = {}): Cell<LogRecord> {
return cell(makeRecord(overrides));
}
describe("Logging component", () => {
it("renders log messages and toggles the timestamp between absolute and relative view", async () => {
const logCell = makeCell({
name: "pepper.trace.logging",
message: "Ping",
levelname: "WARNING",
levelno: 30,
created: 1_700_000_000,
relativeCreated: 12_345,
firstCreated: 1_700_000_000,
firstRelativeCreated: 12_345,
});
const names = new Set(["pepper.trace.logging"]);
mockUseLogs.mockReturnValue({filteredLogs: [logCell], distinctNames: names});
jest.spyOn(Date.prototype, "toLocaleTimeString").mockReturnValue("ABS TIME");
const user = userEvent.setup();
render(<Logging/>);
expect(screen.getByText("Logs")).toBeInTheDocument();
expect(screen.getByText("WARNING")).toBeInTheDocument();
expect(screen.getByText("logging")).toBeInTheDocument();
expect(screen.getByText("Ping")).toBeInTheDocument();
let timestamp = screen.queryByText("ABS TIME");
if (!timestamp) {
// if previous test left the store toggled, click once to show absolute time
timestamp = screen.getByText("00:00:12.345");
await user.click(timestamp);
timestamp = screen.getByText("ABS TIME");
}
await user.click(timestamp);
expect(screen.getByText("00:00:12.345")).toBeInTheDocument();
});
it("shows the scroll-to-bottom button after a manual scroll and scrolls when clicked", async () => {
const logs = [
makeCell({message: "first", firstRelativeCreated: 1}),
makeCell({message: "second", firstRelativeCreated: 2}),
];
mockUseLogs.mockReturnValue({filteredLogs: logs, distinctNames: new Set()});
const scrollSpy = jest.spyOn(Element.prototype, "scrollIntoView").mockImplementation(() => {});
const user = userEvent.setup();
const view = render(<Logging/>);
expect(screen.queryByRole("button", {name: "Scroll to bottom"})).toBeNull();
const scrollable = view.container.querySelector(".scroll-y");
expect(scrollable).toBeTruthy();
fireEvent.wheel(scrollable!);
const button = await screen.findByRole("button", {name: "Scroll to bottom"});
await user.click(button);
expect(scrollSpy).toHaveBeenCalled();
await waitFor(() => {
expect(screen.queryByRole("button", {name: "Scroll to bottom"})).toBeNull();
});
});
it("scrolls the last element into view when a log cell updates", async () => {
const logCell = makeCell({message: "Initial", firstRelativeCreated: 42});
mockUseLogs.mockReturnValue({filteredLogs: [logCell], distinctNames: new Set()});
const scrollSpy = jest.spyOn(Element.prototype, "scrollIntoView").mockImplementation(() => {});
render(<Logging/>);
await waitFor(() => {
expect(scrollSpy).toHaveBeenCalledTimes(1);
});
scrollSpy.mockClear();
act(() => {
const current = logCell.get();
logCell.set({...current, message: "Updated"});
});
expect(screen.getByText("Updated")).toBeInTheDocument();
await waitFor(() => {
expect(scrollSpy).toHaveBeenCalledTimes(1);
});
});
it("passes filter state to Filters and re-invokes useLogs when predicates change", async () => {
const distinct = new Set(["pepper.core"]);
mockUseLogs.mockImplementation((_filters: Map<string, LogFilterPredicate>) => ({
filteredLogs: [],
distinctNames: distinct,
}));
render(<Logging/>);
expect(mockFiltersRender).toHaveBeenCalledTimes(1);
const firstProps = mockFiltersRender.mock.calls[0][0];
expect(firstProps.agentNames).toBe(distinct);
const initialMap = firstProps.filterPredicates;
expect(initialMap).toBeInstanceOf(Map);
expect(initialMap.size).toBe(0);
expect(mockUseLogs).toHaveBeenCalledWith(initialMap);
const updatedPredicate: LogFilterPredicate = {
value: "custom",
priority: 0,
predicate: () => true,
};
act(() => {
firstProps.setFilterPredicates((prev: Map<string, LogFilterPredicate>) => {
const next = new Map(prev);
next.set("custom", updatedPredicate);
return next;
});
});
await waitFor(() => {
expect(mockUseLogs).toHaveBeenCalledTimes(2);
});
const nextFilters = mockUseLogs.mock.calls[1][0];
expect(nextFilters.get("custom")).toBe(updatedPredicate);
const secondProps = mockFiltersRender.mock.calls[mockFiltersRender.mock.calls.length - 1][0];
expect(secondProps.filterPredicates).toBe(nextFilters);
});
});

View File

@@ -0,0 +1,246 @@
import { render, screen, act } from "@testing-library/react";
import "@testing-library/jest-dom";
import {type LogRecord, useLogs} from "../../../src/components/Logging/useLogs.ts";
import {type cell, useCell} from "../../../src/utils/cellStore.ts";
import { StrictMode } from "react";
jest.mock("../../../src/utils/priorityFiltering.ts", () => ({
applyPriorityPredicates: jest.fn((_log, preds: any[]) =>
preds.every(() => true) // default: pass all
),
}));
import {applyPriorityPredicates} from "../../../src/utils/priorityFiltering.ts";
class MockEventSource {
url: string;
onmessage: ((event: { data: string }) => void) | null = null;
onerror: ((event: unknown) => void) | null = null;
close = jest.fn();
constructor(url: string) {
this.url = url;
// expose the latest instance for tests:
(globalThis as any).__es = this;
}
}
beforeAll(() => {
globalThis.EventSource = MockEventSource as any;
});
afterEach(() => {
// reset mock so previous instance not reused accidentally
(globalThis as any).__es = undefined;
jest.clearAllMocks();
});
function LogsProbe({ filters }: { filters: Map<string, any> }) {
const { filteredLogs, distinctNames } = useLogs(filters);
return (
<div>
<div data-testid="names-count">{distinctNames.size}</div>
<ul data-testid="logs">
{filteredLogs.map((c, i) => (
<LogItem key={i} cell={c} index={i} />
))}
</ul>
</div>
);
}
function LogItem({ cell: c, index }: { cell: ReturnType<typeof cell<LogRecord>>; index: number }) {
const value = useCell(c);
return (
<li data-testid={`log-${index}`}>
<span data-testid={`log-${index}-name`}>{value.name}</span>
<span data-testid={`log-${index}-msg`}>{value.message}</span>
<span data-testid={`log-${index}-first`}>{String(value.firstCreated)}</span>
<span data-testid={`log-${index}-created`}>{String(value.created)}</span>
<span data-testid={`log-${index}-ref`}>{value.reference ?? ""}</span>
</li>
);
}
function emit(log: LogRecord) {
const eventSource = (globalThis as any).__es as MockEventSource;
if (!eventSource || !eventSource.onmessage) throw new Error("EventSource not initialized");
act(() => {
eventSource.onmessage!({ data: JSON.stringify(log) });
});
}
describe("useLogs (unit)", () => {
it("creates EventSource once and closes on unmount", () => {
const filters = new Map(); // allow all by default
const { unmount } = render(
<StrictMode>
<LogsProbe filters={filters} />
</StrictMode>
);
const es = (globalThis as any).__es as MockEventSource;
expect(es).toBeTruthy();
expect(es.url).toBe("http://localhost:8000/logs/stream");
unmount();
expect(es.close).toHaveBeenCalledTimes(1);
});
it("appends filtered logs and collects distinct names", () => {
const filters = new Map();
render(
<StrictMode>
<LogsProbe filters={filters} />
</StrictMode>
);
expect(screen.getByTestId("names-count")).toHaveTextContent("0");
emit({
levelname: "DEBUG",
levelno: 10,
name: "alpha",
message: "m1",
created: 1,
relativeCreated: 1,
firstCreated: 1,
firstRelativeCreated: 1,
});
emit({
levelname: "DEBUG",
levelno: 10,
name: "beta",
message: "m2",
created: 2,
relativeCreated: 2,
firstCreated: 2,
firstRelativeCreated: 2,
});
emit({
levelname: "DEBUG",
levelno: 10,
name: "alpha",
message: "m3",
created: 3,
relativeCreated: 3,
firstCreated: 3,
firstRelativeCreated: 3,
});
// 3 messages (no reference), 2 distinct names
expect(screen.getAllByRole("listitem")).toHaveLength(3);
expect(screen.getByTestId("names-count")).toHaveTextContent("2");
expect(screen.getByTestId("log-0-name")).toHaveTextContent("alpha");
expect(screen.getByTestId("log-1-name")).toHaveTextContent("beta");
expect(screen.getByTestId("log-2-name")).toHaveTextContent("alpha");
});
it("updates first message with reference when a second one with that reference comes", () => {
const filters = new Map();
render(<LogsProbe filters={filters} />);
// First message with ref r1
emit({
levelname: "DEBUG",
levelno: 10,
name: "svc",
message: "first",
reference: "r1",
created: 10,
relativeCreated: 10,
firstCreated: 10,
firstRelativeCreated: 10,
});
// Second message with same ref r1, should still be a single item
emit({
levelname: "DEBUG",
levelno: 10,
name: "svc",
message: "second",
reference: "r1",
created: 20,
relativeCreated: 20,
firstCreated: 20,
firstRelativeCreated: 20,
});
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(1);
// Same single item, but message should be "second"
expect(screen.getByTestId("log-0-msg")).toHaveTextContent("second");
// The "firstCreated" should remain the original (10), while "created" is now 20
expect(screen.getByTestId("log-0-first")).toHaveTextContent("10");
expect(screen.getByTestId("log-0-created")).toHaveTextContent("20");
expect(screen.getByTestId("log-0-ref")).toHaveTextContent("r1");
});
it("runs recomputeFiltered when filters change", () => {
const allowAll = new Map<string, any>();
const { rerender } = render(<LogsProbe filters={allowAll} />);
emit({
levelname: "DEBUG",
levelno: 10,
name: "n1",
message: "ok",
created: 1,
relativeCreated: 1,
firstCreated: 1,
firstRelativeCreated: 1,
});
emit({
levelname: "DEBUG",
levelno: 10,
name: "n2",
message: "ok",
created: 2,
relativeCreated: 2,
firstCreated: 2,
firstRelativeCreated: 2,
});
emit({
levelname: "INFO",
levelno: 20,
name: "n3",
message: "ok1",
reference: "r1",
created: 3,
relativeCreated: 3,
firstCreated: 3,
firstRelativeCreated: 3,
});
emit({
levelname: "INFO",
levelno: 20,
name: "n3",
message: "ok2",
reference: "r1",
created: 4,
relativeCreated: 4,
firstCreated: 4,
firstRelativeCreated: 4,
});
expect(screen.getAllByRole("listitem")).toHaveLength(3);
// Now change filters to block all < INFO
(applyPriorityPredicates as jest.Mock).mockImplementation((l) => l.levelno >= 20);
const blockDebug = new Map<string, any>([["dummy", { value: true }]]);
rerender(<LogsProbe filters={blockDebug} />);
// Should recompute with shorter list
expect(screen.queryAllByRole("listitem")).toHaveLength(1);
// Switch back to allow-all
(applyPriorityPredicates as jest.Mock).mockImplementation((_log, preds: any[]) =>
preds.every(() => true)
);
rerender(<LogsProbe filters={allowAll} />);
// recompute should restore all three
expect(screen.getAllByRole("listitem")).toHaveLength(3);
});
});

0
test/eslint.config.js.ts Normal file
View File

View File

@@ -0,0 +1,156 @@
import {render, screen, act} from "@testing-library/react";
import "@testing-library/jest-dom";
import {type Cell, cell, useCell} from "../../src/utils/cellStore.ts";
describe("cell store (unit)", () => {
it("returns initial value with get()", () => {
const c = cell(123);
expect(c.get()).toBe(123);
});
it("updates value with set(next)", () => {
const c = cell("a");
c.set("b");
expect(c.get()).toBe("b");
});
it("gives previous value in set(updater)", () => {
const c = cell(1);
c.set((prev) => prev + 2);
expect(c.get()).toBe(3);
});
it("calls subscribe callback on set", () => {
const c = cell(0);
const cb = jest.fn();
const unsub = c.subscribe(cb);
c.set(1);
c.set(2);
expect(cb).toHaveBeenCalledTimes(2);
unsub();
});
it("stops notifications when unsubscribing", () => {
const c = cell(0);
const cb = jest.fn();
const unsub = c.subscribe(cb);
c.set(1);
unsub();
c.set(2);
expect(cb).toHaveBeenCalledTimes(1);
});
it("updates multiple listeners", () => {
const c = cell("x");
const a = jest.fn();
const b = jest.fn();
const ua = c.subscribe(a);
const ub = c.subscribe(b);
c.set("y");
expect(a).toHaveBeenCalledTimes(1);
expect(b).toHaveBeenCalledTimes(1);
ua();
ub();
});
});
describe("cell store (integration)", () => {
function View({c, label}: { c: Cell<any>; label: string }) {
const v = useCell(c);
// count renders to verify re-render behavior
(View as any).__renders = ((View as any).__renders ?? 0) + 1;
return <div data-testid={label}>{String(v)}</div>;
}
it("reads initial value and updates on set", () => {
const c = cell("hello");
render(<View c={c} label="value"/>);
expect(screen.getByTestId("value")).toHaveTextContent("hello");
act(() => {
c.set("world");
});
expect(screen.getByTestId("value")).toHaveTextContent("world");
});
it("triggers one re-render with set", () => {
const c = cell(1);
(View as any).__renders = 0;
render(<View c={c} label="num"/>);
const rendersAfterMount = (View as any).__renders;
act(() => {
c.set((prev: number) => prev + 1);
});
// exactly one extra render from the update
expect((View as any).__renders).toBe(rendersAfterMount + 1);
expect(screen.getByTestId("num")).toHaveTextContent("2");
});
it("unsubscribes on unmount (no errors on later sets)", () => {
const c = cell("a");
const {unmount} = render(<View c={c} label="value"/>);
unmount();
// should not throw even though there was a subscriber
expect(() =>
act(() => {
c.set("b");
})
).not.toThrow();
});
it("only re-renders components that use the cell", () => {
const a = cell("A");
const b = cell("B");
let rendersA = 0;
let rendersB = 0;
function A() {
const v = useCell(a);
rendersA++;
return <div data-testid="A">{v}</div>;
}
function B() {
const v = useCell(b);
rendersB++;
return <div data-testid="B">{v}</div>;
}
render(
<>
<A/>
<B/>
</>
);
const rendersAAfterMount = rendersA;
const rendersBAfterMount = rendersB;
act(() => {
a.set("A2"); // only A should update
});
expect(screen.getByTestId("A")).toHaveTextContent("A2");
expect(screen.getByTestId("B")).toHaveTextContent("B");
expect(rendersA).toBe(rendersAAfterMount + 1);
expect(rendersB).toBe(rendersBAfterMount); // unchanged
});
});

View File

@@ -0,0 +1,53 @@
import formatDuration from "../../src/utils/formatDuration.ts";
describe("formatting durations (unit)", () => {
it("does one millisecond", () => {
const result = formatDuration(1);
expect(result).toBe("00:00:00.001");
});
it("does one-hundred twenty-three milliseconds", () => {
const result = formatDuration(123);
expect(result).toBe("00:00:00.123");
});
it("does one second", () => {
const result = formatDuration(1*1000);
expect(result).toBe("00:00:01.000");
});
it("does thirteen seconds", () => {
const result = formatDuration(13*1000);
expect(result).toBe("00:00:13.000");
});
it("does one minute", () => {
const result = formatDuration(60*1000);
expect(result).toBe("00:01:00.000");
});
it("does thirteen minutes", () => {
const result = formatDuration(13*60*1000);
expect(result).toBe("00:13:00.000");
});
it("does one hour", () => {
const result = formatDuration(60*60*1000);
expect(result).toBe("01:00:00.000");
});
it("does thirteen hours", () => {
const result = formatDuration(13*60*60*1000);
expect(result).toBe("13:00:00.000");
});
it("does negative one millisecond", () => {
const result = formatDuration(-1);
expect(result).toBe("-00:00:00.001");
});
it("does large negative durations", () => {
const result = formatDuration(-(123*60*60*1000 + 59*60*1000 + 59*1000 + 123));
expect(result).toBe("-123:59:59.123");
});
});

View File

@@ -0,0 +1,81 @@
import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../src/utils/priorityFiltering";
const makePred = <T>(priority: number, fn: (el: T) => boolean | null): PriorityFilterPredicate<T> => ({
priority,
predicate: jest.fn(fn),
});
describe("applyPriorityPredicates (unit)", () => {
beforeEach(() => jest.clearAllMocks());
it("returns true when there are no predicates", () => {
expect(applyPriorityPredicates(123, [])).toBe(true);
});
it("behaves like a normal predicate with only one predicate", () => {
const even = makePred<number>(1, (n) => n % 2 === 0);
expect(applyPriorityPredicates(2, [even])).toBe(true);
expect(applyPriorityPredicates(3, [even])).toBe(false);
});
it("determines the result only listening to the highest priority predicates", () => {
const lowFail = makePred<number>(1, (_) => false);
const lowPass = makePred<number>(1, (_) => true);
const highPass = makePred<number>(10, (n) => n > 0);
const highFail = makePred<number>(10, (n) => n < 0);
expect(applyPriorityPredicates(5, [lowFail, highPass])).toBe(true);
expect(applyPriorityPredicates(5, [lowPass, highFail])).toBe(false);
});
it("uses all predicates at the highest priority", () => {
const high1 = makePred<number>(5, (n) => n % 2 === 0);
const high2 = makePred<number>(5, (n) => n > 2);
expect(applyPriorityPredicates(4, [high1, high2])).toBe(true);
expect(applyPriorityPredicates(2, [high1, high2])).toBe(false);
});
it("is order independent (later higher positive clears earlier lower negative)", () => {
const lowFalse = makePred<number>(1, (_) => false);
const highTrue = makePred<number>(9, (n) => n === 7);
// Higher priority appears later → should reset and decide by highest only
expect(applyPriorityPredicates(7, [lowFalse, highTrue])).toBe(true);
// Same set, different order → same result
expect(applyPriorityPredicates(7, [highTrue, lowFalse])).toBe(true);
});
it("handles many priorities: only max matters", () => {
const p1 = makePred<number>(1, (_) => false);
const p3 = makePred<number>(3, (_) => false);
const p5 = makePred<number>(5, (n) => n > 0);
expect(applyPriorityPredicates(1, [p1, p3, p5])).toBe(true);
});
it("skips predicates that return null", () => {
const high = makePred<number>(10, (n) => n === 0 ? true : null);
const low = makePred<number>(1, (_) => false);
expect(applyPriorityPredicates(0, [high, low])).toBe(true);
expect(applyPriorityPredicates(1, [high, low])).toBe(false);
});
});
describe("(integration) filter with applyPriorityPredicates", () => {
it("filters an array using only highest-priority predicates", () => {
const elems = [1, 2, 3, 4, 5];
const low = makePred<number>(0, (_) => false);
const high1 = makePred<number>(5, (n) => n % 2 === 0);
const high2 = makePred<number>(5, (n) => n > 2);
const result = elems.filter((e) => applyPriorityPredicates(e, [low, high1, high2]));
expect(result).toEqual([4]);
});
it("filters an array using only highest-priority predicates", () => {
const elems = [1, 2, 3, 4, 5];
const low = makePred<number>(0, (_) => false);
const high = makePred<number>(5, (n) => n === 3 ? true : null);
const result = elems.filter((e) => applyPriorityPredicates(e, [low, high]));
expect(result).toEqual([3]);
});
});