diff --git a/frontend/src/devices/DeviceDetail.jsx b/frontend/src/devices/DeviceDetail.jsx
index be6f65a..9313e64 100644
--- a/frontend/src/devices/DeviceDetail.jsx
+++ b/frontend/src/devices/DeviceDetail.jsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from "react";
+import { useState, useEffect, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { MapContainer, TileLayer, Marker } from "react-leaflet";
import "leaflet/dist/leaflet.css";
@@ -7,6 +7,7 @@ import api from "../api/client";
import { useAuth } from "../auth/AuthContext";
import ConfirmDialog from "../components/ConfirmDialog";
import NotesPanel from "../equipment/NotesPanel";
+import useMqttWebSocket from "../mqtt/useMqttWebSocket";
// Fix default Leaflet marker icon
delete L.Icon.Default.prototype._getIconUrl;
@@ -59,11 +60,23 @@ function Subsection({ title, children, isFirst = false }) {
return (
{title}
-
{children}
+ {children}
);
}
+/** A single row of fields that wraps if it doesn't fit */
+function FieldRow({ children }) {
+ return {children}
;
+}
+
+// --- Log level styles ---
+const LOG_LEVEL_STYLES = {
+ INFO: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
+ WARN: { bg: "#3d2e00", color: "#fbbf24" },
+ ERROR: { bg: "var(--danger-bg)", color: "var(--danger-text)" },
+};
+
// --- Date / time helpers ---
function parseFirestoreDate(str) {
@@ -121,15 +134,12 @@ function daysToDisplay(days) {
}
function formatTimestamp(str) {
- // Try to parse as a Firestore timestamp and return HH:MM
if (!str) return "-";
const d = parseFirestoreDate(str);
if (d) {
return `${d.getUTCHours().toString().padStart(2, "0")}:${d.getUTCMinutes().toString().padStart(2, "0")}`;
}
- // Maybe it's already a time string like "14:00"
if (/^\d{1,2}:\d{2}/.test(str)) return str;
- // Try to extract time from the string
const match = str.match(/(\d{1,2}):(\d{2})/);
if (match) return `${match[1].padStart(2, "0")}:${match[2]}`;
return str;
@@ -144,7 +154,6 @@ function msToSeconds(ms) {
function parseCoordinates(coordStr) {
if (!coordStr) return null;
- // Handle "lat,lng" or "lat° N, lng° E" or similar
const cleaned = coordStr.replace(/°\s*[NSEW]/gi, "").trim();
const parts = cleaned.split(",").map((s) => parseFloat(s.trim()));
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
@@ -160,6 +169,159 @@ function formatCoordinates(coords) {
return `${Math.abs(coords.lat).toFixed(7)}° ${latDir}, ${Math.abs(coords.lng).toFixed(7)}° ${lngDir}`;
}
+// --- Device Logs Panel ---
+
+function DeviceLogsPanel({ deviceSerial }) {
+ const [logs, setLogs] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [levelFilter, setLevelFilter] = useState("");
+ const [searchText, setSearchText] = useState("");
+ const [autoRefresh, setAutoRefresh] = useState(false);
+ const [liveLogs, setLiveLogs] = useState([]);
+ const limit = 15;
+
+ const fetchLogs = useCallback(async () => {
+ if (!deviceSerial) return;
+ setLoading(true);
+ try {
+ const params = new URLSearchParams();
+ if (levelFilter) params.set("level", levelFilter);
+ if (searchText) params.set("search", searchText);
+ params.set("limit", limit.toString());
+ params.set("offset", "0");
+ const data = await api.get(`/mqtt/logs/${deviceSerial}?${params}`);
+ setLogs(data.logs || []);
+ } catch {
+ // silently fail in compact view
+ } finally {
+ setLoading(false);
+ }
+ }, [deviceSerial, levelFilter, searchText]);
+
+ useEffect(() => {
+ if (deviceSerial) fetchLogs();
+ }, [deviceSerial, fetchLogs]);
+
+ useEffect(() => {
+ if (!autoRefresh || !deviceSerial) return;
+ const interval = setInterval(fetchLogs, 5000);
+ return () => clearInterval(interval);
+ }, [autoRefresh, deviceSerial, fetchLogs]);
+
+ const handleWsMessage = useCallback(
+ (data) => {
+ if (data.type === "logs" && data.device_serial === deviceSerial) {
+ const logEntry = {
+ id: Date.now(),
+ level: data.payload?.level?.includes("EROR") ? "ERROR" : data.payload?.level?.includes("WARN") ? "WARN" : "INFO",
+ message: data.payload?.message || "",
+ received_at: new Date().toISOString(),
+ _live: true,
+ };
+ setLiveLogs((prev) => [logEntry, ...prev].slice(0, 50));
+ }
+ },
+ [deviceSerial]
+ );
+
+ useMqttWebSocket({ enabled: true, onMessage: handleWsMessage });
+
+ const allLogs = [...liveLogs.filter((l) => {
+ if (levelFilter && l.level !== levelFilter) return false;
+ if (searchText && !l.message.toLowerCase().includes(searchText.toLowerCase())) return false;
+ return true;
+ }), ...logs];
+
+ return (
+
+ {/* Controls */}
+
+
+
+
+
+ setSearchText(e.target.value)}
+ placeholder="Search log messages..."
+ className="w-full px-2 py-1.5 rounded-md text-xs border"
+ />
+
+
+
+
+
+ {/* Log table */}
+ {loading && logs.length === 0 ? (
+ Loading...
+ ) : allLogs.length === 0 ? (
+ No logs found.
+ ) : (
+
+
+
+
+
+ | Time |
+ Level |
+ Message |
+
+
+
+ {allLogs.slice(0, limit).map((log, index) => {
+ const style = LOG_LEVEL_STYLES[log.level] || LOG_LEVEL_STYLES.INFO;
+ return (
+
+ |
+ {log.received_at?.replace("T", " ").substring(0, 19)}
+ {log._live && LIVE}
+ |
+
+
+ {log.level}
+
+ |
+
+ {log.message}
+ |
+
+ );
+ })}
+
+
+
+
+ )}
+
+ );
+}
+
// --- Main component ---
export default function DeviceDetail() {
@@ -298,31 +460,196 @@ export default function DeviceDetail() {
)}
- {/* Masonry layout for single-width sections */}
- {/* Basic Information */}
-
-
-
-
-
- {mqttStatus && (
-
- (MQTT {mqttStatus.seconds_since_heartbeat}s ago)
-
- )}
-
-
-
-
- {device.device_id}
-
-
- {device.id}
-
-
-
-
+ {/* ===== BASIC INFORMATION (double-width) ===== */}
+
+
+
+ {/* Status — fancy */}
+
+
+
+
+
+
Status
+
+ {isOnline ? "Online" : "Offline"}
+ {mqttStatus && (
+
+ (MQTT {mqttStatus.seconds_since_heartbeat}s ago)
+
+ )}
+
+
+
+
+ {/* Serial + Hardware Variant */}
+
+
Serial Number
+
+ {device.device_id}
+ HW: -
+
+
+
+ {/* Quick Admin Notes */}
+
+
+ {/* Document ID */}
+
+
Document ID
+
{device.id}
+
+
+
+
+
+ {/* ===== DEVICE SETTINGS (double-width) ===== */}
+
+
+
+ {/* Left Column */}
+
+ {/* Basic Attributes */}
+
+
+
+
+
+
+
+
+ {/* Alert Settings */}
+
+
+
+ {clock.ringAlerts || "-"}
+ {clock.ringIntervals}
+
+
+ {clock.hourAlertsBell}
+ {clock.halfhourAlertsBell}
+ {clock.quarterAlertsBell}
+
+
+
+
+ {formatTimestamp(clock.daySilenceFrom)} - {formatTimestamp(clock.daySilenceTo)}
+
+
+
+
+
+ {formatTimestamp(clock.nightSilenceFrom)} - {formatTimestamp(clock.nightSilenceTo)}
+
+
+
+
+ {/* Backlight Settings */}
+
+
+
+ {clock.backlightOutput}
+
+ {formatTimestamp(clock.backlightTurnOnTime)} - {formatTimestamp(clock.backlightTurnOffTime)}
+
+
+
+
+ {/* Logging */}
+
+
+ {attr.serialLogLevel}
+ {attr.sdLogLevel}
+ {attr.mqttLogLevel ?? 0}
+
+
+
+
+ {/* Right Column */}
+
+ {/* Network */}
+
+
+ {net.hostname}
+
+
+
+
+ {/* Clock Settings */}
+
+
+
+ {clock.ringIntervals}
+
+
+ {clock.clockOutputs?.[0] ?? "-"}
+ {clock.clockOutputs?.[1] ?? "-"}
+
+
+ {clock.clockTimings?.[0] != null ? msToSeconds(clock.clockTimings[0]) : "-"}
+ {clock.clockTimings?.[1] != null ? msToSeconds(clock.clockTimings[1]) : "-"}
+
+
+
+ {/* Bell Settings */}
+
+
+
+ {attr.totalBells ?? "-"}
+
+ {/* Bell Output-to-Timing mapping with watermark numbers */}
+ {attr.bellOutputs?.length > 0 && (
+
+
+ Output & Timing Map
+
+
+ {attr.bellOutputs.map((output, i) => (
+
+ {/* Watermark number */}
+
+ {i + 1}
+
+ {/* Content */}
+
+
+ Output {output}
+
+
+ {attr.hammerTimings?.[i] != null ? (
+ <>{attr.hammerTimings[i]} ms>
+ ) : "-"}
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+ {/* ===== SINGLE-WIDTH SECTIONS ===== */}
{/* Misc */}
@@ -370,14 +697,14 @@ export default function DeviceDetail() {
{device.device_location}
-
- {coords ? formatCoordinates(coords) : device.device_location_coordinates || "-"}
+
- {/* Device Settings — full width, outside masonry flow */}
-
-
-
- {/* Left Column */}
-
- {/* Basic Attributes */}
-
-
-
-
-
-
- {/* Alert Settings */}
-
-
- {clock.ringAlerts || "-"}
- {clock.ringIntervals}
- {clock.hourAlertsBell}
- {clock.halfhourAlertsBell}
- {clock.quarterAlertsBell}
-
-
- {formatTimestamp(clock.daySilenceFrom)} - {formatTimestamp(clock.daySilenceTo)}
-
-
-
- {formatTimestamp(clock.nightSilenceFrom)} - {formatTimestamp(clock.nightSilenceTo)}
-
-
-
- {/* Backlight Settings */}
-
-
- {clock.backlightOutput}
-
- {formatTimestamp(clock.backlightTurnOnTime)} - {formatTimestamp(clock.backlightTurnOffTime)}
-
-
-
- {/* Logging */}
-
- {attr.serialLogLevel}
- {attr.sdLogLevel}
- {attr.mqttLogLevel ?? 0}
-
-
-
- {/* Right Column */}
-
- {/* Network */}
-
- {net.hostname}
-
-
-
- {/* Clock Settings */}
-
-
- {clock.ringIntervals}
- {clock.clockOutputs?.[0] ?? "-"}
- {clock.clockOutputs?.[1] ?? "-"}
- {clock.clockTimings?.[0] != null ? msToSeconds(clock.clockTimings[0]) : "-"}
- {clock.clockTimings?.[1] != null ? msToSeconds(clock.clockTimings[1]) : "-"}
-
-
- {/* Bell Settings */}
-
-
- {attr.totalBells ?? "-"}
- {/* Bell Output-to-Timing mapping */}
- {attr.bellOutputs?.length > 0 && (
-
-
-
- Output & Timing Map
-
-
- {attr.bellOutputs.map((output, i) => (
-
-
- Bell {i + 1}
-
-
- Output {output}
-
-
- {attr.hammerTimings?.[i] != null ? (
- <>{attr.hammerTimings[i]} ms>
- ) : "-"}
-
-
- ))}
-
-
- )}
-
-
-
-
+ {/* Latest Logs */}
+
* {
- break-inside: avoid;
- margin-bottom: 1.5rem;
+.device-sections > .section-wide {
+ grid-column: 1 / -1;
}
@media (min-width: 900px) {
.device-sections {
- columns: 2;
+ grid-template-columns: repeat(2, 1fr);
+ grid-auto-flow: dense;
}
}
-@media (min-width: 1800px) {
+@media (min-width: 2100px) {
.device-sections {
- columns: 3;
+ grid-template-columns: repeat(3, 1fr);
+ }
+ .device-sections > .section-wide {
+ grid-column: span 2;
}
}