From bca85ee9830a4ceb8ac4ef4a5b87b40b4b75d914 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 17:25:56 +0000 Subject: [PATCH 1/7] feat: add CSV logs viewer utility with Datadog-inspired filtering Co-Authored-By: petar@jam.dev --- components/utils/csv-logs-viewer.utils.ts | 263 +++++++++ components/utils/tools-list.ts | 6 + pages/utilities/csv-logs-viewer.tsx | 674 ++++++++++++++++++++++ 3 files changed, 943 insertions(+) create mode 100644 components/utils/csv-logs-viewer.utils.ts create mode 100644 pages/utilities/csv-logs-viewer.tsx diff --git a/components/utils/csv-logs-viewer.utils.ts b/components/utils/csv-logs-viewer.utils.ts new file mode 100644 index 0000000..cc4ed1e --- /dev/null +++ b/components/utils/csv-logs-viewer.utils.ts @@ -0,0 +1,263 @@ +export interface LogEntry { + [key: string]: string; +} + +export interface ParsedCSV { + headers: string[]; + rows: LogEntry[]; +} + +export type LogLevel = "error" | "warning" | "info" | "debug" | "default"; + +export interface ColumnFilter { + column: string; + selectedValues: string[]; +} + +export interface FacetValue { + value: string; + count: number; +} + +export interface Facet { + column: string; + values: FacetValue[]; +} + +const ERROR_PATTERNS = [ + /\berror\b/i, + /\bfailed\b/i, + /\bfailure\b/i, + /\bexception\b/i, + /\bcritical\b/i, + /\bfatal\b/i, + /\b5\d{2}\b/, +]; + +const WARNING_PATTERNS = [ + /\bwarn(ing)?\b/i, + /\bcaution\b/i, + /\bdeprecated\b/i, + /\b4\d{2}\b/, +]; + +const DEBUG_PATTERNS = [/\bdebug\b/i, /\btrace\b/i, /\bverbose\b/i]; + +const INFO_PATTERNS = [/\binfo\b/i, /\bnotice\b/i]; + +export function detectLogLevel(row: LogEntry): LogLevel { + const rowText = Object.values(row).join(" "); + + for (const pattern of ERROR_PATTERNS) { + if (pattern.test(rowText)) { + return "error"; + } + } + + for (const pattern of WARNING_PATTERNS) { + if (pattern.test(rowText)) { + return "warning"; + } + } + + for (const pattern of DEBUG_PATTERNS) { + if (pattern.test(rowText)) { + return "debug"; + } + } + + for (const pattern of INFO_PATTERNS) { + if (pattern.test(rowText)) { + return "info"; + } + } + + return "default"; +} + +export function getLogLevelColor(level: LogLevel): string { + switch (level) { + case "error": + return "bg-red-500/10 border-l-2 border-l-red-500"; + case "warning": + return "bg-yellow-500/10 border-l-2 border-l-yellow-500"; + case "info": + return "bg-blue-500/5 border-l-2 border-l-blue-500"; + case "debug": + return "bg-gray-500/5 border-l-2 border-l-gray-400"; + default: + return ""; + } +} + +export function getLogLevelBadgeColor(level: LogLevel): string { + switch (level) { + case "error": + return "bg-red-500/10 text-red-700 dark:text-red-400 border-red-200 dark:border-red-800"; + case "warning": + return "bg-yellow-500/10 text-yellow-700 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800"; + case "info": + return "bg-blue-500/10 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-800"; + case "debug": + return "bg-gray-500/10 text-gray-700 dark:text-gray-400 border-gray-200 dark:border-gray-800"; + default: + return "bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700"; + } +} + +export function parseCSV(content: string): ParsedCSV { + const lines = content.trim().split(/\r?\n/); + + if (lines.length === 0) { + return { headers: [], rows: [] }; + } + + const delimiter = detectDelimiter(lines[0]); + const headers = parseCSVLine(lines[0], delimiter); + + const rows: LogEntry[] = []; + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + + const values = parseCSVLine(line, delimiter); + const row: LogEntry = {}; + + headers.forEach((header, index) => { + row[header] = values[index] || ""; + }); + + rows.push(row); + } + + return { headers, rows }; +} + +function detectDelimiter(line: string): string { + const tabCount = (line.match(/\t/g) || []).length; + const commaCount = (line.match(/,/g) || []).length; + const semicolonCount = (line.match(/;/g) || []).length; + + if (tabCount >= commaCount && tabCount >= semicolonCount) { + return "\t"; + } + if (semicolonCount > commaCount) { + return ";"; + } + return ","; +} + +function parseCSVLine(line: string, delimiter: string): string[] { + const result: string[] = []; + let current = ""; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + const nextChar = line[i + 1]; + + if (char === '"') { + if (inQuotes && nextChar === '"') { + current += '"'; + i++; + } else { + inQuotes = !inQuotes; + } + } else if (char === delimiter && !inQuotes) { + result.push(current.trim()); + current = ""; + } else { + current += char; + } + } + + result.push(current.trim()); + return result; +} + +export function buildFacets( + rows: LogEntry[], + headers: string[] +): Map { + const facets = new Map(); + + headers.forEach((header) => { + const valueCounts = new Map(); + + rows.forEach((row) => { + const value = row[header] || "(empty)"; + valueCounts.set(value, (valueCounts.get(value) || 0) + 1); + }); + + const values: FacetValue[] = Array.from(valueCounts.entries()) + .map(([value, count]) => ({ value, count })) + .sort((a, b) => b.count - a.count); + + facets.set(header, { column: header, values }); + }); + + return facets; +} + +export function filterRows( + rows: LogEntry[], + filters: ColumnFilter[], + searchQuery: string +): LogEntry[] { + let result = rows; + + filters.forEach((filter) => { + if (filter.selectedValues.length > 0) { + result = result.filter((row) => { + const value = row[filter.column] || "(empty)"; + return filter.selectedValues.includes(value); + }); + } + }); + + if (searchQuery) { + const query = searchQuery.toLowerCase(); + result = result.filter((row) => { + return Object.values(row).some((value) => + value.toLowerCase().includes(query) + ); + }); + } + + return result; +} + +export function formatDate(dateString: string): string { + try { + const date = new Date(dateString); + if (isNaN(date.getTime())) { + return dateString; + } + return date.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + } catch { + return dateString; + } +} + +export function isDateColumn(header: string, sampleValue: string): boolean { + const dateHeaders = ["date", "time", "timestamp", "created", "updated", "at"]; + const headerLower = header.toLowerCase(); + + if (dateHeaders.some((h) => headerLower.includes(h))) { + return true; + } + + if (sampleValue) { + const isoPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/; + return isoPattern.test(sampleValue); + } + + return false; +} diff --git a/components/utils/tools-list.ts b/components/utils/tools-list.ts index aa8ef64..266a7c5 100644 --- a/components/utils/tools-list.ts +++ b/components/utils/tools-list.ts @@ -179,4 +179,10 @@ export const tools = [ "Transform XML data into JSON format instantly. Simplifies working with APIs and modern web applications that prefer JSON.", link: "/utilities/xml-to-json", }, + { + title: "CSV Logs Viewer", + description: + "View, search, and filter CSV log files with color-coded severity levels. Quickly scan through logs with Datadog-inspired faceted filtering.", + link: "/utilities/csv-logs-viewer", + }, ]; diff --git a/pages/utilities/csv-logs-viewer.tsx b/pages/utilities/csv-logs-viewer.tsx new file mode 100644 index 0000000..1c84b51 --- /dev/null +++ b/pages/utilities/csv-logs-viewer.tsx @@ -0,0 +1,674 @@ +import { useCallback, useMemo, useState, useEffect } from "react"; +import { + parseCSV, + buildFacets, + filterRows, + detectLogLevel, + getLogLevelColor, + getLogLevelBadgeColor, + formatDate, + isDateColumn, + LogEntry, + ParsedCSV, + ColumnFilter, + Facet, + LogLevel, +} from "@/components/utils/csv-logs-viewer.utils"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ds/ButtonComponent"; +import Meta from "@/components/Meta"; +import Header from "@/components/Header"; +import { CMDK } from "@/components/CMDK"; +import { Card } from "@/components/ds/CardComponent"; +import UploadIcon from "@/components/icons/UploadIcon"; +import PageHeader from "@/components/PageHeader"; +import CallToActionGrid from "@/components/CallToActionGrid"; +import { Search, X, ChevronDown, ChevronRight, Filter } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ds/PopoverComponent"; +import { Input } from "@/components/ds/InputComponent"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ds/CommandMenu"; +import { Checkbox } from "@/components/ds/CheckboxComponent"; +import SearchHighlightText from "@/components/SearchHighlightText"; + +interface FacetSidebarProps { + facets: Map; + filters: ColumnFilter[]; + onFilterChange: (column: string, values: string[]) => void; + logLevelCounts: Map; + selectedLogLevels: LogLevel[]; + onLogLevelChange: (levels: LogLevel[]) => void; +} + +function FacetSidebar({ + facets, + filters, + onFilterChange, + logLevelCounts, + selectedLogLevels, + onLogLevelChange, +}: FacetSidebarProps) { + const [expandedFacets, setExpandedFacets] = useState>( + new Set(["Status"]) + ); + + const toggleFacet = (column: string) => { + const newExpanded = new Set(expandedFacets); + if (newExpanded.has(column)) { + newExpanded.delete(column); + } else { + newExpanded.add(column); + } + setExpandedFacets(newExpanded); + }; + + const getSelectedValues = (column: string): string[] => { + const filter = filters.find((f) => f.column === column); + return filter?.selectedValues || []; + }; + + const handleValueToggle = (column: string, value: string) => { + const currentValues = getSelectedValues(column); + const newValues = currentValues.includes(value) + ? currentValues.filter((v) => v !== value) + : [...currentValues, value]; + onFilterChange(column, newValues); + }; + + const handleLogLevelToggle = (level: LogLevel) => { + const newLevels = selectedLogLevels.includes(level) + ? selectedLogLevels.filter((l) => l !== level) + : [...selectedLogLevels, level]; + onLogLevelChange(newLevels); + }; + + const logLevels: { level: LogLevel; label: string }[] = [ + { level: "error", label: "Error" }, + { level: "warning", label: "Warn" }, + { level: "info", label: "Info" }, + { level: "debug", label: "Debug" }, + ]; + + return ( +
+
+ + + Filters + +
+ +
+ + {expandedFacets.has("Status") && ( +
+ {logLevels.map(({ level, label }) => { + const count = logLevelCounts.get(level) || 0; + if (count === 0) return null; + return ( + + ); + })} +
+ )} +
+ + {Array.from(facets.entries()).map(([column, facet]) => { + const selectedValues = getSelectedValues(column); + const isExpanded = expandedFacets.has(column); + const displayValues = facet.values.slice(0, 10); + const hasMore = facet.values.length > 10; + + return ( +
+ + {isExpanded && ( +
+ {displayValues.map(({ value, count }) => ( + + ))} + {hasMore && ( +
+ +{facet.values.length - 10} more +
+ )} +
+ )} +
+ ); + })} +
+ ); +} + +interface ColumnFilterDropdownProps { + facet: Facet; + selectedValues: string[]; + onSelectionChange: (values: string[]) => void; +} + +function ColumnFilterDropdown({ + facet, + selectedValues, + onSelectionChange, +}: ColumnFilterDropdownProps) { + const [open, setOpen] = useState(false); + + const handleToggle = (value: string) => { + const newSelection = selectedValues.includes(value) + ? selectedValues.filter((v) => v !== value) + : [...selectedValues, value]; + onSelectionChange(newSelection); + }; + + return ( + + + + + + + + + No values found. + + {facet.values.slice(0, 20).map((item) => ( + handleToggle(item.value)} + className="flex items-center space-x-2" + > + handleToggle(item.value)} + /> + {item.value} + + {item.count} + + + ))} + + + + + + ); +} + +interface LogsTableProps { + rows: LogEntry[]; + headers: string[]; + searchQuery: string; + facets: Map; + filters: ColumnFilter[]; + onFilterChange: (column: string, values: string[]) => void; +} + +function LogsTable({ + rows, + headers, + searchQuery, + facets, + filters, + onFilterChange, +}: LogsTableProps) { + const [expandedRow, setExpandedRow] = useState(null); + + useEffect(() => { + setExpandedRow(null); + }, [searchQuery, filters]); + + const getSelectedValues = (column: string): string[] => { + const filter = filters.find((f) => f.column === column); + return filter?.selectedValues || []; + }; + + const tableHeaderStyles = "border p-2 px-3 text-left text-[13px] font-medium"; + const tableCellStyles = "border p-2 px-3 text-[13px]"; + const tableRowStyles = "hover:bg-muted-foreground/5 cursor-pointer"; + + return ( +
+ + + + {headers.map((header) => { + const facet = facets.get(header); + const selectedValues = getSelectedValues(header); + return ( + + ); + })} + + + + {rows.map((row, index) => { + const logLevel = detectLogLevel(row); + const isExpanded = expandedRow === index; + + return ( + <> + setExpandedRow(isExpanded ? null : index)} + > + {headers.map((header) => { + const value = row[header] || ""; + const isDate = + rows.length > 0 && isDateColumn(header, rows[0][header]); + const displayValue = isDate ? formatDate(value) : value; + + return ( + + ); + })} + + {isExpanded && ( + + + + )} + + ); + })} + +
+
+ {header} + {facet && facet.values.length > 1 && ( + + onFilterChange(header, values) + } + /> + )} +
+
+ {searchQuery ? ( + + ) : ( + displayValue + )} +
+
+
+ + {logLevel.toUpperCase()} + +
+ {headers.map((header) => ( +
+ + {header}: + + + {searchQuery ? ( + + ) : ( + row[header] || "" + )} + +
+ ))} +
+
+
+ ); +} + +export default function CSVLogsViewer() { + const [status, setStatus] = useState<"idle" | "unsupported" | "hover">( + "idle" + ); + const [csvData, setCsvData] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); + const [filters, setFilters] = useState([]); + const [selectedLogLevels, setSelectedLogLevels] = useState([]); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchQuery(searchQuery); + }, 300); + + return () => clearTimeout(timer); + }, [searchQuery]); + + const handleFileUpload = useCallback((file: File | undefined) => { + if (!file) { + return; + } + + const validExtensions = [".csv", ".tsv", ".txt", ".log"]; + const hasValidExtension = validExtensions.some((ext) => + file.name.toLowerCase().endsWith(ext) + ); + + if (!hasValidExtension) { + setStatus("unsupported"); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + try { + const content = e.target?.result as string; + const parsed = parseCSV(content); + setCsvData(parsed); + setStatus("idle"); + setFilters([]); + setSelectedLogLevels([]); + } catch (error) { + console.error("Error parsing CSV file:", error); + setStatus("unsupported"); + } + }; + reader.readAsText(file); + }, []); + + const handleDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + setStatus("hover"); + + const file = event.dataTransfer.files[0]; + handleFileUpload(file); + setStatus("idle"); + }, + [handleFileUpload] + ); + + const handleDragOver = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + setStatus("hover"); + }, + [] + ); + + const facets = useMemo(() => { + if (!csvData) return new Map(); + return buildFacets(csvData.rows, csvData.headers); + }, [csvData]); + + const logLevelCounts = useMemo(() => { + const counts = new Map(); + if (!csvData) return counts; + + csvData.rows.forEach((row) => { + const level = detectLogLevel(row); + counts.set(level, (counts.get(level) || 0) + 1); + }); + + return counts; + }, [csvData]); + + const filteredRows = useMemo(() => { + if (!csvData) return []; + + let result = filterRows(csvData.rows, filters, debouncedSearchQuery); + + if (selectedLogLevels.length > 0) { + result = result.filter((row) => + selectedLogLevels.includes(detectLogLevel(row)) + ); + } + + return result; + }, [csvData, filters, debouncedSearchQuery, selectedLogLevels]); + + const handleFilterChange = useCallback( + (column: string, values: string[]) => { + setFilters((prev) => { + const existing = prev.find((f) => f.column === column); + if (existing) { + if (values.length === 0) { + return prev.filter((f) => f.column !== column); + } + return prev.map((f) => + f.column === column ? { ...f, selectedValues: values } : f + ); + } + if (values.length === 0) return prev; + return [...prev, { column, selectedValues: values }]; + }); + }, + [] + ); + + const clearAllFilters = useCallback(() => { + setFilters([]); + setSelectedLogLevels([]); + setSearchQuery(""); + }, []); + + const hasActiveFilters = + filters.length > 0 || selectedLogLevels.length > 0 || searchQuery; + + return ( +
+ +
+ +
+ +
+ +
+ +
setStatus("idle")} + className="relative flex flex-col border border-dashed border-border p-6 text-center text-muted-foreground rounded-lg min-h-40 items-center justify-center bg-muted" + > + handleFileUpload(event.target.files?.[0])} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + +
+ {status === "idle" && ( +

Drop your .csv, .tsv, or .log file here

+ )} + {status === "hover" &&

Drop it like it's hot

} + {status === "unsupported" &&

Invalid file format

} +
+
+
+
+ + {csvData && ( + <> +
+
+
+
+ + setSearchQuery(e.target.value)} + className="pl-9 pr-10" + /> + {searchQuery && ( + + )} +
+ +
+

+ Showing {filteredRows.length} of {csvData.rows.length} logs +

+ {hasActiveFilters && ( + + )} +
+
+
+
+ +
+
+ +
+ +
+
+
+ + )} + + +
+ ); +} From bfbb3b5398fe5b8b0b6e0d2afe4eb4ea6f5f51bf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 17:28:22 +0000 Subject: [PATCH 2/7] fix: format csv-logs-viewer.tsx with Prettier Co-Authored-By: petar@jam.dev --- pages/utilities/csv-logs-viewer.tsx | 31 +++++++++++++---------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/pages/utilities/csv-logs-viewer.tsx b/pages/utilities/csv-logs-viewer.tsx index 1c84b51..84fa890 100644 --- a/pages/utilities/csv-logs-viewer.tsx +++ b/pages/utilities/csv-logs-viewer.tsx @@ -527,24 +527,21 @@ export default function CSVLogsViewer() { return result; }, [csvData, filters, debouncedSearchQuery, selectedLogLevels]); - const handleFilterChange = useCallback( - (column: string, values: string[]) => { - setFilters((prev) => { - const existing = prev.find((f) => f.column === column); - if (existing) { - if (values.length === 0) { - return prev.filter((f) => f.column !== column); - } - return prev.map((f) => - f.column === column ? { ...f, selectedValues: values } : f - ); + const handleFilterChange = useCallback((column: string, values: string[]) => { + setFilters((prev) => { + const existing = prev.find((f) => f.column === column); + if (existing) { + if (values.length === 0) { + return prev.filter((f) => f.column !== column); } - if (values.length === 0) return prev; - return [...prev, { column, selectedValues: values }]; - }); - }, - [] - ); + return prev.map((f) => + f.column === column ? { ...f, selectedValues: values } : f + ); + } + if (values.length === 0) return prev; + return [...prev, { column, selectedValues: values }]; + }); + }, []); const clearAllFilters = useCallback(() => { setFilters([]); From 19008f5c9265f9ba8d7ba6a6c2651d06a06c0c9f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 17:40:07 +0000 Subject: [PATCH 3/7] fix: sidebar checkbox filtering, full-width layout, fixed column widths Co-Authored-By: petar@jam.dev --- pages/utilities/csv-logs-viewer.tsx | 47 +++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/pages/utilities/csv-logs-viewer.tsx b/pages/utilities/csv-logs-viewer.tsx index 84fa890..5b2c060 100644 --- a/pages/utilities/csv-logs-viewer.tsx +++ b/pages/utilities/csv-logs-viewer.tsx @@ -132,7 +132,7 @@ function FacetSidebar({ > handleLogLevelToggle(level)} + onCheckedChange={() => handleLogLevelToggle(level)} /> handleValueToggle(column, value)} + onCheckedChange={() => handleValueToggle(column, value)} /> {value} @@ -264,7 +264,7 @@ function ColumnFilterDropdown({ > handleToggle(item.value)} + onCheckedChange={() => handleToggle(item.value)} /> {item.value} @@ -312,16 +312,45 @@ function LogsTable({ const tableCellStyles = "border p-2 px-3 text-[13px]"; const tableRowStyles = "hover:bg-muted-foreground/5 cursor-pointer"; + const getColumnWidth = (header: string, index: number): string => { + const lowerHeader = header.toLowerCase(); + if (lowerHeader.includes("date") || lowerHeader.includes("time")) { + return "180px"; + } + if (lowerHeader.includes("host") || lowerHeader.includes("service")) { + return "200px"; + } + if ( + lowerHeader.includes("content") || + lowerHeader.includes("message") || + lowerHeader.includes("log") + ) { + return "auto"; + } + if (index === headers.length - 1) { + return "auto"; + } + return "150px"; + }; + return (
- +
- {headers.map((header) => { + {headers.map((header, index) => { const facet = facets.get(header); const selectedValues = getSelectedValues(header); + const width = getColumnWidth(header, index); return ( - + )} + + ); + })} + +
+
{header} {facet && facet.values.length > 1 && ( @@ -596,10 +625,10 @@ export default function CSVLogsViewer() { {csvData && ( <> -
+
-
+
-
+
Date: Sat, 24 Jan 2026 17:46:55 +0000 Subject: [PATCH 4/7] feat: replace title attribute with tooltip for instant preview Co-Authored-By: petar@jam.dev --- pages/utilities/csv-logs-viewer.tsx | 251 ++++++++++++++++------------ 1 file changed, 140 insertions(+), 111 deletions(-) diff --git a/pages/utilities/csv-logs-viewer.tsx b/pages/utilities/csv-logs-viewer.tsx index 5b2c060..1ce471d 100644 --- a/pages/utilities/csv-logs-viewer.tsx +++ b/pages/utilities/csv-logs-viewer.tsx @@ -40,6 +40,12 @@ import { } from "@/components/ds/CommandMenu"; import { Checkbox } from "@/components/ds/CheckboxComponent"; import SearchHighlightText from "@/components/SearchHighlightText"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; interface FacetSidebarProps { facets: Map; @@ -334,124 +340,147 @@ function LogsTable({ }; return ( -
- - - - {headers.map((header, index) => { - const facet = facets.get(header); - const selectedValues = getSelectedValues(header); - const width = getColumnWidth(header, index); + +
+
+ + + {headers.map((header, index) => { + const facet = facets.get(header); + const selectedValues = getSelectedValues(header); + const width = getColumnWidth(header, index); + return ( + + ); + })} + + + + {rows.map((row, index) => { + const logLevel = detectLogLevel(row); + const isExpanded = expandedRow === index; + return ( - - - ); - })} - - - - {rows.map((row, index) => { - const logLevel = detectLogLevel(row); - const isExpanded = expandedRow === index; - - return ( - <> - setExpandedRow(isExpanded ? null : index)} - > - {headers.map((header) => { - const value = row[header] || ""; - const isDate = - rows.length > 0 && isDateColumn(header, rows[0][header]); - const displayValue = isDate ? formatDate(value) : value; - - return ( + onClick={() => setExpandedRow(isExpanded ? null : index)} + > + {headers.map((header) => { + const value = row[header] || ""; + const isDate = + rows.length > 0 && + isDateColumn(header, rows[0][header]); + const displayValue = isDate ? formatDate(value) : value; + const isTruncated = value.length > 30; + + const cellContent = ( +
+ {searchQuery ? ( + + ) : ( + displayValue + )} +
+ ); + + return ( + + ); + })} + + {isExpanded && ( + - ); - })} - - {isExpanded && ( - - - - )} - - ); - })} - -
+
+ {header} + {facet && facet.values.length > 1 && ( + + onFilterChange(header, values) + } + /> + )} +
+
-
- {header} - {facet && facet.values.length > 1 && ( - - onFilterChange(header, values) - } - /> + <> +
+ {isTruncated ? ( + + + {cellContent} + + + {value} + + + ) : ( + cellContent + )} +
- {searchQuery ? ( - - ) : ( - displayValue - )} -
-
-
- - {logLevel.toUpperCase()} - -
- {headers.map((header) => ( -
- - {header}: - - - {searchQuery ? ( - - ) : ( - row[header] || "" +
+
+ + {logLevel.toUpperCase()}
- ))} -
-
-
+ {headers.map((header) => ( +
+ + {header}: + + + {searchQuery ? ( + + ) : ( + row[header] || "" + )} + +
+ ))} +
+ +
+
+ ); } From 53de4ea595aeb0907b4177d0d0d36b5afcc97d50 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 17:59:20 +0000 Subject: [PATCH 5/7] feat: add marker column for tracing logs with sequential numbering Co-Authored-By: petar@jam.dev --- pages/utilities/csv-logs-viewer.tsx | 127 +++++++++++++++++++++++++--- 1 file changed, 113 insertions(+), 14 deletions(-) diff --git a/pages/utilities/csv-logs-viewer.tsx b/pages/utilities/csv-logs-viewer.tsx index 1ce471d..0ebda5e 100644 --- a/pages/utilities/csv-logs-viewer.tsx +++ b/pages/utilities/csv-logs-viewer.tsx @@ -286,13 +286,21 @@ function ColumnFilterDropdown({ ); } +interface RowWithOriginalIndex { + row: LogEntry; + originalIndex: number; + isMarkedButFiltered?: boolean; +} + interface LogsTableProps { - rows: LogEntry[]; + rows: RowWithOriginalIndex[]; headers: string[]; searchQuery: string; facets: Map; filters: ColumnFilter[]; onFilterChange: (column: string, values: string[]) => void; + markedRows: Map; + onToggleMark: (originalIndex: number) => void; } function LogsTable({ @@ -302,6 +310,8 @@ function LogsTable({ facets, filters, onFilterChange, + markedRows, + onToggleMark, }: LogsTableProps) { const [expandedRow, setExpandedRow] = useState(null); @@ -345,6 +355,12 @@ function LogsTable({ + {headers.map((header, index) => { const facet = facets.get(header); const selectedValues = getSelectedValues(header); @@ -376,26 +392,51 @@ function LogsTable({ - {rows.map((row, index) => { + {rows.map(({ row, originalIndex, isMarkedButFiltered }, index) => { const logLevel = detectLogLevel(row); const isExpanded = expandedRow === index; + const markerNumber = markedRows.get(originalIndex); + const isMarked = markerNumber !== undefined; return ( <> setExpandedRow(isExpanded ? null : index)} > + {headers.map((header) => { const value = row[header] || ""; const isDate = rows.length > 0 && - isDateColumn(header, rows[0][header]); + isDateColumn(header, rows[0].row[header]); const displayValue = isDate ? formatDate(value) : value; const isTruncated = value.length > 30; @@ -437,9 +478,9 @@ function LogsTable({ })} {isExpanded && ( - +
+ # +
{ + e.stopPropagation(); + onToggleMark(originalIndex); + }} + > + {isMarked ? ( + + {markerNumber} + + ) : ( + + + + + )} +
@@ -452,6 +493,11 @@ function LogsTable({ > {logLevel.toUpperCase()} + {isMarked && ( + + Marker #{markerNumber} + + )}
{headers.map((header) => (
@@ -493,6 +539,8 @@ export default function CSVLogsViewer() { const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); const [filters, setFilters] = useState([]); const [selectedLogLevels, setSelectedLogLevels] = useState([]); + const [markedRows, setMarkedRows] = useState>(new Map()); + const [nextMarkerNumber, setNextMarkerNumber] = useState(1); useEffect(() => { const timer = setTimeout(() => { @@ -526,6 +574,8 @@ export default function CSVLogsViewer() { setStatus("idle"); setFilters([]); setSelectedLogLevels([]); + setMarkedRows(new Map()); + setNextMarkerNumber(1); } catch (error) { console.error("Error parsing CSV file:", error); setStatus("unsupported"); @@ -571,19 +621,60 @@ export default function CSVLogsViewer() { return counts; }, [csvData]); - const filteredRows = useMemo(() => { + const filteredRowsWithMarkers = useMemo((): RowWithOriginalIndex[] => { if (!csvData) return []; - let result = filterRows(csvData.rows, filters, debouncedSearchQuery); + const filteredSet = new Set(); + let filtered = filterRows(csvData.rows, filters, debouncedSearchQuery); if (selectedLogLevels.length > 0) { - result = result.filter((row) => + filtered = filtered.filter((row) => selectedLogLevels.includes(detectLogLevel(row)) ); } + filtered.forEach((row) => { + const originalIndex = csvData.rows.indexOf(row); + filteredSet.add(originalIndex); + }); + + const result: RowWithOriginalIndex[] = []; + + filtered.forEach((row) => { + const originalIndex = csvData.rows.indexOf(row); + result.push({ row, originalIndex, isMarkedButFiltered: false }); + }); + + markedRows.forEach((_, originalIndex) => { + if (!filteredSet.has(originalIndex)) { + result.push({ + row: csvData.rows[originalIndex], + originalIndex, + isMarkedButFiltered: true, + }); + } + }); + + result.sort((a, b) => a.originalIndex - b.originalIndex); + return result; - }, [csvData, filters, debouncedSearchQuery, selectedLogLevels]); + }, [csvData, filters, debouncedSearchQuery, selectedLogLevels, markedRows]); + + const handleToggleMark = useCallback( + (originalIndex: number) => { + setMarkedRows((prev) => { + const newMap = new Map(prev); + if (newMap.has(originalIndex)) { + newMap.delete(originalIndex); + } else { + newMap.set(originalIndex, nextMarkerNumber); + setNextMarkerNumber((n) => n + 1); + } + return newMap; + }); + }, + [nextMarkerNumber] + ); const handleFilterChange = useCallback((column: string, values: string[]) => { setFilters((prev) => { @@ -681,7 +772,13 @@ export default function CSVLogsViewer() {

- Showing {filteredRows.length} of {csvData.rows.length} logs + Showing {filteredRowsWithMarkers.length} of{" "} + {csvData.rows.length} logs + {markedRows.size > 0 && ( + + ({markedRows.size} marked) + + )}

{hasActiveFilters && (
From e5597dd580e711d5ff843420118ed7e19e159a84 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 18:09:53 +0000 Subject: [PATCH 6/7] feat: add second marker group (Cmd+click) with light green color for tracing two flows Co-Authored-By: petar@jam.dev --- pages/utilities/csv-logs-viewer.tsx | 115 ++++++++++++++++++++++------ 1 file changed, 92 insertions(+), 23 deletions(-) diff --git a/pages/utilities/csv-logs-viewer.tsx b/pages/utilities/csv-logs-viewer.tsx index 0ebda5e..1210856 100644 --- a/pages/utilities/csv-logs-viewer.tsx +++ b/pages/utilities/csv-logs-viewer.tsx @@ -286,6 +286,13 @@ function ColumnFilterDropdown({ ); } +type MarkerGroup = "primary" | "secondary"; + +interface MarkerInfo { + group: MarkerGroup; + number: number; +} + interface RowWithOriginalIndex { row: LogEntry; originalIndex: number; @@ -299,8 +306,8 @@ interface LogsTableProps { facets: Map; filters: ColumnFilter[]; onFilterChange: (column: string, values: string[]) => void; - markedRows: Map; - onToggleMark: (originalIndex: number) => void; + markedRows: Map; + onToggleMark: (originalIndex: number, group: MarkerGroup) => void; } function LogsTable({ @@ -395,8 +402,20 @@ function LogsTable({ {rows.map(({ row, originalIndex, isMarkedButFiltered }, index) => { const logLevel = detectLogLevel(row); const isExpanded = expandedRow === index; - const markerNumber = markedRows.get(originalIndex); - const isMarked = markerNumber !== undefined; + const markerInfo = markedRows.get(originalIndex); + const isMarked = markerInfo !== undefined; + const isPrimaryMarker = markerInfo?.group === "primary"; + const isSecondaryMarker = markerInfo?.group === "secondary"; + + const getMarkerRowColor = () => { + if (isPrimaryMarker) { + return "bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-900/50"; + } + if (isSecondaryMarker) { + return "bg-green-100 dark:bg-green-900/30 hover:bg-green-200 dark:hover:bg-green-900/50"; + } + return getLogLevelColor(logLevel); + }; return ( <> @@ -404,9 +423,7 @@ function LogsTable({ key={originalIndex} className={cn( tableRowStyles, - isMarked - ? "bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-900/50" - : getLogLevelColor(logLevel), + getMarkerRowColor(), !isMarked && index % 2 === 0 && "bg-muted/30", isMarkedButFiltered && "opacity-60" )} @@ -419,12 +436,19 @@ function LogsTable({ )} onClick={(e) => { e.stopPropagation(); - onToggleMark(originalIndex); + const group: MarkerGroup = + e.metaKey || e.ctrlKey ? "secondary" : "primary"; + onToggleMark(originalIndex, group); }} > {isMarked ? ( - - {markerNumber} + + {markerInfo.number} ) : ( @@ -494,8 +518,16 @@ function LogsTable({ {logLevel.toUpperCase()} {isMarked && ( - - Marker #{markerNumber} + + {isPrimaryMarker ? "Flow A" : "Flow B"} # + {markerInfo.number} )}
@@ -539,8 +571,11 @@ export default function CSVLogsViewer() { const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(""); const [filters, setFilters] = useState([]); const [selectedLogLevels, setSelectedLogLevels] = useState([]); - const [markedRows, setMarkedRows] = useState>(new Map()); - const [nextMarkerNumber, setNextMarkerNumber] = useState(1); + const [markedRows, setMarkedRows] = useState>( + new Map() + ); + const [nextPrimaryMarker, setNextPrimaryMarker] = useState(1); + const [nextSecondaryMarker, setNextSecondaryMarker] = useState(1); useEffect(() => { const timer = setTimeout(() => { @@ -575,7 +610,8 @@ export default function CSVLogsViewer() { setFilters([]); setSelectedLogLevels([]); setMarkedRows(new Map()); - setNextMarkerNumber(1); + setNextPrimaryMarker(1); + setNextSecondaryMarker(1); } catch (error) { console.error("Error parsing CSV file:", error); setStatus("unsupported"); @@ -661,19 +697,27 @@ export default function CSVLogsViewer() { }, [csvData, filters, debouncedSearchQuery, selectedLogLevels, markedRows]); const handleToggleMark = useCallback( - (originalIndex: number) => { + (originalIndex: number, group: MarkerGroup) => { setMarkedRows((prev) => { const newMap = new Map(prev); - if (newMap.has(originalIndex)) { + const existing = newMap.get(originalIndex); + + if (existing && existing.group === group) { newMap.delete(originalIndex); } else { - newMap.set(originalIndex, nextMarkerNumber); - setNextMarkerNumber((n) => n + 1); + const nextNumber = + group === "primary" ? nextPrimaryMarker : nextSecondaryMarker; + newMap.set(originalIndex, { group, number: nextNumber }); + if (group === "primary") { + setNextPrimaryMarker((n) => n + 1); + } else { + setNextSecondaryMarker((n) => n + 1); + } } return newMap; }); }, - [nextMarkerNumber] + [nextPrimaryMarker, nextSecondaryMarker] ); const handleFilterChange = useCallback((column: string, values: string[]) => { @@ -775,9 +819,34 @@ export default function CSVLogsViewer() { Showing {filteredRowsWithMarkers.length} of{" "} {csvData.rows.length} logs {markedRows.size > 0 && ( - - ({markedRows.size} marked) - + <> + {Array.from(markedRows.values()).filter( + (m) => m.group === "primary" + ).length > 0 && ( + + ( + { + Array.from(markedRows.values()).filter( + (m) => m.group === "primary" + ).length + }{" "} + Flow A) + + )} + {Array.from(markedRows.values()).filter( + (m) => m.group === "secondary" + ).length > 0 && ( + + ( + { + Array.from(markedRows.values()).filter( + (m) => m.group === "secondary" + ).length + }{" "} + Flow B) + + )} + )}

{hasActiveFilters && ( From 789c9a65e96828832de659edbf4622ea32808e05 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 18:20:34 +0000 Subject: [PATCH 7/7] feat: make marker numbers act as responsive stack (renumber on removal) Co-Authored-By: petar@jam.dev --- pages/utilities/csv-logs-viewer.tsx | 63 +++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 16 deletions(-) diff --git a/pages/utilities/csv-logs-viewer.tsx b/pages/utilities/csv-logs-viewer.tsx index 1210856..c35d590 100644 --- a/pages/utilities/csv-logs-viewer.tsx +++ b/pages/utilities/csv-logs-viewer.tsx @@ -290,7 +290,22 @@ type MarkerGroup = "primary" | "secondary"; interface MarkerInfo { group: MarkerGroup; - number: number; + insertionOrder: number; +} + +function computeDisplayNumbers( + markedRows: Map, + group: MarkerGroup +): Map { + const entries = Array.from(markedRows.entries()) + .filter(([, info]) => info.group === group) + .sort((a, b) => a[1].insertionOrder - b[1].insertionOrder); + + const displayNumbers = new Map(); + entries.forEach(([originalIndex], index) => { + displayNumbers.set(originalIndex, index + 1); + }); + return displayNumbers; } interface RowWithOriginalIndex { @@ -308,6 +323,8 @@ interface LogsTableProps { onFilterChange: (column: string, values: string[]) => void; markedRows: Map; onToggleMark: (originalIndex: number, group: MarkerGroup) => void; + primaryDisplayNumbers: Map; + secondaryDisplayNumbers: Map; } function LogsTable({ @@ -319,6 +336,8 @@ function LogsTable({ onFilterChange, markedRows, onToggleMark, + primaryDisplayNumbers, + secondaryDisplayNumbers, }: LogsTableProps) { const [expandedRow, setExpandedRow] = useState(null); @@ -406,6 +425,11 @@ function LogsTable({ const isMarked = markerInfo !== undefined; const isPrimaryMarker = markerInfo?.group === "primary"; const isSecondaryMarker = markerInfo?.group === "secondary"; + const displayNumber = isPrimaryMarker + ? primaryDisplayNumbers.get(originalIndex) + : isSecondaryMarker + ? secondaryDisplayNumbers.get(originalIndex) + : undefined; const getMarkerRowColor = () => { if (isPrimaryMarker) { @@ -448,7 +472,7 @@ function LogsTable({ isPrimaryMarker ? "bg-blue-500" : "bg-green-500" )} > - {markerInfo.number} + {displayNumber} ) : ( @@ -527,7 +551,7 @@ function LogsTable({ )} > {isPrimaryMarker ? "Flow A" : "Flow B"} # - {markerInfo.number} + {displayNumber} )} @@ -574,8 +598,7 @@ export default function CSVLogsViewer() { const [markedRows, setMarkedRows] = useState>( new Map() ); - const [nextPrimaryMarker, setNextPrimaryMarker] = useState(1); - const [nextSecondaryMarker, setNextSecondaryMarker] = useState(1); + const [nextInsertionOrder, setNextInsertionOrder] = useState(1); useEffect(() => { const timer = setTimeout(() => { @@ -610,8 +633,7 @@ export default function CSVLogsViewer() { setFilters([]); setSelectedLogLevels([]); setMarkedRows(new Map()); - setNextPrimaryMarker(1); - setNextSecondaryMarker(1); + setNextInsertionOrder(1); } catch (error) { console.error("Error parsing CSV file:", error); setStatus("unsupported"); @@ -696,6 +718,16 @@ export default function CSVLogsViewer() { return result; }, [csvData, filters, debouncedSearchQuery, selectedLogLevels, markedRows]); + const primaryDisplayNumbers = useMemo( + () => computeDisplayNumbers(markedRows, "primary"), + [markedRows] + ); + + const secondaryDisplayNumbers = useMemo( + () => computeDisplayNumbers(markedRows, "secondary"), + [markedRows] + ); + const handleToggleMark = useCallback( (originalIndex: number, group: MarkerGroup) => { setMarkedRows((prev) => { @@ -705,19 +737,16 @@ export default function CSVLogsViewer() { if (existing && existing.group === group) { newMap.delete(originalIndex); } else { - const nextNumber = - group === "primary" ? nextPrimaryMarker : nextSecondaryMarker; - newMap.set(originalIndex, { group, number: nextNumber }); - if (group === "primary") { - setNextPrimaryMarker((n) => n + 1); - } else { - setNextSecondaryMarker((n) => n + 1); - } + newMap.set(originalIndex, { + group, + insertionOrder: nextInsertionOrder, + }); + setNextInsertionOrder((n) => n + 1); } return newMap; }); }, - [nextPrimaryMarker, nextSecondaryMarker] + [nextInsertionOrder] ); const handleFilterChange = useCallback((column: string, values: string[]) => { @@ -884,6 +913,8 @@ export default function CSVLogsViewer() { onFilterChange={handleFilterChange} markedRows={markedRows} onToggleMark={handleToggleMark} + primaryDisplayNumbers={primaryDisplayNumbers} + secondaryDisplayNumbers={secondaryDisplayNumbers} />