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 */}
+
setExpanded(isOpen ? null : session.id)}
+ className="w-full flex items-center justify-between px-4 py-3 text-left cursor-pointer hover:bg-[var(--bg-card-hover)] transition-colors"
+ >
+
+ {/* Chevron */}
+
+
+
+
+ {/* Icon */}
+
+
+
+
+
+
+ {session.label}
+
+
+ {formatDate(session.savedAt)}
+
+
+
+
+
+
+ {session.lineCount} lines
+
+
+
+
+ {/* Expanded log panel */}
+ {isOpen && (
+
+ {/* Toolbar */}
+
+
+ {search ? `${displayLines?.length ?? 0} of ${session.lineCount} lines match` : `${session.lineCount} lines`}
+
+
+
handleExport(session)}
+ className="px-3 py-1 rounded text-xs font-medium cursor-pointer hover:opacity-80 transition-opacity flex items-center gap-1"
+ style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
+ >
+
+
+
+ Export .txt
+
+
handleDelete(session.id)}
+ className="px-3 py-1 rounded text-xs font-medium cursor-pointer hover:opacity-80 transition-opacity"
+ style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)", border: "1px solid var(--danger)" }}
+ >
+ Delete
+
+
+
+
+ {/* 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 ? (
+
+ {connecting ? "Waiting…" : "Connect to Port"}
+
+ ) : (
+
+ Disconnect
+
+ )}
+
+
+ {lines.length > 0 ? `${lines.length} lines` : "No output yet"}
+
+
+
+
+
+ Clear
+
+
+
+
+
+ Save Logs
+
+
+
+
+ {/* 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
+
+
+
+ );
+}