diff --git a/frontend/src/components/dwe/log-page/log-detail-view.tsx b/frontend/src/components/dwe/log-page/log-detail-view.tsx deleted file mode 100644 index 69fa7028..00000000 --- a/frontend/src/components/dwe/log-page/log-detail-view.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from "@/components/ui/dialog"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Badge } from "@/components/ui/badge"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Card, CardContent } from "@/components/ui/card"; -import { Clock, File, User, Code } from "lucide-react"; -import { components } from "@/schemas/dwe_os_2"; -import { getLevelColor } from "@/lib/utils"; - -export function LogDetailView({ - log, - open, - onOpenChange, -}: { - log: components["schemas"]["LogSchema"] | undefined; - open: boolean; - onOpenChange: (open: boolean) => void; -}) { - if (!log) return null; - - // Format timestamp for better readability - const formatTimestamp = (timestamp: string) => { - try { - const date = new Date(timestamp.replace(",", ".")); - return date.toLocaleString(); - } catch { - return timestamp; - } - }; - - return ( - - - - - {log.level} - {log.name} - - - {formatTimestamp(log.timestamp)} - - - - -
- - Message - Details - -
- - - -
-                {log.message}
-              
-
-
- - - - -
-
- Level: -
-
- - {log.level} - -
-
- -
-
- Logger: -
-
- - {log.name} -
-
- -
-
File:
-
- - {log.filename}:{log.lineno} -
-
- -
-
- Function: -
-
- - {log.function}() -
-
- -
-
- Timestamp: -
-
- {log.timestamp} -
-
-
-
-
-
-
-
- ); -} diff --git a/frontend/src/components/dwe/log-page/log-viewer.tsx b/frontend/src/components/dwe/log-page/log-viewer.tsx index 14325bbe..04f4f208 100644 --- a/frontend/src/components/dwe/log-page/log-viewer.tsx +++ b/frontend/src/components/dwe/log-page/log-viewer.tsx @@ -1,6 +1,12 @@ "use client"; -import { useState, useEffect, useContext } from "react"; +import { + useState, + useEffect, + useContext, + useLayoutEffect, + useRef, +} from "react"; import { Table, TableBody, @@ -19,7 +25,13 @@ import { SelectValue, } from "@/components/ui/select"; import { Button } from "@/components/ui/button"; -import { Search, RefreshCw } from "lucide-react"; +import { Search, RefreshCw, Download } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { toast } from "sonner"; import { Pagination, PaginationContent, @@ -32,10 +44,17 @@ import { import WebsocketContext from "@/contexts/WebsocketContext"; import { API_CLIENT } from "@/api"; import { components } from "@/schemas/dwe_os_2"; -import { LogDetailView } from "./log-detail-view"; import { getLevelColor } from "@/lib/utils"; import { TOUR_STEP_IDS } from "@/lib/tour-constants"; import { useTour } from "@/components/tour/tour"; +import { + copyText, + downloadFile, + formatLogForCopy, + getLogExportFileName, + logsToCsv, + splitTimestamp, +} from "@/lib/util/logs"; const DEMO_LOG: components["schemas"]["LogSchema"][] = [ { @@ -49,6 +68,59 @@ const DEMO_LOG: components["schemas"]["LogSchema"][] = [ }, ]; +/** + * Message clamped to two lines; when that cuts it off, hovering shows the + * whole message in a callout. + */ +const LogMessage = ({ message }: { message: string }) => { + const ref = useRef(null); + const [isTruncated, setIsTruncated] = useState(false); + const [open, setOpen] = useState(false); + + useLayoutEffect(() => { + const element = ref.current; + if (!element) return; + + const check = () => + setIsTruncated(element.scrollHeight > element.clientHeight + 1); + const observer = new ResizeObserver(check); + observer.observe(element); + check(); + return () => observer.disconnect(); + }, [message]); + + return ( + // Always rendered so the measured element stays mounted; it just never + // opens when the message fits + + +
+ {message} +
+
+ e.stopPropagation()} + > +
+          {message}
+        
+
+
+ ); +}; + export function LogViewer() { const { connected, socket } = useContext(WebsocketContext)!; const { isActive } = useTour(); @@ -62,10 +134,8 @@ export function LogViewer() { const [isLoading, setIsLoading] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(10); - const [selectedLog, setSelectedLog] = useState< - components["schemas"]["LogSchema"] | undefined - >(undefined); - const [isDetailOpen, setIsDetailOpen] = useState(false); + // Row that was just copied, briefly highlighted as click feedback + const [copiedRow, setCopiedRow] = useState(null); // Available log levels const logLevels = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]; @@ -92,25 +162,6 @@ export function LogViewer() { } }, [connected, socket]); - // Format timestamp for better readability - const formatTimestamp = (timestamp: string) => { - try { - const date = new Date(timestamp.replace(",", ".")); - return date.toLocaleString("en-US", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - fractionalSecondDigits: 3, - }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (e) { - return timestamp; // Return original if parsing fails - } - }; - // Filter logs based on level and search query useEffect(() => { let filtered = [...logs]; @@ -181,6 +232,27 @@ export function LogViewer() { const displayedLogs = isActive ? DEMO_LOG : currentItems; + const exportLogs = () => { + if (logs.length === 0) { + toast.info("No logs to export"); + return; + } + downloadFile(getLogExportFileName(), logsToCsv(logs), "text/csv"); + }; + + const copyLog = async ( + log: components["schemas"]["LogSchema"], + row: number, + ) => { + setCopiedRow(row); + setTimeout( + () => setCopiedRow((current) => (current === row ? null : current)), + 600, + ); + if (await copyText(formatLogForCopy(log))) toast.success("Log copied"); + else toast.error("Couldn't copy the log"); + }; + return (
+ +
@@ -229,65 +306,66 @@ export function LogViewer() { - Timestamp - Level - Logger + {/* Sized to their fixed-width contents so the gaps between them match */} + Date + Time + Level + Logger Source - Message + {/* No fixed width, so the message takes the remaining space */} + Message {displayedLogs.length > 0 ? ( - displayedLogs.map((log, index) => ( - { - setSelectedLog(log); - setIsDetailOpen(true); - }} - > - - {formatTimestamp(log.timestamp)} - - - - {log.level} - - - { + const [date, time] = splitTimestamp(log.timestamp); + return ( + copyLog(log, index)} > - {log.name} - - -
-
- {log.filename}:{log.lineno} -
-
+ {date} + + + {time} + + + - {log.function}() + {log.level} + + + + {log.name} + + +
+
+ {log.filename}:{log.lineno} +
+
+ {log.function}() +
-
- - -
- {log.message} -
-
- - )) + + + + + + ); + }) ) : ( - + No logs found matching your filters. @@ -381,13 +459,6 @@ export function LogViewer() {
)} - - {/* Log Detail Dialog */} - ); } diff --git a/frontend/src/components/tour/tour-steps.tsx b/frontend/src/components/tour/tour-steps.tsx index 8f05cb01..e655686a 100644 --- a/frontend/src/components/tour/tour-steps.tsx +++ b/frontend/src/components/tour/tour-steps.tsx @@ -312,7 +312,7 @@ export function getSteps(
Sample Log
- You can left click into a log for a detailed view. + Click a log to copy it. Hover over a long message to see all of it.
), diff --git a/frontend/src/lib/util/logs.ts b/frontend/src/lib/util/logs.ts new file mode 100644 index 00000000..6bf5ea00 --- /dev/null +++ b/frontend/src/lib/util/logs.ts @@ -0,0 +1,109 @@ +import { components } from "@/schemas/dwe_os_2"; + +type Log = components["schemas"]["LogSchema"]; + +// "2026-10-15 05:14:50.293" -> ["2026-10-15", "05:14:50.293"]. The backend +// uses "." before the milliseconds; Python's logging default is ",". +export function splitTimestamp(timestamp: string): [string, string] { + const match = timestamp.match( + /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(?:[.,](\d+))?/, + ); + if (!match) return [timestamp, ""]; + return [match[1], match[3] ? `${match[2]}.${match[3]}` : match[2]]; +} + +/** + * One log for the clipboard, like the backend's log file but with the message + * on its own line: + * " [] : ():\n" + */ +export function formatLogForCopy(log: Log): string { + return `${log.timestamp} ${log.level} [${log.name}] ${log.filename}:${log.lineno} ${log.function}():\n${log.message}`; +} + +// RFC 4180: quote fields containing a delimiter, quote or line break +const csvField = (value: string | number): string => { + const text = String(value); + return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +}; + +/** CSV with the same columns as the logs table, plus a BOM so Excel reads it as UTF-8 */ +export function logsToCsv(logs: Log[]): string { + const header = [ + "Date", + "Time", + "Level", + "Logger", + "File", + "Line", + "Function", + "Message", + ]; + const rows = logs.map((log) => [ + ...splitTimestamp(log.timestamp), + log.level, + log.name, + log.filename, + log.lineno, + log.function, + log.message, + ]); + return ( + "\ufeff" + + [header, ...rows] + .map((row) => row.map(csvField).join(",") + "\r\n") + .join("") + ); +} + +export function getLogExportFileName(now: Date = new Date()): string { + const pad = (n: number) => n.toString().padStart(2, "0"); + const stamp = + `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` + + `T${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`; + return `dweos-logs-${stamp}.csv`; +} + +export function downloadFile( + fileName: string, + contents: string, + type: string, +): void { + const url = URL.createObjectURL(new Blob([contents], { type })); + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + link.click(); + URL.revokeObjectURL(url); +} + +/** + * navigator.clipboard only exists on secure origins (HTTPS or localhost), and + * dweOS is usually opened over plain HTTP on the vehicle's IP, so fall back to + * the legacy copy command there. + */ +export async function copyText(text: string): Promise { + if (window.isSecureContext && navigator.clipboard) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Fall through to the legacy path + } + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + try { + return document.execCommand("copy"); + } catch { + return false; + } finally { + document.body.removeChild(textarea); + } +}