From a6e0b1d46e07d9b376437810020c2ad4ae0934e1 Mon Sep 17 00:00:00 2001 From: bonamin Date: Wed, 18 Feb 2026 18:00:48 +0200 Subject: [PATCH] more fixes to the sections and responsiveness --- frontend/src/devices/DeviceDetail.jsx | 612 ++++++++++++++++++-------- frontend/src/index.css | 23 +- 2 files changed, 434 insertions(+), 201 deletions(-) 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.

+ ) : ( +
+
+ + + + + + + + + + {allLogs.slice(0, limit).map((log, index) => { + const style = LOG_LEVEL_STYLES[log.level] || LOG_LEVEL_STYLES.INFO; + return ( + + + + + + ); + })} + +
TimeLevelMessage
+ {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 */} +
+
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 || "-"} +
+ {coords ? formatCoordinates(coords) : device.device_location_coordinates || "-"} {coords && ( e.stopPropagation()} > @@ -406,69 +733,72 @@ export default function DeviceDetail() { {/* Warranty, Maintenance & Statistics */} - {/* Subsection 1: Warranty */} - - {warrantyDaysLeft !== null ? ( - warrantyDaysLeft > 0 ? ( - Active +
+ + {warrantyDaysLeft !== null ? ( + warrantyDaysLeft > 0 ? ( + Active + ) : ( + Expired + ) ) : ( - Expired - ) - ) : ( - - )} - - {formatDate(stats.warrantyStart)} - {stats.warrantyPeriod ? daysToDisplay(stats.warrantyPeriod) : "-"} - {warrantyEnd ? formatDateNice(warrantyEnd) : "-"} - - {warrantyDaysLeft === null ? "-" : warrantyDaysLeft <= 0 ? ( - Expired - ) : ( - `${warrantyDaysLeft} days` - )} - + + )} + + {formatDate(stats.warrantyStart)} + {stats.warrantyPeriod ? daysToDisplay(stats.warrantyPeriod) : "-"} + {warrantyEnd ? formatDateNice(warrantyEnd) : "-"} + + {warrantyDaysLeft === null ? "-" : warrantyDaysLeft <= 0 ? ( + Expired + ) : ( + `${warrantyDaysLeft} days` + )} + +
- {/* Subsection 2: Maintenance */} - {formatDate(stats.maintainedOn)} - {stats.maintainancePeriod ? daysToDisplay(stats.maintainancePeriod) : "-"} - - {nextMaintenance ? ( - - {formatDateNice(nextMaintenance)} - {maintenanceDaysLeft !== null && ( - - ({maintenanceDaysLeft <= 0 ? "overdue" : formatRelativeTime(maintenanceDaysLeft)}) - - )} - - ) : "-"} - +
+ {formatDate(stats.maintainedOn)} + {stats.maintainancePeriod ? daysToDisplay(stats.maintainancePeriod) : "-"} + + {nextMaintenance ? ( + + {formatDateNice(nextMaintenance)} + {maintenanceDaysLeft !== null && ( + + ({maintenanceDaysLeft <= 0 ? "overdue" : formatRelativeTime(maintenanceDaysLeft)}) + + )} + + ) : "-"} + +
- {/* Subsection 3: Statistics */} - {stats.totalPlaybacks} - {stats.totalHammerStrikes} - {stats.totalWarningsGiven} - {device.device_melodies_all?.length ?? 0} - {device.device_melodies_favorites?.length ?? 0} - {stats.perBellStrikes?.length > 0 && ( -
- -
- {stats.perBellStrikes.slice(0, attr.totalBells || stats.perBellStrikes.length).map((count, i) => ( - - Bell {i + 1}: {count} - - ))} -
-
-
- )} +
+ {stats.totalPlaybacks} + {stats.totalHammerStrikes} + {stats.totalWarningsGiven} + {device.device_melodies_all?.length ?? 0} + {device.device_melodies_favorites?.length ?? 0} + {stats.perBellStrikes?.length > 0 && ( +
+ +
+ {stats.perBellStrikes.slice(0, attr.totalBells || stats.perBellStrikes.length).map((count, i) => ( + + Bell {i + 1}: {count} + + ))} +
+
+
+ )} +
@@ -513,111 +843,9 @@ export default function DeviceDetail() { {/* Equipment Notes */} -
- {/* 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; } }