From fee686a9f311807b5afc661712b03ffe9eadcfbd Mon Sep 17 00:00:00 2001 From: bonamin Date: Wed, 25 Mar 2026 10:39:32 +0200 Subject: [PATCH] feat: add Serial Monitor public page and Log Viewer settings page - New public page at /serial-monitor: connects to Web Serial (115200 baud), streams live output, saves sessions to localStorage + downloads .txt - New protected page at /settings/serial-logs (admin/sysadmin only): lists saved sessions, expandable with full scrollable log, search, export and delete per session - Registered routes in App.jsx and added Log Viewer to Console Settings sidebar Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/App.jsx | 11 +- frontend/src/layout/Sidebar.jsx | 1 + frontend/src/serial/SerialLogViewer.jsx | 242 +++++++++++++++ frontend/src/serial/SerialMonitorPage.jsx | 340 ++++++++++++++++++++++ 4 files changed, 590 insertions(+), 4 deletions(-) create mode 100644 frontend/src/serial/SerialLogViewer.jsx create mode 100644 frontend/src/serial/SerialMonitorPage.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 95e4ade..88bb888 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,8 @@ import { Routes, Route, Navigate } from "react-router-dom"; import { useAuth } from "./auth/AuthContext"; import CloudFlashPage from "./cloudflash/CloudFlashPage"; +import SerialMonitorPage from "./serial/SerialMonitorPage"; +import SerialLogViewer from "./serial/SerialLogViewer"; import PublicFeaturesSettings from "./settings/PublicFeaturesSettings"; import LoginPage from "./auth/LoginPage"; import MainLayout from "./layout/MainLayout"; @@ -35,7 +37,7 @@ import DashboardPage from "./dashboard/DashboardPage"; import ApiReferencePage from "./developer/ApiReferencePage"; import { ProductList, ProductForm } from "./crm/products"; import { CustomerList, CustomerForm, CustomerDetail } from "./crm/customers"; -import { OrderList, OrderForm, OrderDetail } from "./crm/orders"; +import { OrderList } from "./crm/orders"; import { QuotationForm, AllQuotationsList } from "./crm/quotations"; import CommsPage from "./crm/inbox/CommsPage"; import MailPage from "./crm/mail/MailPage"; @@ -110,6 +112,7 @@ export default function App() { {/* Public routes — no login required */} } /> + } /> } /> } /> } /> } /> - } /> - } /> - } /> } /> } /> } /> @@ -196,6 +196,9 @@ export default function App() { {/* Settings - Public Features */} } /> + {/* Settings - Serial Log Viewer */} + } /> + } /> diff --git a/frontend/src/layout/Sidebar.jsx b/frontend/src/layout/Sidebar.jsx index c6506c7..3173ec7 100644 --- a/frontend/src/layout/Sidebar.jsx +++ b/frontend/src/layout/Sidebar.jsx @@ -147,6 +147,7 @@ const navSections = [ const settingsChildren = [ { to: "/settings/staff", label: "Staff Management", icon: StaffIcon }, { to: "/settings/public-features", label: "Public Features", icon: SettingsIcon }, + { to: "/settings/serial-logs", label: "Log Viewer", icon: BlackBoxIcon }, { to: "/settings/pages", label: "Page Settings", icon: PlaceholderIcon, placeholder: true }, ]; diff --git a/frontend/src/serial/SerialLogViewer.jsx b/frontend/src/serial/SerialLogViewer.jsx new file mode 100644 index 0000000..841e384 --- /dev/null +++ b/frontend/src/serial/SerialLogViewer.jsx @@ -0,0 +1,242 @@ +import { useState, useEffect } from "react"; + +function formatDate(isoString) { + try { + return new Date(isoString).toLocaleString(); + } catch { + return isoString; + } +} + +function LevelBadge({ line }) { + const text = line.toUpperCase(); + if (text.includes("ERROR") || text.includes("ERR") || text.includes("[E]")) { + return ERR; + } + if (text.includes("WARN") || text.includes("[W]")) { + return WRN; + } + return null; +} + +function lineColor(text) { + const t = text.toUpperCase(); + if (t.includes("ERROR") || t.includes("ERR]") || t.includes("[E]")) return "var(--danger-text)"; + if (t.includes("WARN") || t.includes("[W]")) return "#fb923c"; + return "#7ec87e"; +} + +export default function SerialLogViewer() { + const [sessions, setSessions] = useState([]); + const [expanded, setExpanded] = useState(null); + const [search, setSearch] = useState(""); + + useEffect(() => { + const raw = localStorage.getItem("bellsystems_serial_logs"); + if (raw) { + try { setSessions(JSON.parse(raw)); } catch (_) {} + } + }, []); + + const handleDelete = (id) => { + const updated = sessions.filter((s) => s.id !== id); + setSessions(updated); + localStorage.setItem("bellsystems_serial_logs", JSON.stringify(updated)); + if (expanded === id) setExpanded(null); + }; + + const handleExport = (session) => { + const text = session.lines.map((l) => `[${l.ts}] ${l.text}`).join("\n"); + const blob = new Blob([text], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `serial-log-${session.label}.txt`; + a.click(); + URL.revokeObjectURL(url); + }; + + const filteredSessions = sessions.filter((s) => { + if (!search) return true; + const q = search.toLowerCase(); + return ( + s.label.toLowerCase().includes(q) || + s.lines?.some((l) => l.text.toLowerCase().includes(q)) + ); + }); + + return ( +
+ {/* Page header */} +
+
+

Serial Log Viewer

+

+ Saved sessions from the Serial Monitor page. +

+
+ + {sessions.length} session{sessions.length !== 1 ? "s" : ""} stored + +
+ + {/* Search */} + {sessions.length > 0 && ( +
+ setSearch(e.target.value)} + className="w-full px-3 py-2 rounded-md text-sm" + style={{ + backgroundColor: "var(--bg-card)", + border: "1px solid var(--border-input)", + color: "var(--text-primary)", + outline: "none", + }} + /> +
+ )} + + {/* Empty state */} + {sessions.length === 0 && ( +
+ + + +

No saved logs yet

+

+ Use the Serial Monitor page to connect and save sessions. +

+
+ )} + + {/* Session list */} +
+ {filteredSessions.map((session) => { + const isOpen = expanded === session.id; + + // If searching, filter lines too + const displayLines = search + ? session.lines?.filter((l) => l.text.toLowerCase().includes(search.toLowerCase())) + : session.lines; + + return ( +
+ {/* Session header row */} + + + {/* Expanded log panel */} + {isOpen && ( +
+ {/* Toolbar */} +
+ + {search ? `${displayLines?.length ?? 0} of ${session.lineCount} lines match` : `${session.lineCount} lines`} + +
+ + +
+
+ + {/* Log content */} +
+ {(!displayLines || displayLines.length === 0) ? ( + No lines to display. + ) : ( + displayLines.map((l, i) => ( +
+ + {l.ts} + + {l.text} +
+ )) + )} +
+
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/serial/SerialMonitorPage.jsx b/frontend/src/serial/SerialMonitorPage.jsx new file mode 100644 index 0000000..ee3dea5 --- /dev/null +++ b/frontend/src/serial/SerialMonitorPage.jsx @@ -0,0 +1,340 @@ +import { useState, useRef, useEffect, useCallback } from "react"; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function formatTimestamp(date) { + return date.toISOString().replace("T", " ").replace("Z", ""); +} + +function saveLogsToFile(lines, sessionLabel) { + const text = lines.map((l) => `[${l.ts}] ${l.text}`).join("\n"); + const blob = new Blob([text], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `serial-log-${sessionLabel}.txt`; + a.click(); + URL.revokeObjectURL(url); +} + +function persistSession(lines) { + const saved = JSON.parse(localStorage.getItem("bellsystems_serial_logs") || "[]"); + const label = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const session = { + id: Date.now(), + label, + savedAt: new Date().toISOString(), + lineCount: lines.length, + lines, + }; + saved.unshift(session); + // Keep last 20 sessions + localStorage.setItem("bellsystems_serial_logs", JSON.stringify(saved.slice(0, 20))); + return label; +} + +// ─── sub-components ─────────────────────────────────────────────────────────── + +function InfoBox({ type = "info", children }) { + const styles = { + info: { bg: "var(--badge-blue-bg)", border: "#1e3a5f", color: "var(--badge-blue-text)" }, + warning: { bg: "#2e1a00", border: "#7c4a00", color: "#fb923c" }, + error: { bg: "var(--danger-bg)", border: "var(--danger)", color: "var(--danger-text)" }, + success: { bg: "var(--success-bg)", border: "var(--success)", color: "var(--success-text)" }, + }; + const s = styles[type] || styles.info; + return ( +
+ {children} +
+ ); +} + +// ─── Main Page ──────────────────────────────────────────────────────────────── + +export default function SerialMonitorPage() { + const [connected, setConnected] = useState(false); + const [connecting, setConnecting] = useState(false); + const [portName, setPortName] = useState(""); + const [lines, setLines] = useState([]); + const [error, setError] = useState(""); + const [saved, setSaved] = useState(false); + const [savedLabel, setSavedLabel] = useState(""); + + const portRef = useRef(null); + const readerRef = useRef(null); + const readingRef = useRef(false); + const logEndRef = useRef(null); + const linesRef = useRef([]); // mirror for use inside async loop + + const webSerialAvailable = "serial" in navigator; + + // Keep linesRef in sync + useEffect(() => { + linesRef.current = lines; + }, [lines]); + + // Auto-scroll + useEffect(() => { + logEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [lines]); + + const appendLine = useCallback((text) => { + const entry = { ts: formatTimestamp(new Date()), text }; + setLines((prev) => [...prev, entry]); + }, []); + + const startReading = useCallback(async (port) => { + const decoder = new TextDecoderStream(); + port.readable.pipeTo(decoder.writable).catch(() => {}); + const reader = decoder.readable.getReader(); + readerRef.current = reader; + readingRef.current = true; + + let buffer = ""; + try { + while (readingRef.current) { + const { value, done } = await reader.read(); + if (done) break; + buffer += value; + const parts = buffer.split("\n"); + buffer = parts.pop(); // keep incomplete line + for (const part of parts) { + const trimmed = part.replace(/\r$/, ""); + if (trimmed) appendLine(trimmed); + } + } + } catch (err) { + if (readingRef.current) { + setError("Serial connection lost: " + (err.message || String(err))); + setConnected(false); + } + } + }, [appendLine]); + + const handleConnect = async () => { + setError(""); + setSaved(false); + setConnecting(true); + try { + const port = await navigator.serial.requestPort(); + await port.open({ baudRate: 115200 }); + + const info = port.getInfo?.() || {}; + const label = info.usbVendorId + ? `USB ${info.usbVendorId.toString(16).toUpperCase()}:${(info.usbProductId || 0).toString(16).toUpperCase()}` + : "Serial Port"; + + portRef.current = port; + setPortName(label); + setConnected(true); + setLines([]); + appendLine("--- Serial monitor connected ---"); + startReading(port); + } catch (err) { + if (err.name !== "NotFoundError") { + setError(err.message || "Failed to open port."); + } + } finally { + setConnecting(false); + } + }; + + const handleDisconnect = async () => { + readingRef.current = false; + try { readerRef.current?.cancel(); } catch (_) {} + try { await portRef.current?.close(); } catch (_) {} + portRef.current = null; + readerRef.current = null; + setConnected(false); + appendLine("--- Disconnected ---"); + }; + + const handleSave = () => { + if (lines.length === 0) return; + const label = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + // Save to localStorage for the Log Viewer page + persistSession(lines); + // Also download as a .txt file + saveLogsToFile(lines, label); + setSaved(true); + setSavedLabel(label); + }; + + const handleClear = () => { + setLines([]); + setSaved(false); + }; + + return ( +
+ {/* ── Top bar ── */} +
+
+ + + + + BellSystems — Serial Monitor + +
+ +
+ {/* Connection indicator */} + + + {connected ? portName || "Connected" : "Not connected"} + +
+
+ + {/* ── Body ── */} +
+ + {/* Browser support warning */} + {!webSerialAvailable && ( + + Browser not supported. Serial Monitor requires Google Chrome or Microsoft Edge on a desktop. + Safari, Firefox, and mobile browsers are not supported. + + )} + + {/* Error */} + {error && {error}} + + {/* Success feedback */} + {saved && ( + + Logs saved! Downloaded as serial-log-{savedLabel}.txt and stored in Log Viewer. + + )} + + {/* Controls */} +
+
+ {!connected ? ( + + ) : ( + + )} + + + {lines.length > 0 ? `${lines.length} lines` : "No output yet"} + +
+ +
+ + +
+
+ + {/* How-to hint when not connected */} + {!connected && lines.length === 0 && ( +
+

+ How to use +

+ {[ + "Connect your BellSystems device via USB cable.", + "Click \"Connect to Port\" — a browser popup will appear.", + "Select the COM port for your device (look for CP210x, CH340, or USB Serial).", + "Serial output will appear live at 115200 baud.", + "When finished, click \"Save Logs\" to download a .txt file and store it in Log Viewer.", + ].map((step, i) => ( +
+ {i + 1}. + {step} +
+ ))} +
+ )} + + {/* Log output */} +
+ {lines.length === 0 ? ( + Waiting for serial output… + ) : ( + lines.map((l, i) => ( +
+ {l.ts} + {l.text} +
+ )) + )} +
+
+
+ + {/* ── Footer ── */} +
+ + BellSystems Serial Monitor · 115200 baud · Chrome / Edge only + +
+
+ ); +}