more fixes to the sections and responsiveness
This commit is contained in:
@@ -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 (
|
||||
<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>
|
||||
<dl className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))" }}>{children}</dl>
|
||||
{children}
|
||||
</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 ---
|
||||
|
||||
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 (
|
||||
<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 ---
|
||||
|
||||
export default function DeviceDetail() {
|
||||
@@ -298,31 +460,196 @@ export default function DeviceDetail() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Masonry layout for single-width sections */}
|
||||
<div className="device-sections">
|
||||
{/* Basic Information */}
|
||||
<SectionCard title="Basic Information">
|
||||
<div className="space-y-4">
|
||||
<dl>
|
||||
<Field label="Status">
|
||||
<BoolBadge value={isOnline} yesLabel="Online" noLabel="Offline" />
|
||||
{mqttStatus && (
|
||||
<span className="ml-2 text-xs" style={{ color: "var(--text-muted)" }}>
|
||||
(MQTT {mqttStatus.seconds_since_heartbeat}s ago)
|
||||
</span>
|
||||
)}
|
||||
</Field>
|
||||
</dl>
|
||||
<dl className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))" }}>
|
||||
<Field label="Serial Number">
|
||||
<span className="font-mono">{device.device_id}</span>
|
||||
</Field>
|
||||
<Field label="Document ID">
|
||||
<span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{device.id}</span>
|
||||
</Field>
|
||||
</dl>
|
||||
</div>
|
||||
</SectionCard>
|
||||
{/* ===== BASIC INFORMATION (double-width) ===== */}
|
||||
<div className="section-wide">
|
||||
<section className="rounded-lg border p-5" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
||||
<div className="flex flex-wrap items-center gap-6 divide-x" style={{ borderColor: "var(--border-secondary)" }}>
|
||||
{/* Status — fancy */}
|
||||
<div className="flex items-center gap-3 pr-6">
|
||||
<div
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center"
|
||||
style={{ backgroundColor: isOnline ? "var(--success-bg)" : "var(--bg-card-hover)" }}
|
||||
>
|
||||
<span
|
||||
className="w-3 h-3 rounded-full inline-block"
|
||||
style={{ backgroundColor: isOnline ? "var(--success-text)" : "var(--text-muted)" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Status</div>
|
||||
<div className="text-sm font-semibold" style={{ color: isOnline ? "var(--success-text)" : "var(--text-muted)" }}>
|
||||
{isOnline ? "Online" : "Offline"}
|
||||
{mqttStatus && (
|
||||
<span className="ml-2 text-xs font-normal" style={{ color: "var(--text-muted)" }}>
|
||||
(MQTT {mqttStatus.seconds_since_heartbeat}s ago)
|
||||
</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 & 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 */}
|
||||
<SectionCard title="Misc">
|
||||
@@ -370,14 +697,14 @@ export default function DeviceDetail() {
|
||||
<dl className="space-y-4">
|
||||
<Field label="Location">{device.device_location}</Field>
|
||||
<Field label="Coordinates">
|
||||
<div>
|
||||
{coords ? formatCoordinates(coords) : device.device_location_coordinates || "-"}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{coords ? formatCoordinates(coords) : device.device_location_coordinates || "-"}</span>
|
||||
{coords && (
|
||||
<a
|
||||
href={`https://www.google.com/maps?q=${coords.lat},${coords.lng}`}
|
||||
target="_blank"
|
||||
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)" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -406,69 +733,72 @@ export default function DeviceDetail() {
|
||||
|
||||
{/* Warranty, Maintenance & Statistics */}
|
||||
<SectionCard title="Warranty, Maintenance & Statistics">
|
||||
{/* Subsection 1: Warranty */}
|
||||
<Subsection title="Warranty Information" isFirst>
|
||||
<Field label="Warranty Status">
|
||||
{warrantyDaysLeft !== null ? (
|
||||
warrantyDaysLeft > 0 ? (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>Active</span>
|
||||
<dl className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))" }}>
|
||||
<Field label="Warranty Status">
|
||||
{warrantyDaysLeft !== null ? (
|
||||
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>
|
||||
<Field label="Start Date">{formatDate(stats.warrantyStart)}</Field>
|
||||
<Field label="Warranty Period">{stats.warrantyPeriod ? daysToDisplay(stats.warrantyPeriod) : "-"}</Field>
|
||||
<Field label="Expiration Date">{warrantyEnd ? formatDateNice(warrantyEnd) : "-"}</Field>
|
||||
<Field label="Remaining">
|
||||
{warrantyDaysLeft === null ? "-" : warrantyDaysLeft <= 0 ? (
|
||||
<span style={{ color: "var(--danger-text)" }}>Expired</span>
|
||||
) : (
|
||||
`${warrantyDaysLeft} days`
|
||||
)}
|
||||
</Field>
|
||||
<BoolBadge value={stats.warrantyActive} yesLabel="Active" noLabel="Expired" />
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Start Date">{formatDate(stats.warrantyStart)}</Field>
|
||||
<Field label="Warranty Period">{stats.warrantyPeriod ? daysToDisplay(stats.warrantyPeriod) : "-"}</Field>
|
||||
<Field label="Expiration Date">{warrantyEnd ? formatDateNice(warrantyEnd) : "-"}</Field>
|
||||
<Field label="Remaining">
|
||||
{warrantyDaysLeft === null ? "-" : warrantyDaysLeft <= 0 ? (
|
||||
<span style={{ color: "var(--danger-text)" }}>Expired</span>
|
||||
) : (
|
||||
`${warrantyDaysLeft} days`
|
||||
)}
|
||||
</Field>
|
||||
</dl>
|
||||
</Subsection>
|
||||
|
||||
{/* Subsection 2: Maintenance */}
|
||||
<Subsection title="Maintenance">
|
||||
<Field label="Last Maintained On">{formatDate(stats.maintainedOn)}</Field>
|
||||
<Field label="Maintenance Period">{stats.maintainancePeriod ? daysToDisplay(stats.maintainancePeriod) : "-"}</Field>
|
||||
<Field label="Next Scheduled">
|
||||
{nextMaintenance ? (
|
||||
<span>
|
||||
{formatDateNice(nextMaintenance)}
|
||||
{maintenanceDaysLeft !== null && (
|
||||
<span className="ml-1 text-xs" style={{ color: maintenanceDaysLeft <= 0 ? "var(--danger-text)" : "var(--text-muted)" }}>
|
||||
({maintenanceDaysLeft <= 0 ? "overdue" : formatRelativeTime(maintenanceDaysLeft)})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : "-"}
|
||||
</Field>
|
||||
<dl className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))" }}>
|
||||
<Field label="Last Maintained On">{formatDate(stats.maintainedOn)}</Field>
|
||||
<Field label="Maintenance Period">{stats.maintainancePeriod ? daysToDisplay(stats.maintainancePeriod) : "-"}</Field>
|
||||
<Field label="Next Scheduled">
|
||||
{nextMaintenance ? (
|
||||
<span>
|
||||
{formatDateNice(nextMaintenance)}
|
||||
{maintenanceDaysLeft !== null && (
|
||||
<span className="ml-1 text-xs" style={{ color: maintenanceDaysLeft <= 0 ? "var(--danger-text)" : "var(--text-muted)" }}>
|
||||
({maintenanceDaysLeft <= 0 ? "overdue" : formatRelativeTime(maintenanceDaysLeft)})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : "-"}
|
||||
</Field>
|
||||
</dl>
|
||||
</Subsection>
|
||||
|
||||
{/* Subsection 3: Statistics */}
|
||||
<Subsection title="Statistics">
|
||||
<Field label="Total Playbacks">{stats.totalPlaybacks}</Field>
|
||||
<Field label="Total Hammer Strikes">{stats.totalHammerStrikes}</Field>
|
||||
<Field label="Total Warnings Given">{stats.totalWarningsGiven}</Field>
|
||||
<Field label="Total Melodies">{device.device_melodies_all?.length ?? 0}</Field>
|
||||
<Field label="Favorite Melodies">{device.device_melodies_favorites?.length ?? 0}</Field>
|
||||
{stats.perBellStrikes?.length > 0 && (
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<Field label="Per Bell Strikes">
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{stats.perBellStrikes.slice(0, attr.totalBells || stats.perBellStrikes.length).map((count, i) => (
|
||||
<span key={i} className="px-2 py-1 text-xs rounded-md border" style={{ borderColor: "var(--border-primary)", color: "var(--text-primary)" }}>
|
||||
Bell {i + 1}: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
<dl className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))" }}>
|
||||
<Field label="Total Playbacks">{stats.totalPlaybacks}</Field>
|
||||
<Field label="Total Hammer Strikes">{stats.totalHammerStrikes}</Field>
|
||||
<Field label="Total Warnings Given">{stats.totalWarningsGiven}</Field>
|
||||
<Field label="Total Melodies">{device.device_melodies_all?.length ?? 0}</Field>
|
||||
<Field label="Favorite Melodies">{device.device_melodies_favorites?.length ?? 0}</Field>
|
||||
{stats.perBellStrikes?.length > 0 && (
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<Field label="Per Bell Strikes">
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{stats.perBellStrikes.slice(0, attr.totalBells || stats.perBellStrikes.length).map((count, i) => (
|
||||
<span key={i} className="px-2 py-1 text-xs rounded-md border" style={{ borderColor: "var(--border-primary)", color: "var(--text-primary)" }}>
|
||||
Bell {i + 1}: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</Subsection>
|
||||
</SectionCard>
|
||||
|
||||
@@ -513,111 +843,9 @@ export default function DeviceDetail() {
|
||||
|
||||
{/* Equipment Notes */}
|
||||
<NotesPanel deviceId={id} />
|
||||
</div>
|
||||
|
||||
{/* Device Settings — full width, outside masonry flow */}
|
||||
<div className="mt-6">
|
||||
<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 & 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>
|
||||
{/* Latest Logs */}
|
||||
<DeviceLogsPanel deviceSerial={device.device_id} />
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -145,23 +145,28 @@ input[type="range"]::-moz-range-thumb {
|
||||
border: 2px solid var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Device detail masonry layout */
|
||||
/* Device detail grid layout */
|
||||
.device-sections {
|
||||
columns: 1;
|
||||
column-gap: 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
.device-sections > * {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user