diff --git a/.gitignore b/.gitignore index bacaf01..d15e13e 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,8 @@ CLAUDE.md AI_MEMORY.md knowledge.md multipath.md + +# Teesside Mesh site (separate project, not part of ukmesh.com) +frontend/src/pages/teesside/ +frontend/src/styles/teesside-dashboard.css +frontend/public/favicon-teesside.svg diff --git a/frontend/public/favicon-teesside.svg b/frontend/public/favicon-teesside.svg deleted file mode 100644 index c4358f4..0000000 --- a/frontend/public/favicon-teesside.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/frontend/src/pages/teesside/TeessieDashboard.tsx b/frontend/src/pages/teesside/TeessieDashboard.tsx deleted file mode 100644 index a0fe351..0000000 --- a/frontend/src/pages/teesside/TeessieDashboard.tsx +++ /dev/null @@ -1,587 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { extractPacketSummary } from '../../hooks/packetFeed.js'; -import '../../styles/teesside-dashboard.css'; - -// ── Types ──────────────────────────────────────────────────────────────────── - -type ConnectivityData = { - inbound: boolean; - outbound: boolean; - lastInbound: string | null; - lastOutbound: string | null; - windowHours: number; - checkedAt: string; -}; - -type StatsData = { - mqttNodes: number; - nodesDay: number; - packetsDay: number; -}; - -type NodeStatusRow = { - node_id: string; - name: string | null; - uptime_secs: number | null; - channel_utilization: number | null; - time: string | null; -}; - -type RadioSample = { - batt_percent?: number | null; - batt_milli_volts?: number | null; - last_rssi?: number | null; - last_snr?: number | null; - noise_floor?: number | null; - total_up_time_secs?: number | null; -}; - -type RadioMonitor = { - id: string; - nodeName: string; - pollMinutes: number; - lastSuccessAt: string | null; - lastError: string | null; - lastSample: RadioSample | null; -}; - -// Actual shape returned by /api/radio-stats → radio bot /state -type RadioStatsData = { - connected: boolean; - monitors: RadioMonitor[]; -}; - -type HistorySample = { - time: string; - batteryPercent: number | null; -}; - -type ObserverActivity = { - node_id: string; - name: string | null; - rx_24h: number; - tx_24h: number; -}; - -type RecentPacket = { - time: string; - packet_hash?: string | null; - src_node_id?: string | null; - rx_node_id?: string | null; - observer_node_ids?: string[] | null; - hop_count?: number | null; - packet_type?: number | null; - summary?: string | null; - payload?: Record | null; -}; - -// ── Helpers ────────────────────────────────────────────────────────────────── - -const TYPE_LABELS: Record = { - 0: 'REQ', 1: 'RSP', 2: 'DM', 3: 'ACK', 4: 'ADV', - 5: 'GRP', 6: 'DAT', 7: 'ANON', 8: 'PATH', 9: 'TRC', 11: 'CTL', -}; - -function timeAgo(ts?: string | null): string { - if (!ts) return 'never'; - const sec = Math.max(0, Math.floor((Date.now() - Date.parse(ts)) / 1000)); - if (sec < 60) return `${sec}s ago`; - if (sec < 3600) return `${Math.floor(sec / 60)}m ago`; - if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`; - return `${Math.floor(sec / 86400)}d ago`; -} - -function fmtUptime(secs?: number | null): string { - if (secs == null || secs < 0) return '—'; - const d = Math.floor(secs / 86400); - const h = Math.floor((secs % 86400) / 3600); - const m = Math.floor((secs % 3600) / 60); - if (d > 0) return `${d}d ${h}h`; - if (h > 0) return `${h}h ${m}m`; - return `${m}m`; -} - -function isOnline(monitor: RadioMonitor): boolean { - if (!monitor.lastSuccessAt) return false; - const ageMs = Date.now() - Date.parse(monitor.lastSuccessAt); - return ageMs < monitor.pollMinutes * 2 * 60 * 1000; -} - -function battClass(pct: number | null): string { - if (pct == null) return ''; - if (pct >= 50) return 'td-batt-good'; - if (pct >= 20) return 'td-batt-warn'; - return 'td-batt-low'; -} - -function battBarColor(pct: number | null): string { - if (pct == null) return 'var(--td-text-muted)'; - if (pct >= 50) return 'var(--td-batt-good)'; - if (pct >= 20) return 'var(--td-batt-warn)'; - return 'var(--td-batt-low)'; -} - -// ── Data fetching hook ─────────────────────────────────────────────────────── - -function usePolled(url: string, intervalMs: number): { data: T | null; error: boolean } { - const [data, setData] = useState(null); - const [error, setError] = useState(false); - - const fetchData = useCallback(async () => { - try { - const res = await fetch(`${url}${url.includes('?') ? '&' : '?'}_ts=${Date.now()}`); - if (!res.ok) { setError(true); return; } - setData(await res.json() as T); - setError(false); - } catch { - setError(true); - } - }, [url]); - - useEffect(() => { - void fetchData(); - const id = setInterval(() => void fetchData(), intervalMs); - return () => clearInterval(id); - }, [fetchData, intervalMs]); - - return { data, error }; -} - -// ── Sub-components ──────────────────────────────────────────────────────────── - -function ConnectivityBanner({ data, error }: { data: ConnectivityData | null; error: boolean }) { - if (error) return
Unable to check connectivity
; - if (!data) return
Checking connectivity…
; - - const both = data.inbound && data.outbound; - const either = data.inbound || data.outbound; - - const lightEmoji = both ? '🟢' : either ? '🟡' : '🔴'; - const labelText = both ? 'Connected to UK Mesh' : either ? 'One-way communication' : 'No mesh connectivity'; - const borderClass = both ? 'td-connectivity--green' : either ? 'td-connectivity--amber' : 'td-connectivity--red'; - - return ( -
-
{lightEmoji}
-
-

{labelText}

-
- Inbound: {data.inbound ? `last ${timeAgo(data.lastInbound)}` : `none in ${data.windowHours}h`} - Outbound: {data.outbound ? `last ${timeAgo(data.lastOutbound)}` : `none in ${data.windowHours}h`} - Checked {timeAgo(data.checkedAt)} -
-
-
- ); -} - -function StatsRow({ data, error }: { data: StatsData | null; error: boolean }) { - if (error) return
Stats unavailable
; - const cards: { label: string; value: number | string }[] = [ - { label: 'Active MQTT repeaters', value: data?.mqttNodes ?? '—' }, - { label: 'Heard / 24h', value: data?.nodesDay ?? '—' }, - { label: 'Packets / 24h', value: data?.packetsDay ?? '—' }, - ]; - return ( -
- {cards.map(c => ( -
-

{c.value}

-

{c.label}

-
- ))} -
- ); -} - -const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; - -function BatterySparkline({ samples }: { samples: HistorySample[] }) { - const usable = samples - .filter(s => s.batteryPercent != null && Number.isFinite(s.batteryPercent)) - .map(s => ({ t: Date.parse(s.time), pct: s.batteryPercent as number })) - .filter(s => Date.now() - s.t <= SEVEN_DAYS_MS) - .sort((a, b) => a.t - b.t); - - if (usable.length < 2) return null; - - const W = 200, H = 44; - const tMin = usable[0].t, tMax = usable[usable.length - 1].t; - const tRange = tMax - tMin || 1; - - const px = (s: { t: number; pct: number }) => ((s.t - tMin) / tRange) * W; - const py = (s: { t: number; pct: number }) => H - (s.pct / 100) * H; - - const linePts = usable.map(s => `${px(s)},${py(s)}`).join(' '); - const areaPts = [ - `0,${H}`, - ...usable.map(s => `${px(s)},${py(s)}`), - `${W},${H}`, - ].join(' '); - - const last = usable[usable.length - 1]; - const strokeColor = last.pct >= 50 ? 'var(--td-batt-good)' : last.pct >= 20 ? 'var(--td-batt-warn)' : 'var(--td-batt-low)'; - const fillColor = last.pct >= 50 ? 'rgba(34,197,94,0.12)' : last.pct >= 20 ? 'rgba(245,158,11,0.12)' : 'rgba(239,68,68,0.12)'; - - const y50 = H - (50 / 100) * H; - const y20 = H - (20 / 100) * H; - - return ( - - {/* threshold lines */} - - - {/* area fill */} - - {/* line */} - - {/* latest dot */} - - - ); -} - -function RepeaterGrid({ - radioData, - radioError, - batteryHistory, -}: { - radioData: RadioStatsData | null; - radioError: boolean; - batteryHistory: Record; -}) { - if (radioError) return
Repeater data unavailable
; - if (!radioData) return
Loading repeaters…
; - - const monitors = [...radioData.monitors].sort((a, b) => { - const ao = isOnline(a), bo = isOnline(b); - if (ao !== bo) return ao ? -1 : 1; - return (b.lastSuccessAt ?? '').localeCompare(a.lastSuccessAt ?? ''); - }); - - return ( -
- {monitors.map(monitor => { - const online = isOnline(monitor); - const sample = monitor.lastSample; - const pct = sample?.batt_percent != null - ? Math.max(0, Math.min(100, Math.round(sample.batt_percent))) - : null; - - return ( -
-
-
-
{monitor.nodeName}
-
- - {pct != null && ( -
-
-
- )} - - - -
-
- Battery - {pct != null ? `${pct}%` : '—'} -
-
- Last polled - {timeAgo(monitor.lastSuccessAt)} -
-
-
- ); - })} -
- ); -} - -function ObserverGrid({ - activity, - mqttNodes, - error, -}: { - activity: ObserverActivity[] | null; - mqttNodes: NodeStatusRow[] | null; - error: boolean; -}) { - if (error) return
Observer data unavailable
; - if (!activity) return
Loading observers…
; - - // Merge activity counts into the mqtt node list; fall back to activity-only rows - const merged = activity.map(a => { - const telemetry = mqttNodes?.find(n => n.node_id.toLowerCase() === a.node_id.toLowerCase()); - const lastSeen = telemetry?.time ?? null; - const online = lastSeen ? (Date.now() - Date.parse(lastSeen)) < 10 * 60 * 1000 : false; - return { ...a, uptime_secs: telemetry?.uptime_secs ?? null, lastSeen, online }; - }).sort((a, b) => (b.online ? 1 : 0) - (a.online ? 1 : 0) || b.rx_24h - a.rx_24h); - - return ( -
- {merged.map(node => ( -
-
-
-
{node.name ?? node.node_id.slice(0, 12)}
-
-
-
- Received / 24h - {node.rx_24h.toLocaleString()} -
-
- Sent / 24h - {node.tx_24h.toLocaleString()} -
- {node.uptime_secs != null && ( -
- Uptime - {fmtUptime(node.uptime_secs)} -
- )} -
- Last seen - {timeAgo(node.lastSeen)} -
-
-
- ))} -
- ); -} - -function PacketFeed({ - data, - error, - nodeNames, -}: { - data: RecentPacket[] | null; - error: boolean; - nodeNames: Map; -}) { - if (error) return
Packet feed unavailable
; - if (!data) return
Loading packets…
; - if (data.length === 0) return
No recent packets
; - - function resolveName(id?: string | null): string { - if (!id) return '—'; - return nodeNames.get(id.toLowerCase()) ?? id.slice(0, 8); - } - - function resolveObservers(p: RecentPacket): string { - const ids = p.observer_node_ids?.length ? p.observer_node_ids : (p.rx_node_id ? [p.rx_node_id] : []); - if (ids.length === 0) return '—'; - return ids.map(id => resolveName(id)).join(', '); - } - - function packetContent(p: RecentPacket): string | null { - const text = p.summary ?? extractPacketSummary(p.payload ?? undefined); - if (!text) return null; - if (text.includes('🚫')) return '[redacted]'; - return text; - } - - return ( - - - - - - - - - - - - - {data.map((p, i) => { - const content = packetContent(p); - return ( - - - - - - - - - ); - })} - -
TimeSourceHeard byTypeHopsContent
{timeAgo(p.time)}{resolveName(p.src_node_id)}{resolveObservers(p)}{p.packet_type != null ? (TYPE_LABELS[p.packet_type] ?? String(p.packet_type)) : '—'}{p.hop_count ?? '—'}{content ?? '—'}
- ); -} - -// ── Main component ──────────────────────────────────────────────────────────── - -export const TeessieDashboard: React.FC = () => { - const [lastUpdated, setLastUpdated] = useState(new Date()); - const tickRef = useRef | null>(null); - - useEffect(() => { - tickRef.current = setInterval(() => setLastUpdated(new Date()), 20_000); - // Swap favicon to Teesside amber version - const link = document.querySelector('link[rel~="icon"]'); - if (link) { - link.href = '/favicon-teesside.svg'; - } - document.title = 'Teesside Mesh · MME'; - return () => { if (tickRef.current) clearInterval(tickRef.current); }; - }, []); - - const { data: connectivity, error: connError } = - usePolled('/api/cross-network-connectivity', 60_000); - - const { data: statsRaw, error: statsError } = - usePolled>('/api/stats?network=teesside', 60_000); - - const statsData: StatsData | null = statsRaw - ? { - mqttNodes: statsRaw['mqttNodes'] ?? 0, - nodesDay: statsRaw['nodesDay'] ?? 0, - packetsDay: statsRaw['packetsDay'] ?? 0, - } - : null; - - const { data: radioData, error: radioError } = - usePolled('/api/radio-stats', 30_000); - - // Battery history for sparklines — fetched once per monitor, refreshed every 30 min - const [batteryHistory, setBatteryHistory] = useState>({}); - const monitorIds = radioData?.monitors.map(m => m.id).join(',') ?? ''; - useEffect(() => { - if (!radioData?.monitors.length) return; - const fetchAll = async () => { - const results: Record = {}; - await Promise.all(radioData.monitors.map(async (m) => { - try { - const res = await fetch(`/api/radio-history?target=${encodeURIComponent(m.nodeName)}&limit=168`); - if (res.ok) { - const data = await res.json() as { samples?: HistorySample[] }; - results[m.id] = data.samples ?? []; - } - } catch { /* ignore */ } - })); - setBatteryHistory(results); - }; - void fetchAll(); - const id = setInterval(() => void fetchAll(), 30 * 60 * 1000); - return () => clearInterval(id); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [monitorIds]); - - // MQTT nodes polled to supplement repeater cards with channel utilization - const { data: mqttNodes } = - usePolled('/api/node-status/latest?network=teesside', 30_000); - - // All known nodes (broader than mqttNodes) used to resolve names in packet feed - const { data: allNodes } = - usePolled<{ node_id: string; name: string | null }[]>('/api/nodes?network=teesside', 120_000); - - const nodeNames = React.useMemo(() => { - const map = new Map(); - for (const n of allNodes ?? []) { - if (n.name) map.set(n.node_id.toLowerCase(), n.name); - } - return map; - }, [allNodes]); - - const { data: observerActivity, error: observerError } = - usePolled('/api/observer-activity?network=teesside', 60_000); - - const { data: packets, error: packetError } = - usePolled('/api/packets/recent?limit=15&network=teesside', 20_000); - - return ( -
-
-

