// frontend/src/pages/bellcloud/devices/tabs/GeneralTab.jsx
// General Information tab — 3-row bento layout
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import api from '@/lib/api'
import { useToast } from '@/components/ui/Toast'
import Button from '@/components/ui/Button'
import StatusBadge from '@/components/ui/StatusBadge'
import Icon from '@/components/ui/Icon'
import FormField from '@/components/ui/FormField'
import Spinner from '@/components/ui/Spinner'
import { fmtDateTimeMedium } from '@/lib/formatters'
// ─── Constants ────────────────────────────────────────────────────────────────
const LOG_LEVELS = [0, 1, 2, 3, 4, 5]
const LOG_LEVEL_META = {
0: { label: 'Disabled', color: 'var(--color-text-muted)' },
1: { label: 'Error', color: 'var(--color-danger)' },
2: { label: 'Warning', color: 'var(--color-warning)' },
3: { label: 'Info', color: 'var(--color-info)' },
4: { label: 'Debug', color: '#9f7aea' },
5: { label: 'Verbose', color: 'var(--color-success)' },
}
// Glass bg tokens
const GLASS_BG = 'rgba(28, 32, 38, 0.30)'
const GLASS_BG_INNER = 'rgba(28, 32, 38, 0.40)'
// ─── Helpers ──────────────────────────────────────────────────────────────────
function parseCoordinates(coordStr) {
if (!coordStr) return null
if (typeof coordStr === 'object' && coordStr !== null) {
const lat = parseFloat(coordStr.lat ?? coordStr.latitude)
const lng = parseFloat(coordStr.lng ?? coordStr.longitude)
if (!isNaN(lat) && !isNaN(lng)) return { lat, lng }
return null
}
const numbers = coordStr.match(/-?\d+(?:\.\d+)?/g)
if (numbers && numbers.length >= 2) {
let lat = parseFloat(numbers[0])
let lng = parseFloat(numbers[1])
if (!isNaN(lat) && !isNaN(lng)) {
if (/\bS\b/.test(coordStr)) lat = -Math.abs(lat)
if (/\bW\b/.test(coordStr)) lng = -Math.abs(lng)
return { lat, lng }
}
}
return null
}
function formatCoordinates(coords) {
if (!coords) return '—'
const latDir = coords.lat >= 0 ? 'N' : 'S'
const lngDir = coords.lng >= 0 ? 'E' : 'W'
return `${Math.abs(coords.lat).toFixed(6)}° ${latDir}, ${Math.abs(coords.lng).toFixed(6)}° ${lngDir}`
}
function getNearestPlaceLabel(data) {
const addr = data?.address || {}
const name = addr.city || addr.town || addr.village || addr.hamlet || addr.municipality || data?.display_name?.split(',')[0] || ''
const region = addr.state || addr.county || ''
const country = addr.country || ''
return [name, region, country].filter(Boolean).join(', ')
}
function getLogLevelMeta(levelRaw) {
if (!Number.isFinite(levelRaw)) return null
return LOG_LEVEL_META[Number(levelRaw)] || null
}
// ─── OSM Tile helpers ─────────────────────────────────────────────────────────
function latLngToTile(lat, lng, zoom) {
const n = Math.pow(2, zoom)
const xFrac = ((lng + 180) / 360) * n
const latRad = (lat * Math.PI) / 180
const yFrac = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n
const x = Math.floor(xFrac)
const y = Math.floor(yFrac)
return { x, y, px: (xFrac - x) * 256, py: (yFrac - y) * 256 }
}
function buildTileGrid(lat, lng, zoom = 13, options = {}) {
const { x, y, px, py } = latLngToTile(lat, lng, zoom)
const COL_RADIUS = options.colRadius ?? 3
const ROW_RADIUS = options.rowRadius ?? 1
const tiles = []
for (let dy = -ROW_RADIUS; dy <= ROW_RADIUS; dy++) {
for (let dx = -COL_RADIUS; dx <= COL_RADIUS; dx++) {
tiles.push({
url: `https://tiles.stadiamaps.com/tiles/stamen_toner/${zoom}/${x + dx}/${y + dy}.png`,
col: dx + COL_RADIUS,
row: dy + ROW_RADIUS,
})
}
}
return {
tiles,
markerOffset: {
x: COL_RADIUS * 256 + px,
y: ROW_RADIUS * 256 + py,
},
}
}
function getResponsiveTileCounts(width, height) {
const safeWidth = Number.isFinite(width) && width > 0 ? width : 0
const safeHeight = Number.isFinite(height) && height > 0 ? height : 0
let totalCols = Math.max(7, Math.ceil(safeWidth / 256) + 2)
let totalRows = Math.max(3, Math.ceil(safeHeight / 256) + 2)
if (totalCols % 2 === 0) totalCols += 1
if (totalRows % 2 === 0) totalRows += 1
return {
totalCols,
totalRows,
colRadius: Math.floor(totalCols / 2),
rowRadius: Math.floor(totalRows / 2),
}
}
// ─── Keyframes ────────────────────────────────────────────────────────────────
let keyframesInjected = false
function ensureKeyframes() {
if (keyframesInjected) return
keyframesInjected = true
const style = document.createElement('style')
style.textContent = `
@keyframes gen-tab-pulse {
0% { opacity: 0.9; transform: scale(1); }
70% { opacity: 0; transform: scale(2.4); }
100% { opacity: 0; transform: scale(2.4); }
}
.gen-log-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 2px;
outline: none;
cursor: pointer;
background: rgba(28,32,38,0.70);
}
.gen-log-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid rgba(192,193,255,0.25);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.gen-log-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid rgba(192,193,255,0.25);
cursor: pointer;
}
.gen-log-slider:hover::-webkit-slider-thumb {
transform: scale(1.2);
}
`
document.head.appendChild(style)
}
// ─── Primitive design atoms ───────────────────────────────────────────────────
function SectionLabel({ children }) {
return (
{children}
)
}
// Glass card — the universal panel wrapper
function GlassCard({ title, titleAction, children, noPadding, style, fullHeight }) {
return (
{title && (
{title}
{titleAction &&
{titleAction}
}
)}
{children}
)
}
// Inner surface — glass nested surface
function InnerSurface({ children, style }) {
return (
{children}
)
}
function EditActionButton({ onClick, label = 'Edit' }) {
return (
}
style={{ whiteSpace: 'nowrap', flexShrink: 0 }}
>
{label}
)
}
function GoogleMapsIcon({ size = 14 }) {
return (
)
}
// ─── Safety Row ───────────────────────────────────────────────────────────────
function SafetyRow({ label, description, enabled }) {
return (
{label}
{description}
{enabled ? 'Armed' : 'Disarmed'}
)
}
// ─── Log Slider ───────────────────────────────────────────────────────────────
function LogSlider({ label, level, onChange, canEdit, color }) {
const meta = getLogLevelMeta(level)
const safeLevel = Number.isFinite(level) ? Number(level) : 0
// Build CSS gradient for the track — filled up to thumb position
const pct = (safeLevel / (LOG_LEVELS.length - 1)) * 100
const trackBg = `linear-gradient(90deg, ${color} 0%, ${color} ${pct}%, rgba(28,32,38,0.70) ${pct}%, rgba(28,32,38,0.70) 100%)`
return (
{label}
{meta ? meta.label : '—'}
onChange(Number(e.target.value)) : undefined}
style={{
background: trackBg,
opacity: canEdit ? 1 : 0.6,
// thumb color via CSS custom property on inline style — works in Safari
'--thumb-color': color,
}}
/>
{/* tick marks — padding-inline of 7px (half thumb width) aligns ticks with thumb centre */}
{LOG_LEVELS.map(l => (
))}
)
}
// ─── Location Map ─────────────────────────────────────────────────────────────
function LocationMap({ coords, locationName, locationLabel, canEdit, onEdit }) {
const ZOOM = 13
const PIN_X = 72 // % across
const PIN_Y = 50 // % down
const containerRef = useRef(null)
const [mapSize, setMapSize] = useState({ width: 0, height: 0 })
useEffect(() => {
const node = containerRef.current
if (!node) return
const updateSize = () => {
const { width, height } = node.getBoundingClientRect()
setMapSize(prev => (
prev.width === width && prev.height === height
? prev
: { width, height }
))
}
updateSize()
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(updateSize)
observer.observe(node)
return () => observer.disconnect()
}, [])
const { totalCols, totalRows, colRadius, rowRadius } = useMemo(
() => getResponsiveTileCounts(mapSize.width, mapSize.height),
[mapSize.width, mapSize.height]
)
const { tiles, markerOffset } = useMemo(() => {
if (!coords) return { tiles: null, markerOffset: null }
return buildTileGrid(coords.lat, coords.lng, ZOOM, { colRadius, rowRadius })
}, [coords, colRadius, rowRadius])
const googleMapsUrl = coords
? `https://www.google.com/maps?q=${coords.lat},${coords.lng}`
: null
const handleOpenGoogleMaps = useCallback(() => {
if (!googleMapsUrl || typeof window === 'undefined') return
window.open(googleMapsUrl, '_blank', 'noopener,noreferrer')
}, [googleMapsUrl])
return (
{/* Tiles */}
{tiles ? (
{tiles.map((t, i) => (
))}
) : (
)}
{/* Marker */}
{tiles && markerOffset && (
)}
{/* Left-fade overlay */}
{/* Top-left info stack */}
Location
{locationLabel || '—'}
{locationName && (
Nearest Place
{locationName}
)}
{/* Coordinates — bottom-left */}
{coords && (
{formatCoordinates(coords)}
)}
{/* Edit button — top-right */}
{canEdit && (
)}
)
}
// ─── Device Notes ─────────────────────────────────────────────────────────────
function DeviceNotesSection({ deviceId, canEdit }) {
const { toast } = useToast()
const [notes, setNotes] = useState([])
const [loaded, setLoaded] = useState(false)
const [adding, setAdding] = useState(false)
const [newText, setNewText] = useState('')
const [savingNew, setSavingNew] = useState(false)
const [editingId, setEditingId] = useState(null)
const [editingText, setEditingText] = useState('')
const [savingEdit, setSavingEdit] = useState(false)
const [deletingId, setDeletingId] = useState(null)
useEffect(() => {
if (!deviceId) return
api.get(`/devices/${deviceId}/notes`)
.then(data => { setNotes(data.notes || []); setLoaded(true) })
.catch(() => setLoaded(true))
}, [deviceId])
const handleAdd = async () => {
const content = newText.trim()
if (!content) return
setSavingNew(true)
try {
const data = await api.post(`/devices/${deviceId}/notes`, { content })
setNotes(prev => [data.note || { id: Date.now(), content, created_at: new Date().toISOString() }, ...prev])
setNewText('')
setAdding(false)
} catch (err) {
toast.danger('Error', err.message || 'Failed to add note.')
} finally {
setSavingNew(false)
}
}
const handleUpdate = async (noteId) => {
const content = editingText.trim()
if (!content) return
setSavingEdit(true)
try {
const data = await api.put(`/devices/${deviceId}/notes/${noteId}`, { content })
setNotes(prev => prev.map(n => n.id === noteId ? (data.note || { ...n, content }) : n))
setEditingId(null)
} catch (err) {
toast.danger('Error', err.message || 'Failed to update note.')
} finally {
setSavingEdit(false)
}
}
const handleDelete = async (noteId) => {
setDeletingId(noteId)
try {
await api.delete(`/devices/${deviceId}/notes/${noteId}`)
setNotes(prev => prev.filter(n => n.id !== noteId))
} catch (err) {
toast.danger('Error', err.message || 'Failed to delete note.')
} finally {
setDeletingId(null)
}
}
if (!loaded) {
return (
Loading notes…
)
}
return (
{notes.length === 0 && !adding && (
No notes for this device.
)}
{notes.map(note => (
{editingId === note.id ? (
setEditingText(e.target.value)}
rows={3}
/>
handleUpdate(note.id)} loading={savingEdit}>
Save
setEditingId(null)}>
Cancel
) : (
{note.content}
{note.created_by && `${note.created_by} · `}{note.created_at ? fmtDateTimeMedium(note.created_at) : ''}
{canEdit && (
{ setEditingId(note.id); setEditingText(note.content) }}
aria-label="Edit note"
>
handleDelete(note.id)}
aria-label="Delete note"
>
)}
)}
))}
{/* Add note */}
{canEdit && (
adding ? (
setNewText(e.target.value)}
placeholder="Write a note…"
rows={3}
/>
Add Note
{ setAdding(false); setNewText('') }}>
Cancel
) : (
} onClick={() => setAdding(true)} style={{ alignSelf: 'flex-start' }}>
Add Note
)
)}
)
}
// ─── Tags Row ─────────────────────────────────────────────────────────────────
function TagsRow({ tags, canEdit, onAdd, onRemove, saving }) {
const [input, setInput] = useState('')
const handleAdd = () => {
const trimmed = input.trim()
if (!trimmed) return
onAdd(trimmed)
setInput('')
}
return (
{tags.length === 0 && !canEdit && (
No tags.
)}
{tags.map(tag => (
{tag}
{canEdit && (
onRemove(tag)}
disabled={saving}
aria-label={`Remove tag ${tag}`}
style={{
background: 'none', border: 'none', padding: 0, lineHeight: 1,
cursor: 'pointer', color: 'var(--color-text-muted)', fontSize: 13,
opacity: saving ? 0.4 : 1,
}}
>
×
)}
))}
{canEdit && (
setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAdd() } }}
placeholder="Add tag…"
style={{
background: GLASS_BG_INNER,
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: '4px 10px',
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-primary)',
outline: 'none',
width: 110,
fontFamily: 'var(--font-family-base)',
}}
/>
Add
)}
)
}
// ─── GeneralTab ───────────────────────────────────────────────────────────────
export default function GeneralTab({
device,
canEdit,
attr,
tags,
setTags,
onEditLocation,
onEditAttributes,
onEditLogging,
}) {
const { toast } = useToast()
const [locationName, setLocationName] = useState(null)
const [savingTags, setSavingTags] = useState(false)
// Logging level state — local editable copies for sliders
const [serialLevel, setSerialLevel] = useState(attr?.serialLogLevel ?? 0)
const [sdLevel, setSdLevel] = useState(attr?.sdLogLevel ?? 0)
const [mqttLevel, setMqttLevel] = useState(attr?.mqttLogLevel ?? 0)
const id = device?.id || device?.device_id
// Inject CSS keyframes + slider styles
useEffect(() => { ensureKeyframes() }, [])
// Keep slider state in sync if attr changes
useEffect(() => {
if (Number.isFinite(attr?.serialLogLevel)) setSerialLevel(attr.serialLogLevel)
if (Number.isFinite(attr?.sdLogLevel)) setSdLevel(attr.sdLogLevel)
if (Number.isFinite(attr?.mqttLogLevel)) setMqttLevel(attr.mqttLogLevel)
}, [attr?.serialLogLevel, attr?.sdLogLevel, attr?.mqttLogLevel])
// Nearest place
const coords = useMemo(() => parseCoordinates(device?.device_location_coordinates), [device?.device_location_coordinates])
useEffect(() => {
if (!coords) return
setLocationName(null)
fetch(`https://nominatim.openstreetmap.org/reverse?lat=${coords.lat}&lon=${coords.lng}&format=json`)
.then(r => r.json())
.then(data => setLocationName(getNearestPlaceLabel(data) || null))
.catch(() => setLocationName(null))
}, [coords])
const net = attr?.networkSettings || {}
const staticIpAddress = Array.isArray(net.ipAddress)
? net.ipAddress.filter(Boolean).join('.')
: (net.ipAddress || '')
// ── Tag handlers ────────────────────────────────────────────────────────────
const handleAddTag = useCallback(async (tag) => {
const trimmed = tag.trim()
if (!trimmed || tags.includes(trimmed)) return
const next = [...tags, trimmed]
setSavingTags(true)
try {
await api.put(`/devices/${id}/tags`, { tags: next })
setTags(next)
} catch (err) {
toast.danger('Error', err.message || 'Failed to add tag.')
} finally {
setSavingTags(false)
}
}, [id, tags, setTags, toast])
const handleRemoveTag = useCallback(async (tag) => {
const next = tags.filter(t => t !== tag)
setSavingTags(true)
try {
await api.put(`/devices/${id}/tags`, { tags: next })
setTags(next)
} catch (err) {
toast.danger('Error', err.message || 'Failed to remove tag.')
} finally {
setSavingTags(false)
}
}, [id, tags, setTags, toast])
// ── Log level save (on blur / edit) ─────────────────────────────────────────
const handleSaveLogging = useCallback(async (field, value) => {
if (!canEdit) return
try {
await api.put(`/devices/${id}`, {
device_attributes: {
...(attr || {}),
serialLogLevel: field === 'serial' ? value : serialLevel,
sdLogLevel: field === 'sd' ? value : sdLevel,
mqttLogLevel: field === 'mqtt' ? value : mqttLevel,
}
})
toast.success('Saved', 'Log levels updated.')
} catch (err) {
toast.danger('Error', err.message || 'Failed to save log levels.')
}
}, [id, attr, serialLevel, sdLevel, mqttLevel, canEdit, toast])
// ── Network fields ───────────────────────────────────────────────────────────
const netFields = [
{ label: 'Hostname', value: net.hostname || '—' },
{ label: 'IP Address', value: staticIpAddress || '—' },
{ label: 'Gateway', value: net.gateway || '—' },
{ label: 'DNS', value: net.dns || '—' },
{ label: 'Subnet', value: net.subnet || '—' },
{ label: 'MAC Address', value: net.mac || net.macAddress || '—' },
]
// ── Render ──────────────────────────────────────────────────────────────────
return (
{/* ════════════════════════════════════════════════════════════════════
ROW 1 — Map (2/3) + Safety Settings (1/3) equal height
════════════════════════════════════════════════════════════════════ */}
{/* Map */}
{/* Safety Settings */}
)}
>
{/* ════════════════════════════════════════════════════════════════════
ROW 2 — Mechanisms | Log Settings | Network Info equal height
════════════════════════════════════════════════════════════════════ */}
{/* Mechanisms */}
)}
>
{[
{ label: 'Has Bells', value: attr?.hasBells, yes: 'Yes', no: 'No', variant: 'success' },
{ label: 'Has Clock', value: attr?.hasClock, yes: 'Yes', no: 'No', variant: 'info' },
].map(({ label, value, yes, no, variant }) => (
{label}
{value ? yes : no}
))}
Connected Bells
{Number.isFinite(attr?.totalBells) ? attr.totalBells : '—'}
{/* Log Settings */}
)}
>
{ setSerialLevel(v); handleSaveLogging('serial', v) }}
/>
{ setSdLevel(v); handleSaveLogging('sd', v) }}
/>
{ setMqttLevel(v); handleSaveLogging('mqtt', v) }}
/>
{/* Network Info */}
)}
>
{netFields.map(({ label, value }) => (
{label}
{value}
))}
{/* ════════════════════════════════════════════════════════════════════
ROW 3 — Tags & Notes (full width)
════════════════════════════════════════════════════════════════════ */}
{/* Tags row */}
{/* Divider */}
{/* Device Notes */}
)
}