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:
@@ -1,6 +1,8 @@
|
|||||||
import { Routes, Route, Navigate } from "react-router-dom";
|
import { Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { useAuth } from "./auth/AuthContext";
|
import { useAuth } from "./auth/AuthContext";
|
||||||
import CloudFlashPage from "./cloudflash/CloudFlashPage";
|
import CloudFlashPage from "./cloudflash/CloudFlashPage";
|
||||||
|
import SerialMonitorPage from "./serial/SerialMonitorPage";
|
||||||
|
import SerialLogViewer from "./serial/SerialLogViewer";
|
||||||
import PublicFeaturesSettings from "./settings/PublicFeaturesSettings";
|
import PublicFeaturesSettings from "./settings/PublicFeaturesSettings";
|
||||||
import LoginPage from "./auth/LoginPage";
|
import LoginPage from "./auth/LoginPage";
|
||||||
import MainLayout from "./layout/MainLayout";
|
import MainLayout from "./layout/MainLayout";
|
||||||
@@ -35,7 +37,7 @@ import DashboardPage from "./dashboard/DashboardPage";
|
|||||||
import ApiReferencePage from "./developer/ApiReferencePage";
|
import ApiReferencePage from "./developer/ApiReferencePage";
|
||||||
import { ProductList, ProductForm } from "./crm/products";
|
import { ProductList, ProductForm } from "./crm/products";
|
||||||
import { CustomerList, CustomerForm, CustomerDetail } from "./crm/customers";
|
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 { QuotationForm, AllQuotationsList } from "./crm/quotations";
|
||||||
import CommsPage from "./crm/inbox/CommsPage";
|
import CommsPage from "./crm/inbox/CommsPage";
|
||||||
import MailPage from "./crm/mail/MailPage";
|
import MailPage from "./crm/mail/MailPage";
|
||||||
@@ -110,6 +112,7 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
{/* Public routes — no login required */}
|
{/* Public routes — no login required */}
|
||||||
<Route path="/cloudflash" element={<CloudFlashPage />} />
|
<Route path="/cloudflash" element={<CloudFlashPage />} />
|
||||||
|
<Route path="/serial-monitor" element={<SerialMonitorPage />} />
|
||||||
|
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route
|
<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" element={<PermissionGate section="crm"><CustomerDetail /></PermissionGate>} />
|
||||||
<Route path="crm/customers/:id/edit" element={<PermissionGate section="crm" action="edit"><CustomerForm /></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" 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" element={<PermissionGate section="crm"><AllQuotationsList /></PermissionGate>} />
|
||||||
<Route path="crm/quotations/new" element={<PermissionGate section="crm" action="edit"><QuotationForm /></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>} />
|
<Route path="crm/quotations/:id" element={<PermissionGate section="crm" action="edit"><QuotationForm /></PermissionGate>} />
|
||||||
@@ -196,6 +196,9 @@ export default function App() {
|
|||||||
{/* Settings - Public Features */}
|
{/* Settings - Public Features */}
|
||||||
<Route path="settings/public-features" element={<RoleGate roles={["sysadmin", "admin"]}><PublicFeaturesSettings /></RoleGate>} />
|
<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 path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ const navSections = [
|
|||||||
const settingsChildren = [
|
const settingsChildren = [
|
||||||
{ to: "/settings/staff", label: "Staff Management", icon: StaffIcon },
|
{ to: "/settings/staff", label: "Staff Management", icon: StaffIcon },
|
||||||
{ to: "/settings/public-features", label: "Public Features", icon: SettingsIcon },
|
{ 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 },
|
{ to: "/settings/pages", label: "Page Settings", icon: PlaceholderIcon, placeholder: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
242
frontend/src/serial/SerialLogViewer.jsx
Normal file
242
frontend/src/serial/SerialLogViewer.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
340
frontend/src/serial/SerialMonitorPage.jsx
Normal file
340
frontend/src/serial/SerialMonitorPage.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user