Teesside Mesh

- MME -
- -
-

UK Mesh Connectivity

- -
- -
-

Network Overview

- -
- -
-

Repeater Status

- -
- -
-

MQTT Observers

- -
- -
-

Recent Packets

- -
- -
-

Network Coverage

-
-
-

Coverage area

-

- Middlesbrough · Stockton-on-Tees · Hartlepool · Redcar & Cleveland · - Darlington corridor. The network sits between the Cleveland Hills to the - south and the North Sea coast to the east, using the Tees Valley as a - natural RF corridor. -

-
-
-

Key sites

-
-
- Lordstones-RPT - Cleveland Hills · ~380 m ASL - Elevated above the Tees plain — best LOS node on the network -
-
- Hartlepool-RPT - North Sea coast · TS24–TS26 - Eastern coastal reach toward the Tees estuary and Seal Sands -
-
-
-
-

About MME

-

- MME is the IATA code for{' '} - Teesside International Airport{' '} - near Darlington — used as this network's callsign. The airport sits at the - southern edge of the Tees plain, roughly midpoint between the Cleveland Hills - and the coast. -

-
-
-

RF geography

-

- Roseberry Topping (320 m), the Cleveland Hills escarpment, and the flat - industrial lowlands around the Tees estuary combine to give the network - a mix of high-gain hilltop coverage and urban valley fill. - The TS17 node at Stockton provides deep residential coverage where - the hilltop sites have ground clutter. -

