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..c35d590 --- /dev/null +++ b/pages/utilities/csv-logs-viewer.tsx @@ -0,0 +1,928 @@ +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"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +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} + + + ))} + + + + + + ); +} + +type MarkerGroup = "primary" | "secondary"; + +interface MarkerInfo { + group: MarkerGroup; + 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 { + row: LogEntry; + originalIndex: number; + isMarkedButFiltered?: boolean; +} + +interface LogsTableProps { + rows: RowWithOriginalIndex[]; + headers: string[]; + searchQuery: string; + facets: Map; + filters: ColumnFilter[]; + onFilterChange: (column: string, values: string[]) => void; + markedRows: Map; + onToggleMark: (originalIndex: number, group: MarkerGroup) => void; + primaryDisplayNumbers: Map; + secondaryDisplayNumbers: Map; +} + +function LogsTable({ + rows, + headers, + searchQuery, + facets, + filters, + onFilterChange, + markedRows, + onToggleMark, + primaryDisplayNumbers, + secondaryDisplayNumbers, +}: 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"; + + 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, index) => { + const facet = facets.get(header); + const selectedValues = getSelectedValues(header); + const width = getColumnWidth(header, index); + return ( + + ); + })} + + + + {rows.map(({ row, originalIndex, isMarkedButFiltered }, index) => { + const logLevel = detectLogLevel(row); + const isExpanded = expandedRow === index; + const markerInfo = markedRows.get(originalIndex); + 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) { + 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 ( + <> + setExpandedRow(isExpanded ? null : index)} + > + + {headers.map((header) => { + const value = row[header] || ""; + const isDate = + rows.length > 0 && + isDateColumn(header, rows[0].row[header]); + const displayValue = isDate ? formatDate(value) : value; + const isTruncated = value.length > 30; + + const cellContent = ( +
+ {searchQuery ? ( + + ) : ( + displayValue + )} +
+ ); + + return ( + + ); + })} + + {isExpanded && ( + + + + )} + + ); + })} + +
+ # + +
+ {header} + {facet && facet.values.length > 1 && ( + + onFilterChange(header, values) + } + /> + )} +
+
{ + e.stopPropagation(); + const group: MarkerGroup = + e.metaKey || e.ctrlKey ? "secondary" : "primary"; + onToggleMark(originalIndex, group); + }} + > + {isMarked ? ( + + {displayNumber} + + ) : ( + + + + + )} + + {isTruncated ? ( + + + {cellContent} + + + {value} + + + ) : ( + cellContent + )} +
+
+
+ + {logLevel.toUpperCase()} + + {isMarked && ( + + {isPrimaryMarker ? "Flow A" : "Flow B"} # + {displayNumber} + + )} +
+ {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([]); + const [markedRows, setMarkedRows] = useState>( + new Map() + ); + const [nextInsertionOrder, setNextInsertionOrder] = useState(1); + + 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([]); + setMarkedRows(new Map()); + setNextInsertionOrder(1); + } 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 filteredRowsWithMarkers = useMemo((): RowWithOriginalIndex[] => { + if (!csvData) return []; + + const filteredSet = new Set(); + let filtered = filterRows(csvData.rows, filters, debouncedSearchQuery); + + if (selectedLogLevels.length > 0) { + 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, markedRows]); + + const primaryDisplayNumbers = useMemo( + () => computeDisplayNumbers(markedRows, "primary"), + [markedRows] + ); + + const secondaryDisplayNumbers = useMemo( + () => computeDisplayNumbers(markedRows, "secondary"), + [markedRows] + ); + + const handleToggleMark = useCallback( + (originalIndex: number, group: MarkerGroup) => { + setMarkedRows((prev) => { + const newMap = new Map(prev); + const existing = newMap.get(originalIndex); + + if (existing && existing.group === group) { + newMap.delete(originalIndex); + } else { + newMap.set(originalIndex, { + group, + insertionOrder: nextInsertionOrder, + }); + setNextInsertionOrder((n) => n + 1); + } + return newMap; + }); + }, + [nextInsertionOrder] + ); + + 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 {filteredRowsWithMarkers.length} of{" "} + {csvData.rows.length} logs + {markedRows.size > 0 && ( + <> + {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 && ( + + )} +
+
+
+
+ +
+
+ +
+ +
+
+
+ + )} + + +
+ ); +}