more fixes to the sections and responsiveness

This commit is contained in:
2026-02-18 18:00:48 +02:00
parent 61ef097da1
commit a6e0b1d46e
2 changed files with 434 additions and 201 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { MapContainer, TileLayer, Marker } from "react-leaflet"; import { MapContainer, TileLayer, Marker } from "react-leaflet";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
@@ -7,6 +7,7 @@ import api from "../api/client";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import ConfirmDialog from "../components/ConfirmDialog"; import ConfirmDialog from "../components/ConfirmDialog";
import NotesPanel from "../equipment/NotesPanel"; import NotesPanel from "../equipment/NotesPanel";
import useMqttWebSocket from "../mqtt/useMqttWebSocket";
// Fix default Leaflet marker icon // Fix default Leaflet marker icon
delete L.Icon.Default.prototype._getIconUrl; delete L.Icon.Default.prototype._getIconUrl;
@@ -59,11 +60,23 @@ function Subsection({ title, children, isFirst = false }) {
return ( return (
<div className={isFirst ? "" : "mt-4 pt-4 border-t"} style={isFirst ? {} : { borderColor: "var(--border-secondary)" }}> <div className={isFirst ? "" : "mt-4 pt-4 border-t"} style={isFirst ? {} : { borderColor: "var(--border-secondary)" }}>
<h3 className="text-sm font-semibold mb-3" style={{ color: "var(--text-primary)" }}>{title}</h3> <h3 className="text-sm font-semibold mb-3" style={{ color: "var(--text-primary)" }}>{title}</h3>
<dl className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))" }}>{children}</dl> {children}
</div> </div>
); );
} }
/** A single row of fields that wraps if it doesn't fit */
function FieldRow({ children }) {
return <dl className="flex flex-wrap gap-x-8 gap-y-3 mb-3">{children}</dl>;
}
// --- 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 --- // --- Date / time helpers ---
function parseFirestoreDate(str) { function parseFirestoreDate(str) {
@@ -121,15 +134,12 @@ function daysToDisplay(days) {
} }
function formatTimestamp(str) { function formatTimestamp(str) {
// Try to parse as a Firestore timestamp and return HH:MM
if (!str) return "-"; if (!str) return "-";
const d = parseFirestoreDate(str); const d = parseFirestoreDate(str);
if (d) { if (d) {
return `${d.getUTCHours().toString().padStart(2, "0")}:${d.getUTCMinutes().toString().padStart(2, "0")}`; 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; 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})/); const match = str.match(/(\d{1,2}):(\d{2})/);
if (match) return `${match[1].padStart(2, "0")}:${match[2]}`; if (match) return `${match[1].padStart(2, "0")}:${match[2]}`;
return str; return str;
@@ -144,7 +154,6 @@ function msToSeconds(ms) {
function parseCoordinates(coordStr) { function parseCoordinates(coordStr) {
if (!coordStr) return null; if (!coordStr) return null;
// Handle "lat,lng" or "lat° N, lng° E" or similar
const cleaned = coordStr.replace(/°\s*[NSEW]/gi, "").trim(); const cleaned = coordStr.replace(/°\s*[NSEW]/gi, "").trim();
const parts = cleaned.split(",").map((s) => parseFloat(s.trim())); const parts = cleaned.split(",").map((s) => parseFloat(s.trim()));
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) { 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}`; 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 (
<SectionCard title="Latest Logs">
{/* Controls */}
<div className="flex flex-wrap gap-3 items-end mb-4">
<div>
<select
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
className="px-2 py-1.5 rounded-md text-xs border"
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-primary)", borderColor: "var(--border-primary)" }}
>
<option value="">All Levels</option>
<option value="INFO">INFO</option>
<option value="WARN">WARN</option>
<option value="ERROR">ERROR</option>
</select>
</div>
<div className="flex-1 min-w-32">
<input
type="text"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="Search log messages..."
className="w-full px-2 py-1.5 rounded-md text-xs border"
/>
</div>
<label className="flex items-center gap-1.5 text-xs cursor-pointer" style={{ color: "var(--text-secondary)" }}>
<input type="checkbox" checked={autoRefresh} onChange={(e) => setAutoRefresh(e.target.checked)} />
Auto-refresh
</label>
<button
onClick={fetchLogs}
className="px-2 py-1.5 text-xs rounded-md border hover:opacity-80 cursor-pointer"
style={{ borderColor: "var(--border-primary)", color: "var(--text-primary)" }}
>
Refresh
</button>
</div>
{/* Log table */}
{loading && logs.length === 0 ? (
<p className="text-xs" style={{ color: "var(--text-muted)" }}>Loading...</p>
) : allLogs.length === 0 ? (
<p className="text-xs" style={{ color: "var(--text-muted)" }}>No logs found.</p>
) : (
<div className="rounded-md overflow-hidden border" style={{ borderColor: "var(--border-primary)" }}>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-3 py-2 text-left font-medium w-40" style={{ color: "var(--text-secondary)" }}>Time</th>
<th className="px-3 py-2 text-left font-medium w-16" style={{ color: "var(--text-secondary)" }}>Level</th>
<th className="px-3 py-2 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Message</th>
</tr>
</thead>
<tbody>
{allLogs.slice(0, limit).map((log, index) => {
const style = LOG_LEVEL_STYLES[log.level] || LOG_LEVEL_STYLES.INFO;
return (
<tr
key={log._live ? `live-${log.id}` : log.id}
style={{
borderBottom: index < Math.min(allLogs.length, limit) - 1 ? "1px solid var(--border-primary)" : "none",
backgroundColor: log._live ? "rgba(116, 184, 22, 0.05)" : "transparent",
}}
>
<td className="px-3 py-2 font-mono" style={{ color: "var(--text-muted)" }}>
{log.received_at?.replace("T", " ").substring(0, 19)}
{log._live && <span className="ml-1" style={{ color: "var(--accent)" }}>LIVE</span>}
</td>
<td className="px-3 py-2">
<span className="px-1.5 py-0.5 text-xs rounded-full" style={{ backgroundColor: style.bg, color: style.color }}>
{log.level}
</span>
</td>
<td className="px-3 py-2 font-mono" style={{ color: "var(--text-primary)" }}>
{log.message}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</SectionCard>
);
}
// --- Main component --- // --- Main component ---
export default function DeviceDetail() { export default function DeviceDetail() {
@@ -298,31 +460,196 @@ export default function DeviceDetail() {
)} )}
</div> </div>
{/* Masonry layout for single-width sections */}
<div className="device-sections"> <div className="device-sections">
{/* Basic Information */} {/* ===== BASIC INFORMATION (double-width) ===== */}
<SectionCard title="Basic Information"> <div className="section-wide">
<div className="space-y-4"> <section className="rounded-lg border p-5" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<dl> <div className="flex flex-wrap items-center gap-6 divide-x" style={{ borderColor: "var(--border-secondary)" }}>
<Field label="Status"> {/* Status — fancy */}
<BoolBadge value={isOnline} yesLabel="Online" noLabel="Offline" /> <div className="flex items-center gap-3 pr-6">
{mqttStatus && ( <div
<span className="ml-2 text-xs" style={{ color: "var(--text-muted)" }}> className="w-10 h-10 rounded-full flex items-center justify-center"
(MQTT {mqttStatus.seconds_since_heartbeat}s ago) style={{ backgroundColor: isOnline ? "var(--success-bg)" : "var(--bg-card-hover)" }}
</span> >
)} <span
</Field> className="w-3 h-3 rounded-full inline-block"
</dl> style={{ backgroundColor: isOnline ? "var(--success-text)" : "var(--text-muted)" }}
<dl className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))" }}> />
<Field label="Serial Number"> </div>
<span className="font-mono">{device.device_id}</span> <div>
</Field> <div className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Status</div>
<Field label="Document ID"> <div className="text-sm font-semibold" style={{ color: isOnline ? "var(--success-text)" : "var(--text-muted)" }}>
<span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{device.id}</span> {isOnline ? "Online" : "Offline"}
</Field> {mqttStatus && (
</dl> <span className="ml-2 text-xs font-normal" style={{ color: "var(--text-muted)" }}>
</div> (MQTT {mqttStatus.seconds_since_heartbeat}s ago)
</SectionCard> </span>
)}
</div>
</div>
</div>
{/* Serial + Hardware Variant */}
<div className="pl-6">
<div className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Serial Number</div>
<div className="text-sm font-mono mt-0.5" style={{ color: "var(--text-primary)" }}>
{device.device_id}
<span className="ml-3 text-xs" style={{ color: "var(--text-muted)" }}>HW: -</span>
</div>
</div>
{/* Quick Admin Notes */}
<div className="pl-6">
<div className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Admin Notes</div>
<div className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>-</div>
</div>
{/* Document ID */}
<div className="pl-6">
<div className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Document ID</div>
<div className="text-xs font-mono mt-0.5" style={{ color: "var(--text-muted)" }}>{device.id}</div>
</div>
</div>
</section>
</div>
{/* ===== DEVICE SETTINGS (double-width) ===== */}
<div className="section-wide">
<SectionCard title="Device Settings">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column */}
<div>
{/* Basic Attributes */}
<Subsection title="Basic Attributes" isFirst>
<FieldRow>
<Field label="Bell Guard"><BoolBadge value={attr.bellGuardOn} /></Field>
<Field label="Warnings On"><BoolBadge value={attr.warningsOn} /></Field>
<Field label="Bell Guard Safety"><BoolBadge value={attr.bellGuardSafetyOn} /></Field>
</FieldRow>
</Subsection>
{/* Alert Settings */}
<Subsection title="Alert Settings">
<FieldRow>
<Field label="Alerts Status"><BoolBadge value={clock.ringAlertsMasterOn} yesLabel="ON" noLabel="OFF" /></Field>
<Field label="Alerts Type"><span className="capitalize">{clock.ringAlerts || "-"}</span></Field>
<Field label="Ring Intervals">{clock.ringIntervals}</Field>
</FieldRow>
<FieldRow>
<Field label="Hour Bell">{clock.hourAlertsBell}</Field>
<Field label="Half-Hour Bell">{clock.halfhourAlertsBell}</Field>
<Field label="Quarter Bell">{clock.quarterAlertsBell}</Field>
</FieldRow>
<FieldRow>
<Field label="Daytime Silence"><BoolBadge value={clock.isDaySilenceOn} yesLabel="ON" noLabel="OFF" /></Field>
<Field label="Day-Time Period">
{formatTimestamp(clock.daySilenceFrom)} - {formatTimestamp(clock.daySilenceTo)}
</Field>
</FieldRow>
<FieldRow>
<Field label="Nighttime Silence"><BoolBadge value={clock.isNightSilenceOn} yesLabel="ON" noLabel="OFF" /></Field>
<Field label="Nighttime Period">
{formatTimestamp(clock.nightSilenceFrom)} - {formatTimestamp(clock.nightSilenceTo)}
</Field>
</FieldRow>
</Subsection>
{/* Backlight Settings */}
<Subsection title="Backlight Settings">
<FieldRow>
<Field label="Auto Backlight"><BoolBadge value={clock.isBacklightAutomationOn} yesLabel="ON" noLabel="OFF" /></Field>
<Field label="Backlight Output">{clock.backlightOutput}</Field>
<Field label="Period">
{formatTimestamp(clock.backlightTurnOnTime)} - {formatTimestamp(clock.backlightTurnOffTime)}
</Field>
</FieldRow>
</Subsection>
{/* Logging */}
<Subsection title="Logging">
<FieldRow>
<Field label="Serial Log Level">{attr.serialLogLevel}</Field>
<Field label="SD Log Level">{attr.sdLogLevel}</Field>
<Field label="MQTT Log Level">{attr.mqttLogLevel ?? 0}</Field>
</FieldRow>
</Subsection>
</div>
{/* Right Column */}
<div>
{/* Network */}
<Subsection title="Network" isFirst>
<FieldRow>
<Field label="Hostname">{net.hostname}</Field>
<Field label="Has Static IP"><BoolBadge value={net.useStaticIP} /></Field>
</FieldRow>
</Subsection>
{/* Clock Settings */}
<Subsection title="Clock Settings">
<FieldRow>
<Field label="Has Clock"><BoolBadge value={attr.hasClock} /></Field>
<Field label="Ring Intervals">{clock.ringIntervals}</Field>
</FieldRow>
<FieldRow>
<Field label="Odd Output">{clock.clockOutputs?.[0] ?? "-"}</Field>
<Field label="Even Output">{clock.clockOutputs?.[1] ?? "-"}</Field>
</FieldRow>
<FieldRow>
<Field label="Run Pulse">{clock.clockTimings?.[0] != null ? msToSeconds(clock.clockTimings[0]) : "-"}</Field>
<Field label="Pause Pulse">{clock.clockTimings?.[1] != null ? msToSeconds(clock.clockTimings[1]) : "-"}</Field>
</FieldRow>
</Subsection>
{/* Bell Settings */}
<Subsection title="Bell Settings">
<FieldRow>
<Field label="Bells Active"><BoolBadge value={attr.hasBells} /></Field>
<Field label="Total">{attr.totalBells ?? "-"}</Field>
</FieldRow>
{/* Bell Output-to-Timing mapping with watermark numbers */}
{attr.bellOutputs?.length > 0 && (
<div className="mt-3">
<dt className="text-xs font-medium uppercase tracking-wide mb-2" style={{ color: "var(--text-muted)" }}>
Output &amp; Timing Map
</dt>
<div className="flex flex-wrap gap-2">
{attr.bellOutputs.map((output, i) => (
<div
key={i}
className="relative rounded-md border px-4 py-3 text-center overflow-hidden"
style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)", minWidth: 90 }}
>
{/* Watermark number */}
<span
className="absolute inset-0 flex items-center justify-center font-bold pointer-events-none select-none"
style={{ fontSize: "3rem", color: "var(--text-heading)", opacity: 0.06 }}
>
{i + 1}
</span>
{/* Content */}
<div className="relative">
<div className="text-xs" style={{ color: "var(--text-muted)" }}>
Output <span style={{ color: "var(--text-primary)" }}>{output}</span>
</div>
<div className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
{attr.hammerTimings?.[i] != null ? (
<><span style={{ color: "var(--text-primary)" }}>{attr.hammerTimings[i]}</span> ms</>
) : "-"}
</div>
</div>
</div>
))}
</div>
</div>
)}
</Subsection>
</div>
</div>
</SectionCard>
</div>
{/* ===== SINGLE-WIDTH SECTIONS ===== */}
{/* Misc */} {/* Misc */}
<SectionCard title="Misc"> <SectionCard title="Misc">
@@ -370,14 +697,14 @@ export default function DeviceDetail() {
<dl className="space-y-4"> <dl className="space-y-4">
<Field label="Location">{device.device_location}</Field> <Field label="Location">{device.device_location}</Field>
<Field label="Coordinates"> <Field label="Coordinates">
<div> <div className="flex flex-wrap items-center gap-2">
{coords ? formatCoordinates(coords) : device.device_location_coordinates || "-"} <span>{coords ? formatCoordinates(coords) : device.device_location_coordinates || "-"}</span>
{coords && ( {coords && (
<a <a
href={`https://www.google.com/maps?q=${coords.lat},${coords.lng}`} href={`https://www.google.com/maps?q=${coords.lat},${coords.lng}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="ml-2 px-2 py-0.5 text-xs rounded-md inline-block" className="px-2 py-0.5 text-xs rounded-md inline-block"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }} style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
@@ -406,69 +733,72 @@ export default function DeviceDetail() {
{/* Warranty, Maintenance & Statistics */} {/* Warranty, Maintenance & Statistics */}
<SectionCard title="Warranty, Maintenance & Statistics"> <SectionCard title="Warranty, Maintenance & Statistics">
{/* Subsection 1: Warranty */}
<Subsection title="Warranty Information" isFirst> <Subsection title="Warranty Information" isFirst>
<Field label="Warranty Status"> <dl className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))" }}>
{warrantyDaysLeft !== null ? ( <Field label="Warranty Status">
warrantyDaysLeft > 0 ? ( {warrantyDaysLeft !== null ? (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>Active</span> warrantyDaysLeft > 0 ? (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>Active</span>
) : (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>Expired</span>
)
) : ( ) : (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>Expired</span> <BoolBadge value={stats.warrantyActive} yesLabel="Active" noLabel="Expired" />
) )}
) : ( </Field>
<BoolBadge value={stats.warrantyActive} yesLabel="Active" noLabel="Expired" /> <Field label="Start Date">{formatDate(stats.warrantyStart)}</Field>
)} <Field label="Warranty Period">{stats.warrantyPeriod ? daysToDisplay(stats.warrantyPeriod) : "-"}</Field>
</Field> <Field label="Expiration Date">{warrantyEnd ? formatDateNice(warrantyEnd) : "-"}</Field>
<Field label="Start Date">{formatDate(stats.warrantyStart)}</Field> <Field label="Remaining">
<Field label="Warranty Period">{stats.warrantyPeriod ? daysToDisplay(stats.warrantyPeriod) : "-"}</Field> {warrantyDaysLeft === null ? "-" : warrantyDaysLeft <= 0 ? (
<Field label="Expiration Date">{warrantyEnd ? formatDateNice(warrantyEnd) : "-"}</Field> <span style={{ color: "var(--danger-text)" }}>Expired</span>
<Field label="Remaining"> ) : (
{warrantyDaysLeft === null ? "-" : warrantyDaysLeft <= 0 ? ( `${warrantyDaysLeft} days`
<span style={{ color: "var(--danger-text)" }}>Expired</span> )}
) : ( </Field>
`${warrantyDaysLeft} days` </dl>
)}
</Field>
</Subsection> </Subsection>
{/* Subsection 2: Maintenance */}
<Subsection title="Maintenance"> <Subsection title="Maintenance">
<Field label="Last Maintained On">{formatDate(stats.maintainedOn)}</Field> <dl className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))" }}>
<Field label="Maintenance Period">{stats.maintainancePeriod ? daysToDisplay(stats.maintainancePeriod) : "-"}</Field> <Field label="Last Maintained On">{formatDate(stats.maintainedOn)}</Field>
<Field label="Next Scheduled"> <Field label="Maintenance Period">{stats.maintainancePeriod ? daysToDisplay(stats.maintainancePeriod) : "-"}</Field>
{nextMaintenance ? ( <Field label="Next Scheduled">
<span> {nextMaintenance ? (
{formatDateNice(nextMaintenance)} <span>
{maintenanceDaysLeft !== null && ( {formatDateNice(nextMaintenance)}
<span className="ml-1 text-xs" style={{ color: maintenanceDaysLeft <= 0 ? "var(--danger-text)" : "var(--text-muted)" }}> {maintenanceDaysLeft !== null && (
({maintenanceDaysLeft <= 0 ? "overdue" : formatRelativeTime(maintenanceDaysLeft)}) <span className="ml-1 text-xs" style={{ color: maintenanceDaysLeft <= 0 ? "var(--danger-text)" : "var(--text-muted)" }}>
</span> ({maintenanceDaysLeft <= 0 ? "overdue" : formatRelativeTime(maintenanceDaysLeft)})
)} </span>
</span> )}
) : "-"} </span>
</Field> ) : "-"}
</Field>
</dl>
</Subsection> </Subsection>
{/* Subsection 3: Statistics */}
<Subsection title="Statistics"> <Subsection title="Statistics">
<Field label="Total Playbacks">{stats.totalPlaybacks}</Field> <dl className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))" }}>
<Field label="Total Hammer Strikes">{stats.totalHammerStrikes}</Field> <Field label="Total Playbacks">{stats.totalPlaybacks}</Field>
<Field label="Total Warnings Given">{stats.totalWarningsGiven}</Field> <Field label="Total Hammer Strikes">{stats.totalHammerStrikes}</Field>
<Field label="Total Melodies">{device.device_melodies_all?.length ?? 0}</Field> <Field label="Total Warnings Given">{stats.totalWarningsGiven}</Field>
<Field label="Favorite Melodies">{device.device_melodies_favorites?.length ?? 0}</Field> <Field label="Total Melodies">{device.device_melodies_all?.length ?? 0}</Field>
{stats.perBellStrikes?.length > 0 && ( <Field label="Favorite Melodies">{device.device_melodies_favorites?.length ?? 0}</Field>
<div style={{ gridColumn: "1 / -1" }}> {stats.perBellStrikes?.length > 0 && (
<Field label="Per Bell Strikes"> <div style={{ gridColumn: "1 / -1" }}>
<div className="flex flex-wrap gap-2 mt-1"> <Field label="Per Bell Strikes">
{stats.perBellStrikes.slice(0, attr.totalBells || stats.perBellStrikes.length).map((count, i) => ( <div className="flex flex-wrap gap-2 mt-1">
<span key={i} className="px-2 py-1 text-xs rounded-md border" style={{ borderColor: "var(--border-primary)", color: "var(--text-primary)" }}> {stats.perBellStrikes.slice(0, attr.totalBells || stats.perBellStrikes.length).map((count, i) => (
Bell {i + 1}: {count} <span key={i} className="px-2 py-1 text-xs rounded-md border" style={{ borderColor: "var(--border-primary)", color: "var(--text-primary)" }}>
</span> Bell {i + 1}: {count}
))} </span>
</div> ))}
</Field> </div>
</div> </Field>
)} </div>
)}
</dl>
</Subsection> </Subsection>
</SectionCard> </SectionCard>
@@ -513,111 +843,9 @@ export default function DeviceDetail() {
{/* Equipment Notes */} {/* Equipment Notes */}
<NotesPanel deviceId={id} /> <NotesPanel deviceId={id} />
</div>
{/* Device Settings — full width, outside masonry flow */} {/* Latest Logs */}
<div className="mt-6"> <DeviceLogsPanel deviceSerial={device.device_id} />
<SectionCard title="Device Settings">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column */}
<div>
{/* Basic Attributes */}
<Subsection title="Basic Attributes" isFirst>
<Field label="Bell Guard"><BoolBadge value={attr.bellGuardOn} /></Field>
<Field label="Warnings On"><BoolBadge value={attr.warningsOn} /></Field>
<Field label="Bell Guard Safety"><BoolBadge value={attr.bellGuardSafetyOn} /></Field>
</Subsection>
{/* Alert Settings */}
<Subsection title="Alert Settings">
<Field label="Alerts Status"><BoolBadge value={clock.ringAlertsMasterOn} yesLabel="ON" noLabel="OFF" /></Field>
<Field label="Alerts Type"><span className="capitalize">{clock.ringAlerts || "-"}</span></Field>
<Field label="Ring Intervals">{clock.ringIntervals}</Field>
<Field label="Hour Bell">{clock.hourAlertsBell}</Field>
<Field label="Half-Hour Bell">{clock.halfhourAlertsBell}</Field>
<Field label="Quarter Bell">{clock.quarterAlertsBell}</Field>
<Field label="Daytime Silence"><BoolBadge value={clock.isDaySilenceOn} yesLabel="ON" noLabel="OFF" /></Field>
<Field label="Day-Time Period">
{formatTimestamp(clock.daySilenceFrom)} - {formatTimestamp(clock.daySilenceTo)}
</Field>
<Field label="Nighttime Silence"><BoolBadge value={clock.isNightSilenceOn} yesLabel="ON" noLabel="OFF" /></Field>
<Field label="Nighttime Period">
{formatTimestamp(clock.nightSilenceFrom)} - {formatTimestamp(clock.nightSilenceTo)}
</Field>
</Subsection>
{/* Backlight Settings */}
<Subsection title="Backlight Settings">
<Field label="Auto Backlight"><BoolBadge value={clock.isBacklightAutomationOn} yesLabel="ON" noLabel="OFF" /></Field>
<Field label="Backlight Output">{clock.backlightOutput}</Field>
<Field label="Period">
{formatTimestamp(clock.backlightTurnOnTime)} - {formatTimestamp(clock.backlightTurnOffTime)}
</Field>
</Subsection>
{/* Logging */}
<Subsection title="Logging">
<Field label="Serial Log Level">{attr.serialLogLevel}</Field>
<Field label="SD Log Level">{attr.sdLogLevel}</Field>
<Field label="MQTT Log Level">{attr.mqttLogLevel ?? 0}</Field>
</Subsection>
</div>
{/* Right Column */}
<div>
{/* Network */}
<Subsection title="Network" isFirst>
<Field label="Hostname">{net.hostname}</Field>
<Field label="Has Static IP"><BoolBadge value={net.useStaticIP} /></Field>
</Subsection>
{/* Clock Settings */}
<Subsection title="Clock Settings">
<Field label="Has Clock"><BoolBadge value={attr.hasClock} /></Field>
<Field label="Ring Intervals">{clock.ringIntervals}</Field>
<Field label="Odd Output">{clock.clockOutputs?.[0] ?? "-"}</Field>
<Field label="Even Output">{clock.clockOutputs?.[1] ?? "-"}</Field>
<Field label="Run Pulse">{clock.clockTimings?.[0] != null ? msToSeconds(clock.clockTimings[0]) : "-"}</Field>
<Field label="Pause Pulse">{clock.clockTimings?.[1] != null ? msToSeconds(clock.clockTimings[1]) : "-"}</Field>
</Subsection>
{/* Bell Settings */}
<Subsection title="Bell Settings">
<Field label="Bells Active"><BoolBadge value={attr.hasBells} /></Field>
<Field label="Total">{attr.totalBells ?? "-"}</Field>
{/* Bell Output-to-Timing mapping */}
{attr.bellOutputs?.length > 0 && (
<div style={{ gridColumn: "1 / -1" }}>
<dt className="text-xs font-medium uppercase tracking-wide mb-2" style={{ color: "var(--text-muted)" }}>
Output &amp; Timing Map
</dt>
<div className="flex flex-wrap gap-2">
{attr.bellOutputs.map((output, i) => (
<div
key={i}
className="rounded-md border px-3 py-2 text-center"
style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)", minWidth: 80 }}
>
<div className="text-xs font-medium mb-1" style={{ color: "var(--text-heading)" }}>
Bell {i + 1}
</div>
<div className="text-xs" style={{ color: "var(--text-muted)" }}>
Output <span style={{ color: "var(--text-primary)" }}>{output}</span>
</div>
<div className="text-xs" style={{ color: "var(--text-muted)" }}>
{attr.hammerTimings?.[i] != null ? (
<><span style={{ color: "var(--text-primary)" }}>{attr.hammerTimings[i]}</span> ms</>
) : "-"}
</div>
</div>
))}
</div>
</div>
)}
</Subsection>
</div>
</div>
</SectionCard>
</div> </div>
<ConfirmDialog <ConfirmDialog

View File

@@ -145,23 +145,28 @@ input[type="range"]::-moz-range-thumb {
border: 2px solid var(--bg-primary); border: 2px solid var(--bg-primary);
} }
/* Device detail masonry layout */ /* Device detail grid layout */
.device-sections { .device-sections {
columns: 1; display: grid;
column-gap: 1.5rem; grid-template-columns: 1fr;
gap: 1.5rem;
align-items: start;
} }
.device-sections > * { .device-sections > .section-wide {
break-inside: avoid; grid-column: 1 / -1;
margin-bottom: 1.5rem;
} }
@media (min-width: 900px) { @media (min-width: 900px) {
.device-sections { .device-sections {
columns: 2; grid-template-columns: repeat(2, 1fr);
grid-auto-flow: dense;
} }
} }
@media (min-width: 1800px) { @media (min-width: 2100px) {
.device-sections { .device-sections {
columns: 3; grid-template-columns: repeat(3, 1fr);
}
.device-sections > .section-wide {
grid-column: span 2;
} }
} }