-
-
-
- - -
- ); -}; diff --git a/frontend/src/styles/teesside-dashboard.css b/frontend/src/styles/teesside-dashboard.css deleted file mode 100644 index a48aa82..0000000 --- a/frontend/src/styles/teesside-dashboard.css +++ /dev/null @@ -1,335 +0,0 @@ -/* Teesside Dashboard — charcoal + amber accent */ -.teesside-dashboard { - --td-bg: #0c0c0e; - --td-panel: #131316; - --td-panel-alt: #18181c; - --td-border: #26262e; - --td-accent: #f59e0b; - --td-accent-dim: rgba(245, 158, 11, 0.12); - --td-online: #22c55e; - --td-warning: #f59e0b; - --td-danger: #ef4444; - --td-text: #e4e4e8; - --td-text-muted: #5c5c6b; - --td-batt-good: #22c55e; - --td-batt-warn: #f59e0b; - --td-batt-low: #ef4444; - - background: var(--td-bg); - color: var(--td-text); - min-height: 100vh; - font-family: system-ui, -apple-system, sans-serif; - font-size: 14px; - line-height: 1.5; -} - -/* Header — amber top-border gives the identity without painting everything brown */ -.td-header { - background: var(--td-panel); - border-top: 3px solid var(--td-accent); - border-bottom: 1px solid var(--td-border); - padding: 14px 24px; - display: flex; - align-items: center; - gap: 14px; -} - -.td-header__wordmark { - font-size: 16px; - font-weight: 700; - letter-spacing: 0.1em; - color: var(--td-text); - text-transform: uppercase; - margin: 0; -} - -.td-header__badge { - background: var(--td-accent); - color: #000; - font-size: 11px; - font-weight: 800; - letter-spacing: 0.12em; - padding: 2px 7px; - border-radius: 3px; -} - -/* Sections */ -.td-section { - padding: 20px 24px; - border-bottom: 1px solid var(--td-border); -} - -.td-section__title { - font-size: 10px; - font-weight: 600; - letter-spacing: 0.12em; - text-transform: uppercase; - color: var(--td-text-muted); - margin: 0 0 14px; -} - -/* Connectivity banner */ -.td-connectivity { - background: var(--td-panel); - border: 1px solid var(--td-border); - border-left: 3px solid var(--td-border); - border-radius: 6px; - padding: 18px 20px; - display: flex; - align-items: flex-start; - gap: 18px; -} - -.td-connectivity--green { border-left-color: var(--td-online); } -.td-connectivity--amber { border-left-color: var(--td-warning); } -.td-connectivity--red { border-left-color: var(--td-danger); } - -.td-connectivity__light { - font-size: 28px; - line-height: 1; - flex-shrink: 0; -} - -.td-connectivity__label { - font-size: 18px; - font-weight: 600; - margin: 0 0 8px; - color: var(--td-text); -} - -.td-connectivity__meta { - font-size: 12px; - color: var(--td-text-muted); - display: flex; - flex-wrap: wrap; - gap: 14px; - margin: 0; -} - -.td-connectivity__meta span { white-space: nowrap; } - -/* Stats cards row */ -.td-stats-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 10px; -} - -.td-stat-card { - background: var(--td-panel); - border: 1px solid var(--td-border); - border-radius: 6px; - padding: 14px 16px; -} - -.td-stat-card__value { - font-size: 26px; - font-weight: 700; - color: var(--td-accent); - line-height: 1; - margin: 0 0 5px; -} - -.td-stat-card__label { - font-size: 11px; - color: var(--td-text-muted); - text-transform: uppercase; - letter-spacing: 0.06em; - margin: 0; -} - -/* Repeater grid */ -.td-repeater-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - gap: 10px; -} - -.td-repeater-card { - background: var(--td-panel); - border: 1px solid var(--td-border); - border-radius: 6px; - padding: 14px; -} - -.td-repeater-card--offline { - opacity: 0.5; -} - -.td-repeater-card__header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 10px; -} - -.td-repeater-card__dot { - width: 7px; - height: 7px; - border-radius: 50%; - flex-shrink: 0; -} - -.td-repeater-card__dot--online { background: var(--td-online); box-shadow: 0 0 4px var(--td-online); } -.td-repeater-card__dot--offline { background: var(--td-text-muted); } - -.td-repeater-card__name { - font-weight: 600; - font-size: 13px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--td-text); -} - -.td-repeater-card__rows { - display: flex; - flex-direction: column; - gap: 5px; -} - -.td-repeater-card__row { - display: flex; - justify-content: space-between; - font-size: 12px; -} - -.td-repeater-card__row-label { - color: var(--td-text-muted); -} - -.td-batt-good { color: var(--td-batt-good); } -.td-batt-warn { color: var(--td-batt-warn); } -.td-batt-low { color: var(--td-batt-low); } - -/* Battery bar */ -.td-batt-bar { - height: 3px; - background: var(--td-border); - border-radius: 2px; - margin: 6px 0 8px; - overflow: hidden; -} - -.td-batt-bar__fill { - height: 100%; - border-radius: 2px; - transition: width 0.3s ease; -} - -/* Packet feed */ -.td-packet-table { - width: 100%; - border-collapse: collapse; - font-size: 12px; -} - -.td-packet-table th { - text-align: left; - color: var(--td-text-muted); - font-weight: 500; - padding: 4px 8px 8px 0; - border-bottom: 1px solid var(--td-border); - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.06em; -} - -.td-packet-table td { - padding: 5px 8px 5px 0; - border-bottom: 1px solid var(--td-border); - vertical-align: top; - color: var(--td-text); -} - -.td-packet-table tr:last-child td { - border-bottom: none; -} - -.td-muted { color: var(--td-text-muted); } -.td-packet-content { color: var(--td-text); max-width: 260px; word-break: break-word; } - -/* Footer */ -.td-footer { - padding: 14px 24px; - text-align: center; - font-size: 12px; - color: var(--td-text-muted); - border-top: 1px solid var(--td-border); -} - -/* Local network context */ -.td-local-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: 10px; -} - -.td-local-card { - background: var(--td-panel); - border: 1px solid var(--td-border); - border-radius: 6px; - padding: 14px; -} - -.td-local-card__heading { - font-size: 11px; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--td-accent); - margin: 0 0 8px; -} - -.td-local-card__body { - font-size: 12px; - color: var(--td-text-muted); - line-height: 1.6; - margin: 0; -} - -.td-local-highlight { - color: var(--td-text); -} - -.td-local-sites { - display: flex; - flex-direction: column; - gap: 10px; -} - -.td-local-site { - display: flex; - flex-direction: column; - gap: 2px; -} - -.td-local-site__name { - font-size: 12px; - font-weight: 600; - color: var(--td-text); -} - -.td-local-site__detail { - font-size: 11px; - color: var(--td-accent); -} - -.td-local-site__note { - font-size: 11px; - color: var(--td-text-muted); - line-height: 1.5; -} - -/* Loading / error */ -.td-loading { - color: var(--td-text-muted); - font-size: 13px; - padding: 12px 0; -} - -.td-error { - color: var(--td-danger); - font-size: 13px; - padding: 12px 0; -}