mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-09-09 16:45:33 +00:00
Remove Teesside Mesh site from repo
Teesside dashboard, CSS theme, and favicon are now gitignored — they live on the VM but are not part of the ukmesh.com project. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#0c0c0e"/>
|
||||
<!-- Cleveland Hills silhouette at base -->
|
||||
<path d="M 2 27 Q 7 22 11 24 Q 14 20 16 21 Q 18 19 21 22 Q 25 20 30 27 Z" fill="#1a1a1e"/>
|
||||
<!-- Signal source dot -->
|
||||
<circle cx="16" cy="22" r="2" fill="#f59e0b"/>
|
||||
<!-- Signal waves -->
|
||||
<path d="M 11.5 18.5 Q 11.5 13 16 13 Q 20.5 13 20.5 18.5" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M 7.5 20.5 Q 7.5 8 16 8 Q 24.5 8 24.5 20.5" fill="none" stroke="#f59e0b" stroke-width="1.75" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M 3.5 22 Q 3.5 3 16 3 Q 28.5 3 28.5 22" fill="none" stroke="#f59e0b" stroke-width="1.25" stroke-linecap="round" opacity="0.22"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 778 B |
@@ -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<string, unknown> | null;
|
||||
};
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const TYPE_LABELS: Record<number, string> = {
|
||||
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<T>(url: string, intervalMs: number): { data: T | null; error: boolean } {
|
||||
const [data, setData] = useState<T | null>(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 <div className="td-error">Unable to check connectivity</div>;
|
||||
if (!data) return <div className="td-loading">Checking connectivity…</div>;
|
||||
|
||||
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 (
|
||||
<div className={`td-connectivity ${borderClass}`}>
|
||||
<div className="td-connectivity__light">{lightEmoji}</div>
|
||||
<div>
|
||||
<p className="td-connectivity__label">{labelText}</p>
|
||||
<div className="td-connectivity__meta">
|
||||
<span>Inbound: {data.inbound ? `last ${timeAgo(data.lastInbound)}` : `none in ${data.windowHours}h`}</span>
|
||||
<span>Outbound: {data.outbound ? `last ${timeAgo(data.lastOutbound)}` : `none in ${data.windowHours}h`}</span>
|
||||
<span className="td-muted">Checked {timeAgo(data.checkedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsRow({ data, error }: { data: StatsData | null; error: boolean }) {
|
||||
if (error) return <div className="td-error">Stats unavailable</div>;
|
||||
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 (
|
||||
<div className="td-stats-row">
|
||||
{cards.map(c => (
|
||||
<div className="td-stat-card" key={c.label}>
|
||||
<p className="td-stat-card__value">{c.value}</p>
|
||||
<p className="td-stat-card__label">{c.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none"
|
||||
style={{ width: '100%', height: '44px', display: 'block', margin: '8px 0 4px' }}>
|
||||
{/* threshold lines */}
|
||||
<line x1="0" y1={y50} x2={W} y2={y50} stroke="var(--td-border)" strokeWidth="0.75" strokeDasharray="3,3" />
|
||||
<line x1="0" y1={y20} x2={W} y2={y20} stroke="var(--td-danger)" strokeWidth="0.75" strokeDasharray="3,3" opacity="0.4" />
|
||||
{/* area fill */}
|
||||
<polygon points={areaPts} fill={fillColor} />
|
||||
{/* line */}
|
||||
<polyline points={linePts} fill="none" stroke={strokeColor} strokeWidth="1.5" strokeLinejoin="round" />
|
||||
{/* latest dot */}
|
||||
<circle cx={px(last)} cy={py(last)} r="2.5" fill={strokeColor} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RepeaterGrid({
|
||||
radioData,
|
||||
radioError,
|
||||
batteryHistory,
|
||||
}: {
|
||||
radioData: RadioStatsData | null;
|
||||
radioError: boolean;
|
||||
batteryHistory: Record<string, HistorySample[]>;
|
||||
}) {
|
||||
if (radioError) return <div className="td-error">Repeater data unavailable</div>;
|
||||
if (!radioData) return <div className="td-loading">Loading repeaters…</div>;
|
||||
|
||||
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 (
|
||||
<div className="td-repeater-grid">
|
||||
{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 (
|
||||
<div key={monitor.id} className={`td-repeater-card${online ? '' : ' td-repeater-card--offline'}`}>
|
||||
<div className="td-repeater-card__header">
|
||||
<div className={`td-repeater-card__dot td-repeater-card__dot--${online ? 'online' : 'offline'}`} />
|
||||
<div className="td-repeater-card__name">{monitor.nodeName}</div>
|
||||
</div>
|
||||
|
||||
{pct != null && (
|
||||
<div className="td-batt-bar">
|
||||
<div className="td-batt-bar__fill" style={{ width: `${pct}%`, background: battBarColor(pct) }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BatterySparkline samples={batteryHistory[monitor.id] ?? []} />
|
||||
|
||||
<div className="td-repeater-card__rows">
|
||||
<div className="td-repeater-card__row">
|
||||
<span className="td-repeater-card__row-label">Battery</span>
|
||||
<span className={battClass(pct)}>{pct != null ? `${pct}%` : '—'}</span>
|
||||
</div>
|
||||
<div className="td-repeater-card__row">
|
||||
<span className="td-repeater-card__row-label">Last polled</span>
|
||||
<span className="td-muted">{timeAgo(monitor.lastSuccessAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ObserverGrid({
|
||||
activity,
|
||||
mqttNodes,
|
||||
error,
|
||||
}: {
|
||||
activity: ObserverActivity[] | null;
|
||||
mqttNodes: NodeStatusRow[] | null;
|
||||
error: boolean;
|
||||
}) {
|
||||
if (error) return <div className="td-error">Observer data unavailable</div>;
|
||||
if (!activity) return <div className="td-loading">Loading observers…</div>;
|
||||
|
||||
// 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 (
|
||||
<div className="td-repeater-grid">
|
||||
{merged.map(node => (
|
||||
<div key={node.node_id} className={`td-repeater-card${node.online ? '' : ' td-repeater-card--offline'}`}>
|
||||
<div className="td-repeater-card__header">
|
||||
<div className={`td-repeater-card__dot td-repeater-card__dot--${node.online ? 'online' : 'offline'}`} />
|
||||
<div className="td-repeater-card__name">{node.name ?? node.node_id.slice(0, 12)}</div>
|
||||
</div>
|
||||
<div className="td-repeater-card__rows">
|
||||
<div className="td-repeater-card__row">
|
||||
<span className="td-repeater-card__row-label">Received / 24h</span>
|
||||
<span>{node.rx_24h.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="td-repeater-card__row">
|
||||
<span className="td-repeater-card__row-label">Sent / 24h</span>
|
||||
<span>{node.tx_24h.toLocaleString()}</span>
|
||||
</div>
|
||||
{node.uptime_secs != null && (
|
||||
<div className="td-repeater-card__row">
|
||||
<span className="td-repeater-card__row-label">Uptime</span>
|
||||
<span>{fmtUptime(node.uptime_secs)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="td-repeater-card__row">
|
||||
<span className="td-repeater-card__row-label">Last seen</span>
|
||||
<span className="td-muted">{timeAgo(node.lastSeen)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PacketFeed({
|
||||
data,
|
||||
error,
|
||||
nodeNames,
|
||||
}: {
|
||||
data: RecentPacket[] | null;
|
||||
error: boolean;
|
||||
nodeNames: Map<string, string>;
|
||||
}) {
|
||||
if (error) return <div className="td-error">Packet feed unavailable</div>;
|
||||
if (!data) return <div className="td-loading">Loading packets…</div>;
|
||||
if (data.length === 0) return <div className="td-muted" style={{ padding: '12px 0' }}>No recent packets</div>;
|
||||
|
||||
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 (
|
||||
<table className="td-packet-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Source</th>
|
||||
<th>Heard by</th>
|
||||
<th>Type</th>
|
||||
<th>Hops</th>
|
||||
<th>Content</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((p, i) => {
|
||||
const content = packetContent(p);
|
||||
return (
|
||||
<tr key={p.packet_hash ?? i}>
|
||||
<td className="td-muted">{timeAgo(p.time)}</td>
|
||||
<td>{resolveName(p.src_node_id)}</td>
|
||||
<td>{resolveObservers(p)}</td>
|
||||
<td>{p.packet_type != null ? (TYPE_LABELS[p.packet_type] ?? String(p.packet_type)) : '—'}</td>
|
||||
<td>{p.hop_count ?? '—'}</td>
|
||||
<td className={content ? 'td-packet-content' : 'td-muted'}>{content ?? '—'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
export const TeessieDashboard: React.FC = () => {
|
||||
const [lastUpdated, setLastUpdated] = useState<Date>(new Date());
|
||||
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
tickRef.current = setInterval(() => setLastUpdated(new Date()), 20_000);
|
||||
// Swap favicon to Teesside amber version
|
||||
const link = document.querySelector<HTMLLinkElement>('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<ConnectivityData>('/api/cross-network-connectivity', 60_000);
|
||||
|
||||
const { data: statsRaw, error: statsError } =
|
||||
usePolled<Record<string, number>>('/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<RadioStatsData>('/api/radio-stats', 30_000);
|
||||
|
||||
// Battery history for sparklines — fetched once per monitor, refreshed every 30 min
|
||||
const [batteryHistory, setBatteryHistory] = useState<Record<string, HistorySample[]>>({});
|
||||
const monitorIds = radioData?.monitors.map(m => m.id).join(',') ?? '';
|
||||
useEffect(() => {
|
||||
if (!radioData?.monitors.length) return;
|
||||
const fetchAll = async () => {
|
||||
const results: Record<string, HistorySample[]> = {};
|
||||
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<NodeStatusRow[]>('/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<string, string>();
|
||||
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<ObserverActivity[]>('/api/observer-activity?network=teesside', 60_000);
|
||||
|
||||
const { data: packets, error: packetError } =
|
||||
usePolled<RecentPacket[]>('/api/packets/recent?limit=15&network=teesside', 20_000);
|
||||
|
||||
return (
|
||||
<div className="teesside-dashboard">
|
||||
<header className="td-header">
|
||||
<h1 className="td-header__wordmark">Teesside Mesh</h1>
|
||||
<span className="td-header__badge">MME</span>
|
||||
</header>
|
||||
|
||||
<section className="td-section">
|
||||
<p className="td-section__title">UK Mesh Connectivity</p>
|
||||
<ConnectivityBanner data={connectivity} error={connError} />
|
||||
</section>
|
||||
|
||||
<section className="td-section">
|
||||
<p className="td-section__title">Network Overview</p>
|
||||
<StatsRow data={statsData} error={statsError} />
|
||||
</section>
|
||||
|
||||
<section className="td-section">
|
||||
<p className="td-section__title">Repeater Status</p>
|
||||
<RepeaterGrid radioData={radioData} radioError={radioError} batteryHistory={batteryHistory} />
|
||||
</section>
|
||||
|
||||
<section className="td-section">
|
||||
<p className="td-section__title">MQTT Observers</p>
|
||||
<ObserverGrid activity={observerActivity} mqttNodes={mqttNodes} error={observerError} />
|
||||
</section>
|
||||
|
||||
<section className="td-section">
|
||||
<p className="td-section__title">Recent Packets</p>
|
||||
<PacketFeed data={packets} error={packetError} nodeNames={nodeNames} />
|
||||
</section>
|
||||
|
||||
<section className="td-section">
|
||||
<p className="td-section__title">Network Coverage</p>
|
||||
<div className="td-local-grid">
|
||||
<div className="td-local-card">
|
||||
<p className="td-local-card__heading">Coverage area</p>
|
||||
<p className="td-local-card__body">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="td-local-card">
|
||||
<p className="td-local-card__heading">Key sites</p>
|
||||
<div className="td-local-sites">
|
||||
<div className="td-local-site">
|
||||
<span className="td-local-site__name">Lordstones-RPT</span>
|
||||
<span className="td-local-site__detail">Cleveland Hills · ~380 m ASL</span>
|
||||
<span className="td-local-site__note">Elevated above the Tees plain — best LOS node on the network</span>
|
||||
</div>
|
||||
<div className="td-local-site">
|
||||
<span className="td-local-site__name">Hartlepool-RPT</span>
|
||||
<span className="td-local-site__detail">North Sea coast · TS24–TS26</span>
|
||||
<span className="td-local-site__note">Eastern coastal reach toward the Tees estuary and Seal Sands</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="td-local-card">
|
||||
<p className="td-local-card__heading">About MME</p>
|
||||
<p className="td-local-card__body">
|
||||
MME is the IATA code for{' '}
|
||||
<span className="td-local-highlight">Teesside International Airport</span>{' '}
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="td-local-card">
|
||||
<p className="td-local-card__heading">RF geography</p>
|
||||
<p className="td-local-card__body">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="td-footer">
|
||||
Teesside Mesh Network · MME · Updated {lastUpdated.toLocaleTimeString()}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user