]*className=["']device-badge["'][^>]*>[\s\S]*?<\/span>/,
+ );
+ expect(deviceBadgeMatch).toBeTruthy();
+ if (deviceBadgeMatch) {
+ expect(deviceBadgeMatch[0]).toContain("Highlight");
+ }
+ });
+
+ it("should show 'No matches' when search has no results vs 'No records yet' when empty", () => {
+ const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
+
+ // Check for "No records yet" message
+ expect(content).toContain("No records yet");
+
+ // Check for "No matches" or similar search-specific empty message
+ expect(content).toMatch(/No matches|No search results|No records found/i);
+ });
+
+ it("should define .search-input styles consistent with dark theme", () => {
+ const cssContent = readFileSync(join(__dirname, "App.css"), "utf-8");
+
+ // Check for .search-input selector
+ expect(cssContent).toMatch(/\.search-input\s*\{/);
+
+ // Extract the search-input block
+ const searchInputBlock = cssContent.match(/\.search-input\s*\{[^}]+\}/);
+ expect(searchInputBlock).toBeTruthy();
+
+ if (searchInputBlock) {
+ const block = searchInputBlock[0];
+ // Dark background
+ expect(block).toMatch(/background:\s*#[0-9a-fA-F]{6}/);
+ // Light text
+ expect(block).toMatch(/color:\s*#[0-9a-fA-F]{6}/);
+ // Border
+ expect(block).toMatch(/border:/);
+ // Padding
+ expect(block).toMatch(/padding:/);
+ // Border radius
+ expect(block).toMatch(/border-radius:/);
+ }
+ });
+
+ it("should define mark element styles with yellow/orange background for dark theme visibility", () => {
+ const cssContent = readFileSync(join(__dirname, "App.css"), "utf-8");
+
+ // Check for mark selector
+ expect(cssContent).toMatch(/\bmark\s*\{|\.mark\s*\{/);
+
+ // Extract the mark block
+ const markBlock = cssContent.match(/\bmark\s*\{[^}]+\}/);
+ expect(markBlock).toBeTruthy();
+
+ if (markBlock) {
+ const block = markBlock[0];
+ // Yellow/orange background (hex codes starting with #f, #fb, #fc, #d, #e, etc.)
+ expect(block).toMatch(/background[^:]*:\s*#[def][0-9a-fA-F]{5}/);
+ // Should have color defined for text
+ expect(block).toMatch(/color:/);
+ }
+ });
+
+ it("should place search input above device filter buttons", () => {
+ const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
+
+ // Extract the return JSX section
+ const returnMatch = content.match(/return\s*\(([\s\S]+)\);?\s*\}/);
+ expect(returnMatch).toBeTruthy();
+
+ if (returnMatch) {
+ const jsx = returnMatch[1];
+
+ // Find position of search input
+ const searchInputPos = jsx.search(/]*search-input/);
+ expect(searchInputPos).toBeGreaterThan(-1);
+
+ // Find position of filters div
+ const filtersPos = jsx.search(/]*className=["']filters["']/);
+ expect(filtersPos).toBeGreaterThan(-1);
+
+ // Search input should come before filters
+ expect(searchInputPos).toBeLessThan(filtersPos);
+ }
+ });
+
+ it("should render filtered records instead of raw records in the list", () => {
+ const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
+
+ // Check for a filtered variable being mapped
+ const useMemoMatch = content.match(/const\s+(\w+)\s*=\s*useMemo/);
+ expect(useMemoMatch).toBeTruthy();
+
+ if (useMemoMatch) {
+ const filteredVar = useMemoMatch[1];
+
+ // Check that this variable is used in .map()
+ const mapPattern = new RegExp(`\\{${filteredVar}\\.map\\(`);
+ expect(content).toMatch(mapPattern);
+ }
+
+ // Additionally ensure raw records.map is NOT used in the render section
+ // (after the useMemo definition)
+ const useMemoIndex = content.indexOf("useMemo");
+ const recordsMapMatch = content.slice(useMemoIndex).match(/\{records\.map\(/);
+ expect(recordsMapMatch).toBeNull();
+ });
+
+ it("should handle empty search query gracefully", () => {
+ const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
+
+ // Check that filtering logic handles empty query
+ const useMemoBlock = content.match(/useMemo\([^}]+\}/);
+ expect(useMemoBlock).toBeTruthy();
+
+ // Should either have early return for empty query OR filter logic that handles it
+ expect(content).toMatch(/if\s*\(\s*!\w+|if\s*\(\s*\w+\.trim\(\)|\.includes\(/);
+ });
+
+ it("should import useMemo from React", () => {
+ const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
+
+ // Check for useMemo in React import (may have React, before the destructure)
+ expect(content).toMatch(/import\s+.*\{[^}]*useMemo[^}]*\}\s+from\s+["']react["']/);
+ });
+});
diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx
index 8292804..9b829bb 100644
--- a/packages/frontend/src/App.tsx
+++ b/packages/frontend/src/App.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState, useRef } from "react";
+import React, { useEffect, useState, useRef, useMemo } from "react";
import { useNavigate } from "react-router-dom";
interface Record {
@@ -29,6 +29,25 @@ function fmtDuration(ms: number) {
return `${(ms / 1000).toFixed(1)}s`;
}
+function Highlight({ text, query }: { text: string; query: string }) {
+ if (!query.trim()) return <>{text}>;
+
+ const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
+ const parts = text.split(regex);
+
+ return (
+ <>
+ {parts.map((part, i) =>
+ regex.test(part) ? (
+ {part}
+ ) : (
+ {part}
+ ),
+ )}
+ >
+ );
+}
+
export default function App() {
const [records, setRecords] = useState([]);
const [workers, setWorkers] = useState([]);
@@ -36,9 +55,23 @@ export default function App() {
[],
);
const [filter, setFilter] = useState("");
+ const [searchQuery, setSearchQuery] = useState("");
const nav = useNavigate();
const wsRef = useRef(null);
+ const filteredRecords = useMemo(() => {
+ if (!searchQuery.trim()) return records;
+
+ const lowerQuery = searchQuery.toLowerCase();
+ return records.filter((r) => {
+ return (
+ r.command.toLowerCase().includes(lowerQuery) ||
+ r.device.toLowerCase().includes(lowerQuery) ||
+ r.id.toLowerCase().includes(lowerQuery)
+ );
+ });
+ }, [records, searchQuery]);
+
useEffect(() => {
fetch(`/api/records${filter ? `?device=${filter}` : ""}`)
.then((r) => r.json())
@@ -94,6 +127,13 @@ export default function App() {
)}
+ setSearchQuery(e.target.value)}
+ />
{records.length === 0 &&
No records yet
}
- {records.map((r) => (
+ {records.length > 0 && filteredRecords.length === 0 && searchQuery && (
+
No matches found
+ )}
+ {filteredRecords.map((r) => (