diff --git a/frontend/src/components/ui/EditableText.jsx b/frontend/src/components/ui/EditableText.jsx
new file mode 100644
index 0000000..3f09b47
--- /dev/null
+++ b/frontend/src/components/ui/EditableText.jsx
@@ -0,0 +1,123 @@
+// src/components/ui/EditableText.jsx
+// Inline hover-to-edit text — shows a pencil icon on hover, click swaps to a
+// text input in place. Enter/blur saves, Escape cancels. Use for a single
+// display value that's editable in-place without a modal (e.g. a page hero
+// title) — for anything with more than one field, use a Modal instead.
+//
+// Props:
+// value — string — current display value
+// placeholder — string — shown (italic, muted) when value is empty
+// onSave — (newValue) => void | Promise — called on confirm; not called if unchanged
+// editable — bool — hide the pencil / disable editing entirely (default: true)
+// textStyle — style object applied to the display span AND the input, so
+// swapping between them doesn't shift surrounding layout
+// className — extra class on the root element
+// inputAriaLabel — string — accessible label for the input while editing
+
+import { useState, useRef, useEffect } from 'react'
+import Icon from '@/components/ui/Icon'
+
+export default function EditableText({
+ value,
+ placeholder = 'Untitled',
+ onSave,
+ editable = true,
+ textStyle = {},
+ className = '',
+ inputAriaLabel = 'Edit value',
+}) {
+ const [editing, setEditing] = useState(false)
+ const [draft, setDraft] = useState(value || '')
+ const [saving, setSaving] = useState(false)
+ const inputRef = useRef(null)
+
+ useEffect(() => {
+ if (editing) {
+ setDraft(value || '')
+ // Wait a tick so the input exists before focusing.
+ requestAnimationFrame(() => inputRef.current?.select())
+ }
+ }, [editing, value])
+
+ const commit = async () => {
+ const trimmed = draft.trim()
+ setEditing(false)
+ if (trimmed === (value || '')) return
+ setSaving(true)
+ try {
+ await onSave?.(trimmed)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const cancel = () => {
+ setDraft(value || '')
+ setEditing(false)
+ }
+
+ if (editing) {
+ return (
+ setDraft(e.target.value)}
+ onBlur={commit}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') { e.preventDefault(); commit() }
+ if (e.key === 'Escape') { e.preventDefault(); cancel() }
+ }}
+ aria-label={inputAriaLabel}
+ disabled={saving}
+ className={className}
+ style={{
+ ...textStyle,
+ background: 'var(--color-bg-elevated)',
+ border: '1px solid var(--color-border-strong)',
+ borderRadius: 'var(--radius-sm)',
+ padding: '1px 6px',
+ outline: 'none',
+ minWidth: '120px',
+ }}
+ />
+ )
+ }
+
+ return (
+
+
+ {value || placeholder}
+
+ {editable && (
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/ui/LineChart.jsx b/frontend/src/components/ui/LineChart.jsx
new file mode 100644
index 0000000..4ca857b
--- /dev/null
+++ b/frontend/src/components/ui/LineChart.jsx
@@ -0,0 +1,514 @@
+// src/components/ui/LineChart.jsx
+// Time-series line chart — hand-rolled SVG, no charting library. Single primitive
+// reused for every metric-over-time graph in the console (RSSI, ping latency,
+// free heap, etc). Never build a one-off inline chart in a page — use this.
+//
+// Props:
+// data — array<{ x: number|string (ms epoch or index), y: number|null }>
+// null y-values render as gaps (device offline / no sample), not zeros.
+// color — CSS color token, e.g. 'var(--color-primary)' (default: 'var(--color-primary)')
+// Ignored if colorStops is set.
+// colorStops — array<{ value: number, color: string }>, sorted ascending by
+// value — colors the line/fill as a smooth vertical gradient
+// keyed to the Y VALUE (not x-position), so "danger red" always
+// means the same y-value everywhere on the chart regardless of
+// when it occurred. Requires >= 2 stops.
+// height — number, px (default: 160)
+// formatValue — (y) => string — used in tooltip + trailing value badge
+// formatX — (x) => string — used in tooltip
+// unit — string appended after formatValue in the trailing badge, e.g. 'dBm', 'ms', 'KB'
+// xMin, xMax — number (ms epoch) — explicit axis bounds. When provided, the
+// chart renders this FULL span even if data covers less of it
+// (e.g. a 30-day range with 1 day of real data shows the data
+// bunched at one end, not silently zoomed to fit). Omit to
+// auto-fit to the data's own x-range (previous behaviour).
+// gapThresholdMs — if the x-gap between two consecutive points exceeds this,
+// the segment break is drawn as a dotted "possibly offline"
+// connector instead of a silent gap. Omit to just break silently.
+// offlineWindows — array<{ start: number, end: number }> (ms epoch) — explicit
+// "known offline" windows to render as dotted baseline
+// regardless of this chart's own data gaps. Use this instead
+// of (or alongside) gapThresholdMs when offline periods are
+// computed once from a shared/authoritative signal (e.g. the
+// device's heartbeat cadence) and applied identically across
+// several charts whose own series may be too sparse for
+// per-series gap detection to find the same windows —
+// otherwise a chart fed from a low-frequency series (ping,
+// diagnostics) can under- or over-detect gaps relative to one
+// fed from a high-frequency series (heartbeats) even though
+// both describe the same device over the same real outage.
+// Always rendered in the 'baseline' style (see gapStyle)
+// since these are known facts, not inferred from this
+// chart's own two neighboring points.
+// gapStyle — 'connector' | 'baseline' (default: 'connector')
+// 'connector' draws the dotted line directly between the last point
+// before the gap and the first point after it (diagonal if their
+// y-values differ). 'baseline' instead draws it as a flat dotted
+// line along the chart's bottom edge, spanning the same x-range —
+// reads as "no data" rather than implying a value in between.
+// emptyLabel — string shown when data has no non-null points
+// className — extra classes on the root element
+
+import { useMemo, useState, useRef, useId } from 'react'
+
+const PAD = { top: 12, right: 12, bottom: 22, left: 8 }
+// Above this many raw points, downsample to ~2 points (min+max) per pixel
+// column so spikes survive being zoomed out over a wide time range instead
+// of getting smoothed away by naive decimation.
+const DOWNSAMPLE_THRESHOLD = 400
+
+// Centripetal Catmull-Rom → cubic Bezier. Using each segment's actual
+// Euclidean distance (not a fixed 1/6 fraction) keeps tangents proportional
+// to real point spacing — with the naive uniform version, a run of tightly-
+// packed points followed by a big gap produces an overshooting tangent that
+// visibly loops the curve backward before continuing forward.
+function clampToRange(v, a, b) {
+ return Math.min(Math.max(v, Math.min(a, b)), Math.max(a, b))
+}
+
+function smoothPath(seg) {
+ if (seg.length < 2) return `M${seg[0].px},${seg[0].py}`
+ if (seg.length === 2) return `M${seg[0].px},${seg[0].py}L${seg[1].px},${seg[1].py}`
+
+ const dist = (a, b) => Math.max(1e-3, Math.hypot(b.px - a.px, b.py - a.py))
+ const n = seg.length
+
+ let d = `M${seg[0].px},${seg[0].py}`
+ for (let i = 0; i < n - 1; i++) {
+ const p0 = seg[i - 1] || seg[i]
+ const p1 = seg[i]
+ const p2 = seg[i + 1]
+ const p3 = seg[i + 2] || p2
+
+ const d1 = dist(p0, p1)
+ const d2 = dist(p1, p2)
+ const d3 = dist(p2, p3)
+
+ // Tangent at p1, scaled by the ratio of the adjacent segment length to
+ // the sum of both neighboring segments — this is what makes it
+ // "centripetal": short segments pull the tangent in, long ones don't
+ // over-extend it.
+ const t1 = d1 / (d1 + d2)
+ let c1x = p1.px + (p2.px - p0.px) * (t1 / 3)
+ const c1y = p1.py + (p2.py - p0.py) * (t1 / 3)
+
+ const t2 = d2 / (d2 + d3)
+ let c2x = p2.px - (p3.px - p1.px) * ((1 - t2) / 3)
+ const c2y = p2.py - (p3.py - p1.py) * ((1 - t2) / 3)
+
+ // X is always time in these charts, so it must be monotonic — a cubic
+ // segment whose control point x strays outside its own [p1.x, p2.x] span
+ // renders as the line visibly reversing direction (looping backward)
+ // before continuing forward. This happens whenever a neighboring point
+ // pulls the tangent estimate too far: most commonly right at a segment's
+ // open boundary (next to a data gap, where there's no further point to
+ // temper the tangent), and also wherever the min/max-per-bucket
+ // downsampler emits two points with nearly identical x — a near-vertical
+ // spike that the same tangent math overshoots on either side of. Clamping
+ // x keeps every curve horizontally monotonic without altering its y
+ // shape, which is what removes the loop while leaving normal smoothing
+ // (evenly spaced points, no reordering) completely unaffected.
+ c1x = clampToRange(c1x, p1.px, p2.px)
+ c2x = clampToRange(c2x, p1.px, p2.px)
+
+ d += `C${c1x},${c1y} ${c2x},${c2y} ${p2.px},${p2.py}`
+ }
+ return d
+}
+
+function buildSegments(points) {
+ // Splits into contiguous segments at null gaps — each becomes its own path.
+ const segments = []
+ let current = []
+ for (const p of points) {
+ if (p.y == null) {
+ if (current.length) segments.push(current)
+ current = []
+ } else {
+ current.push(p)
+ }
+ }
+ if (current.length) segments.push(current)
+ return segments
+}
+
+// Min/max-per-pixel-bucket downsampling. Guarantees any spike/dip within a
+// bucket survives (both the min AND max point are kept), unlike averaging
+// which can smooth a brief extreme out of existence when zoomed out over a
+// wide range. Null (gap) points pass through untouched — they carry their
+// own meaning and must never be merged into a bucket.
+function downsample(points, targetBuckets) {
+ if (points.length <= targetBuckets * 2) return points
+
+ const validXs = points.filter(p => p.y != null).map(p => p.x)
+ if (validXs.length === 0) return points
+ const xMin = Math.min(...validXs)
+ const xMax = Math.max(...validXs)
+ const span = Math.max(1, xMax - xMin)
+
+ const buckets = new Map()
+ for (const p of points) {
+ if (p.y == null) continue
+ const bucketIdx = Math.min(targetBuckets - 1, Math.floor(((p.x - xMin) / span) * targetBuckets))
+ const existing = buckets.get(bucketIdx)
+ if (!existing) {
+ buckets.set(bucketIdx, { min: p, max: p })
+ } else {
+ if (p.y < existing.min.y) existing.min = p
+ if (p.y > existing.max.y) existing.max = p
+ }
+ }
+
+ // Each bucket contributes its min and max point (the same point twice if
+ // only one sample fell in that bucket) — order doesn't matter here since
+ // gap markers are merged back in and everything is sorted by x afterward.
+ const flattened = []
+ for (const { min, max } of buckets.values()) {
+ flattened.push(min)
+ if (max !== min) flattened.push(max)
+ }
+ const gaps = points.filter(p => p.y == null)
+ return [...flattened, ...gaps].sort((a, b) => a.x - b.x)
+}
+
+export default function LineChart({
+ data = [],
+ color = 'var(--color-primary)',
+ colorStops = null,
+ height = 160,
+ formatValue = v => String(v),
+ formatX = x => String(x),
+ unit = '',
+ xMin: xMinProp = null,
+ xMax: xMaxProp = null,
+ gapThresholdMs = null,
+ offlineWindows = null,
+ gapStyle = 'connector',
+ emptyLabel = 'No data yet.',
+ className = '',
+}) {
+ const containerRef = useRef(null)
+ const [width, setWidth] = useState(600)
+ const [hoverIdx, setHoverIdx] = useState(null)
+ const gradientId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
+
+ // Measure container width responsively — ResizeObserver is the only
+ // chart-sizing mechanism in the app.
+ const measureRef = el => {
+ containerRef.current = el
+ if (!el) return
+ const ro = new ResizeObserver(entries => {
+ const w = entries[0]?.contentRect?.width
+ if (w) setWidth(w)
+ })
+ ro.observe(el)
+ el._ro = ro
+ }
+
+ const validPoints = data.filter(d => d.y != null)
+ const hasData = validPoints.length > 0
+
+ const { points, yMin, yMax, xMin, xMax } = useMemo(() => {
+ if (!hasData) return { points: [], yMin: 0, yMax: 1, xMin: 0, xMax: 1 }
+
+ const xs = data.map(d => Number(d.x))
+ const ys = validPoints.map(d => Number(d.y))
+ // Explicit range props win — the chart must render the FULL requested
+ // span (e.g. "last 30 days") even when real data only covers a sliver of
+ // it, rather than silently auto-zooming to whatever data happens to exist.
+ let xMin = xMinProp != null ? Number(xMinProp) : Math.min(...xs)
+ let xMax = xMaxProp != null ? Number(xMaxProp) : Math.max(...xs)
+ let yMin = Math.min(...ys)
+ let yMax = Math.max(...ys)
+ // colorStops are value-anchored on an absolute scale (e.g. alert
+ // thresholds), not the data's own range — if the y-axis only auto-fit to
+ // the data, stops outside that range would clamp to the same 0%/100%
+ // gradient offset and collapse into a hard edge instead of a smooth
+ // blend. Widening yMin/yMax to cover every stop keeps the full gradient
+ // visible even when the data currently sits well inside a safe band.
+ if (colorStops && colorStops.length) {
+ const stopValues = colorStops.map(s => s.value)
+ yMin = Math.min(yMin, ...stopValues)
+ yMax = Math.max(yMax, ...stopValues)
+ }
+
+ if (xMin === xMax) { xMin -= 1; xMax += 1 }
+ if (yMin === yMax) { yMin -= 1; yMax += 1 }
+ // Headroom so the line/dots never touch the plot edge.
+ const yPad = (yMax - yMin) * 0.12
+ yMin -= yPad
+ yMax += yPad
+
+ const innerW = Math.max(1, width - PAD.left - PAD.right)
+ const innerH = Math.max(1, height - PAD.top - PAD.bottom)
+
+ let mapped = data.map(d => {
+ const x = Number(d.x)
+ const px = PAD.left + ((x - xMin) / (xMax - xMin)) * innerW
+ const py = d.y == null ? null : PAD.top + innerH - ((Number(d.y) - yMin) / (yMax - yMin)) * innerH
+ return { x, y: d.y, px, py: py ?? 0 }
+ })
+ // Drop points that fall outside the explicit range entirely (can happen
+ // if a caller passes data slightly wider than xMin/xMax).
+ mapped = mapped.filter(p => p.x >= xMin - 1 && p.x <= xMax + 1)
+
+ // Cut every real segment at explicit offline-window boundaries —
+ // offlineWindows is computed externally (e.g. from a denser series like
+ // heartbeats) and can disagree with THIS series' own point spacing. Two
+ // distinct cases both need handling, not just one:
+ // 1. A real sample happens to fall inside the window (mask it to null).
+ // 2. No sample at all falls inside a narrow window in a sparse series
+ // (ping every 60s, diagnostics every 5min) — the point just before
+ // and just after the window are still adjacent in the array with
+ // nothing between them, so without inserting explicit boundary gap
+ // markers, smoothPath() draws one continuous curve straight across
+ // the "offline" stretch, right on top of the dotted connector.
+ // Inserting a null-y marker at both edges of the window forces
+ // buildSegments() to break there even when case 2 applies.
+ if (offlineWindows && offlineWindows.length) {
+ mapped = mapped.map(p => {
+ if (p.y == null) return p
+ const inWindow = offlineWindows.some(w => p.x >= w.start && p.x <= w.end)
+ return inWindow ? { ...p, y: null } : p
+ })
+ const boundaryMarkers = []
+ for (const w of offlineWindows) {
+ const spansWindow = mapped.some(p => p.x < w.start) && mapped.some(p => p.x > w.end)
+ if (!spansWindow) continue
+ for (const x of [w.start, w.end]) {
+ if (x < xMin || x > xMax) continue
+ const px = PAD.left + ((x - xMin) / (xMax - xMin)) * innerW
+ boundaryMarkers.push({ x, y: null, px, py: 0 })
+ }
+ }
+ if (boundaryMarkers.length) {
+ mapped = [...mapped, ...boundaryMarkers].sort((a, b) => a.x - b.x)
+ }
+ }
+
+ const targetBuckets = Math.max(50, Math.ceil(width / 3))
+ if (mapped.length > DOWNSAMPLE_THRESHOLD) {
+ mapped = downsample(mapped, targetBuckets)
+ }
+
+ return { points: mapped, yMin, yMax, xMin, xMax }
+ }, [data, width, height, hasData, validPoints, xMinProp, xMaxProp, colorStops, offlineWindows])
+
+ if (!hasData) {
+ return (
+
+ {emptyLabel}
+
+ )
+ }
+
+ const segments = buildSegments(points)
+ const innerW = Math.max(1, width - PAD.left - PAD.right)
+ const innerH = Math.max(1, height - PAD.top - PAD.bottom)
+ const baseline = PAD.top + innerH
+
+ const hovered = hoverIdx != null ? points[hoverIdx] : null
+ const latest = [...points].reverse().find(p => p.y != null)
+
+ // colorStops → an SVG linearGradient keyed to Y VALUE. gradientUnits
+ // userSpaceOnUse + y1/y2 in pixel-space (not objectBoundingBox) is what
+ // makes "red" always mean the same underlying value across every chart,
+ // rather than always meaning "top of whatever this chart's own range is."
+ const strokeColor = colorStops ? `url(#${gradientId})` : color
+ const sortedStops = colorStops ? [...colorStops].sort((a, b) => a.value - b.value) : null
+
+ // Dotted "possibly offline" connectors — drawn between the last point of one
+ // segment and the first point of the next, only when the real x-gap between
+ // them exceeds gapThresholdMs. A shorter gap (e.g. one missed 30s heartbeat)
+ // is just a silent break; a longer one is worth flagging visually.
+ // gapStyle 'baseline' flattens the connector to the chart's bottom edge
+ // instead of a diagonal between the two real y-values — a straight line
+ // at the actual readings would visually imply an interpolated value that
+ // was never measured, which is misleading for a gap that means "offline."
+ const offlineConnectors = []
+ if (gapThresholdMs) {
+ for (let i = 0; i < segments.length - 1; i++) {
+ const end = segments[i][segments[i].length - 1]
+ const start = segments[i + 1][0]
+ if (start.x - end.x > gapThresholdMs) {
+ if (gapStyle === 'baseline') {
+ offlineConnectors.push({ x1: end.px, y1: baseline, x2: start.px, y2: baseline })
+ } else {
+ offlineConnectors.push({ x1: end.px, y1: end.py, x2: start.px, y2: start.py })
+ }
+ }
+ }
+ }
+
+ // Explicit offline windows (computed once, shared across charts — see the
+ // prop doc above) mapped straight into this chart's own pixel space using
+ // the same xMin/xMax/innerW as the data points, and always drawn along the
+ // baseline — these are known facts about the device/console, not something
+ // inferred from this particular series' own two neighboring points, so they
+ // render even in a stretch where this series happens to have no points at
+ // all on either side of the window.
+ if (offlineWindows && offlineWindows.length) {
+ const toPx = x => PAD.left + ((x - xMin) / (xMax - xMin)) * innerW
+ for (const w of offlineWindows) {
+ const clampedStart = Math.max(w.start, xMin)
+ const clampedEnd = Math.min(w.end, xMax)
+ if (clampedEnd <= clampedStart) continue
+ offlineConnectors.push({ x1: toPx(clampedStart), y1: baseline, x2: toPx(clampedEnd), y2: baseline })
+ }
+ }
+
+ const handleMove = e => {
+ if (!containerRef.current) return
+ const rect = containerRef.current.getBoundingClientRect()
+ const relX = e.clientX - rect.left
+ // Nearest point by px distance.
+ let nearest = 0
+ let nearestDist = Infinity
+ points.forEach((p, i) => {
+ const d = Math.abs(p.px - relX)
+ if (d < nearestDist) { nearestDist = d; nearest = i }
+ })
+ setHoverIdx(nearest)
+ }
+
+ // Gridlines: 3 horizontal bands (min/mid/max) — recessive, label-free except axis text.
+ const gridY = [yMin + (yMax - yMin) * 0.0, yMin + (yMax - yMin) * 0.5, yMin + (yMax - yMin) * 1.0]
+
+ const legendColor = sortedStops
+ ? (latest ? sortedStops.reduce((closest, s) => Math.abs(s.value - latest.y) < Math.abs(closest.value - latest.y) ? s : closest, sortedStops[0]).color : sortedStops[0].color)
+ : color
+
+ return (
+