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 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 10:39:32 +02:00
parent b2d1e2bdc4
commit fee686a9f3
4 changed files with 590 additions and 4 deletions

View File

@@ -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() {
<Routes>
{/* Public routes — no login required */}
<Route path="/cloudflash" element={<CloudFlashPage />} />
<Route path="/serial-monitor" element={<SerialMonitorPage />} />
<Route path="/login" element={<LoginPage />} />
<Route
@@ -176,9 +179,6 @@ export default function App() {
<Route path="crm/customers/:id" element={<PermissionGate section="crm"><CustomerDetail /></PermissionGate>} />
<Route path="crm/customers/:id/edit" element={<PermissionGate section="crm" action="edit"><CustomerForm /></PermissionGate>} />
<Route path="crm/orders" element={<PermissionGate section="crm"><OrderList /></PermissionGate>} />
<Route path="crm/orders/new" element={<PermissionGate section="crm" action="edit"><OrderForm /></PermissionGate>} />
<Route path="crm/orders/:id" element={<PermissionGate section="crm"><OrderDetail /></PermissionGate>} />
<Route path="crm/orders/:id/edit" element={<PermissionGate section="crm" action="edit"><OrderForm /></PermissionGate>} />
<Route path="crm/quotations" element={<PermissionGate section="crm"><AllQuotationsList /></PermissionGate>} />
<Route path="crm/quotations/new" element={<PermissionGate section="crm" action="edit"><QuotationForm /></PermissionGate>} />
<Route path="crm/quotations/:id" element={<PermissionGate section="crm" action="edit"><QuotationForm /></PermissionGate>} />
@@ -196,6 +196,9 @@ export default function App() {
{/* Settings - Public Features */}
<Route path="settings/public-features" element={<RoleGate roles={["sysadmin", "admin"]}><PublicFeaturesSettings /></RoleGate>} />
{/* Settings - Serial Log Viewer */}
<Route path="settings/serial-logs" element={<RoleGate roles={["sysadmin", "admin"]}><SerialLogViewer /></RoleGate>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>

View File

@@ -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 },
];

View File

@@ -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 <span className="text-xs font-bold" style={{ color: "var(--danger)" }}>ERR</span>;
}
if (text.includes("WARN") || text.includes("[W]")) {
return <span className="text-xs font-bold" style={{ color: "#fb923c" }}>WRN</span>;
}
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 (
<div>
{/* Page header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold" style={{ color: "var(--text-heading)" }}>Serial Log Viewer</h1>
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>
Saved sessions from the Serial Monitor page.
</p>
</div>
<span className="text-sm" style={{ color: "var(--text-muted)" }}>
{sessions.length} session{sessions.length !== 1 ? "s" : ""} stored
</span>
</div>
{/* Search */}
{sessions.length > 0 && (
<div className="mb-4">
<input
type="text"
placeholder="Search sessions or log content…"
value={search}
onChange={(e) => 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",
}}
/>
</div>
)}
{/* Empty state */}
{sessions.length === 0 && (
<div
className="rounded-lg border p-10 text-center"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<svg className="w-10 h-10 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"
style={{ color: "var(--text-muted)" }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>No saved logs yet</p>
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
Use the Serial Monitor page to connect and save sessions.
</p>
</div>
)}
{/* Session list */}
<div className="space-y-2">
{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 (
<div
key={session.id}
className="rounded-lg border overflow-hidden"
style={{ backgroundColor: "var(--bg-card)", borderColor: isOpen ? "var(--accent)" : "var(--border-primary)" }}
>
{/* Session header row */}
<button
type="button"
onClick={() => 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"
>
<div className="flex items-center gap-3">
{/* Chevron */}
<svg
className={`w-4 h-4 flex-shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24"
style={{ color: "var(--text-muted)" }}
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
{/* Icon */}
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"
style={{ color: isOpen ? "var(--accent)" : "var(--text-muted)" }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8}
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<div>
<span className="text-sm font-medium font-mono" style={{ color: "var(--text-heading)" }}>
{session.label}
</span>
<span className="text-xs ml-3" style={{ color: "var(--text-muted)" }}>
{formatDate(session.savedAt)}
</span>
</div>
</div>
<div className="flex items-center gap-3">
<span
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}
>
{session.lineCount} lines
</span>
</div>
</button>
{/* Expanded log panel */}
{isOpen && (
<div style={{ borderTop: "1px solid var(--border-primary)" }}>
{/* Toolbar */}
<div
className="flex items-center justify-between px-4 py-2"
style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}
>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
{search ? `${displayLines?.length ?? 0} of ${session.lineCount} lines match` : `${session.lineCount} lines`}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => 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)" }}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Export .txt
</button>
<button
onClick={() => 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
</button>
</div>
</div>
{/* Log content */}
<div
className="overflow-y-auto font-mono text-xs"
style={{
backgroundColor: "#070d07",
maxHeight: 500,
padding: "10px 14px",
}}
>
{(!displayLines || displayLines.length === 0) ? (
<span style={{ color: "#3a5c3a" }}>No lines to display.</span>
) : (
displayLines.map((l, i) => (
<div key={i} className="leading-relaxed flex items-start gap-2">
<span className="flex-shrink-0" style={{ color: "#3a6b3a", userSelect: "none" }}>
{l.ts}
</span>
<span style={{ color: lineColor(l.text) }}>{l.text}</span>
</div>
))
)}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -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 (
<div
className="text-sm rounded-md p-3 border"
style={{ backgroundColor: s.bg, borderColor: s.border, color: s.color }}
>
{children}
</div>
);
}
// ─── 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 (
<div
className="min-h-screen flex flex-col"
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-primary)" }}
>
{/* ── Top bar ── */}
<div
className="flex items-center justify-between px-6 py-4 border-b"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="flex items-center gap-3">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: "var(--accent)" }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8}
d="M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18" />
</svg>
<span className="font-bold text-base" style={{ color: "var(--text-heading)" }}>
BellSystems Serial Monitor
</span>
</div>
<div className="flex items-center gap-2">
{/* Connection indicator */}
<span
className="inline-block w-2 h-2 rounded-full"
style={{ backgroundColor: connected ? "#22c55e" : "var(--border-primary)" }}
/>
<span className="text-sm" style={{ color: connected ? "var(--text-heading)" : "var(--text-muted)" }}>
{connected ? portName || "Connected" : "Not connected"}
</span>
</div>
</div>
{/* ── Body ── */}
<div className="flex-1 flex flex-col max-w-4xl w-full mx-auto px-6 py-6 gap-4">
{/* Browser support warning */}
{!webSerialAvailable && (
<InfoBox type="warning">
<strong>Browser not supported.</strong> Serial Monitor requires Google Chrome or Microsoft Edge on a desktop.
Safari, Firefox, and mobile browsers are not supported.
</InfoBox>
)}
{/* Error */}
{error && <InfoBox type="error">{error}</InfoBox>}
{/* Success feedback */}
{saved && (
<InfoBox type="success">
Logs saved! Downloaded as <strong>serial-log-{savedLabel}.txt</strong> and stored in Log Viewer.
</InfoBox>
)}
{/* Controls */}
<div
className="rounded-lg border p-4 flex flex-wrap items-center justify-between gap-3"
style={{ backgroundColor: "var(--bg-card)", borderColor: connected ? "var(--accent)" : "var(--border-primary)" }}
>
<div className="flex items-center gap-3">
{!connected ? (
<button
onClick={handleConnect}
disabled={!webSerialAvailable || connecting}
className="px-5 py-2 rounded-md text-sm font-semibold cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90 transition-opacity"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{connecting ? "Waiting…" : "Connect to Port"}
</button>
) : (
<button
onClick={handleDisconnect}
className="px-5 py-2 rounded-md text-sm font-medium cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
Disconnect
</button>
)}
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
{lines.length > 0 ? `${lines.length} lines` : "No output yet"}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleClear}
disabled={lines.length === 0}
className="px-4 py-2 rounded-md text-sm font-medium cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-80 transition-opacity"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }}
>
Clear
</button>
<button
onClick={handleSave}
disabled={lines.length === 0}
className="px-5 py-2 rounded-md text-sm font-semibold cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90 transition-opacity flex items-center gap-2"
style={{ backgroundColor: "var(--accent)", color: "var(--bg-primary)" }}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
</svg>
Save Logs
</button>
</div>
</div>
{/* How-to hint when not connected */}
{!connected && lines.length === 0 && (
<div
className="rounded-lg border p-4 space-y-2"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-secondary)" }}
>
<p className="text-xs font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
How to use
</p>
{[
"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) => (
<div key={i} className="flex items-start gap-2">
<span className="text-xs font-bold flex-shrink-0 mt-0.5" style={{ color: "var(--accent)" }}>{i + 1}.</span>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>{step}</span>
</div>
))}
</div>
)}
{/* Log output */}
<div
className="rounded-md border overflow-y-auto font-mono text-xs flex-1"
style={{
backgroundColor: "#070d07",
borderColor: "var(--border-secondary)",
color: "#7ec87e",
minHeight: 400,
maxHeight: "calc(100vh - 340px)",
padding: "12px 14px",
}}
>
{lines.length === 0 ? (
<span style={{ color: "#3a5c3a" }}>Waiting for serial output</span>
) : (
lines.map((l, i) => (
<div key={i} className="leading-relaxed">
<span style={{ color: "#3a6b3a", userSelect: "none" }}>{l.ts} </span>
<span>{l.text}</span>
</div>
))
)}
<div ref={logEndRef} />
</div>
</div>
{/* ── Footer ── */}
<div
className="border-t px-6 py-3 text-center"
style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-card)" }}
>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
BellSystems Serial Monitor · 115200 baud · Chrome / Edge only
</span>
</div>
</div>
);
}